Просмотр исходного кода

Migrate worker tests to Cypress (#7359)

GitOrigin-RevId: f373f4215e5f25d14256008cf5f6582eb3124431
Alf Eaton 4 лет назад
Родитель
Сommit
5e9af2c15c
48 измененных файлов с 1784 добавлено и 1962 удалено
  1. 133 475
      package-lock.json
  2. 1 1
      services/web/.eslintrc
  3. 2 0
      services/web/.gitignore
  4. 3 4
      services/web/cypress.json
  5. 0 0
      services/web/cypress/fixtures/build/output-2.pdf
  6. 0 0
      services/web/cypress/fixtures/build/output-corrupt.pdf
  7. 21 0
      services/web/cypress/fixtures/build/output-human-readable.log
  8. 10 0
      services/web/cypress/fixtures/build/output-undefined-references.log
  9. 1 0
      services/web/cypress/fixtures/build/output.blg
  10. 19 0
      services/web/cypress/fixtures/build/output.log
  11. 0 0
      services/web/cypress/fixtures/build/output.pdf
  12. 24 1
      services/web/cypress/plugins/index.js
  13. 0 3
      services/web/cypress/support/ct/index.js
  14. 5 0
      services/web/cypress/support/ct/index.ts
  15. 0 2
      services/web/cypress/support/ct/window.ts
  16. 3 0
      services/web/cypress/support/shared/commands.ts
  17. 68 0
      services/web/cypress/support/shared/compile.ts
  18. 5 0
      services/web/cypress/support/shared/events.ts
  19. 0 0
      services/web/cypress/support/shared/exceptions.ts
  20. 8 0
      services/web/cypress/support/shared/types.d.ts
  21. 1 1
      services/web/frontend/js/shared/components/beta-badge.js
  22. 3 0
      services/web/frontend/js/utils/worker.js
  23. 3 0
      services/web/package.json
  24. 75 0
      services/web/test/frontend/components/pdf-preview/detach-compile-button.spec.tsx
  25. 74 0
      services/web/test/frontend/components/pdf-preview/pdf-js-viewer.spec.tsx
  26. 143 0
      services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx
  27. 75 0
      services/web/test/frontend/components/pdf-preview/pdf-preview-detached-root.spec.tsx
  28. 102 0
      services/web/test/frontend/components/pdf-preview/pdf-preview-hybrid-toolbar.spec.tsx
  29. 599 0
      services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx
  30. 380 0
      services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx
  31. 13 0
      services/web/test/frontend/components/pdf-preview/scope.tsx
  32. 0 0
      services/web/test/frontend/components/shared/beta-badge.spec.tsx
  33. 0 5
      services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.test.js
  34. 0 2
      services/web/test/frontend/features/file-tree/components/file-tree-root.test.js
  35. 0 2
      services/web/test/frontend/features/file-tree/flows/create-folder.test.js
  36. 0 2
      services/web/test/frontend/features/file-tree/flows/rename-entity.test.js
  37. 0 51
      services/web/test/frontend/features/pdf-preview/components/detach-compile-button.test.js
  38. 0 37
      services/web/test/frontend/features/pdf-preview/components/pdf-js-viewer.test.js
  39. 0 120
      services/web/test/frontend/features/pdf-preview/components/pdf-logs-entries.test.js
  40. 0 70
      services/web/test/frontend/features/pdf-preview/components/pdf-preview-detached-root.test.js
  41. 0 74
      services/web/test/frontend/features/pdf-preview/components/pdf-preview-hybrid-toolbar.test.js
  42. 0 523
      services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js
  43. 0 437
      services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js
  44. 0 148
      services/web/test/frontend/features/pdf-preview/utils/mock-compile.js
  45. 1 1
      services/web/test/frontend/helpers/editor-providers.js
  46. 3 1
      services/web/tsconfig.json
  47. 5 0
      services/web/types/window.ts
  48. 4 2
      services/web/webpack.config.js

Разница между файлами не показана из-за своего большого размера
+ 133 - 475
package-lock.json


+ 1 - 1
services/web/.eslintrc

@@ -104,7 +104,7 @@
     },
     {
       // Cypress specific rules
-      "files": ["cypress/**/*.js", "**/test/frontend/**/*.spec.js"],
+      "files": ["cypress/**/*.{js,ts,tsx}", "**/test/frontend/**/*.spec.{js,ts,tsx}"],
       "extends": [
         "plugin:cypress/recommended"
       ]

+ 2 - 0
services/web/.gitignore

@@ -91,3 +91,5 @@ cypress/downloads/
 
 # Ace themes for conversion
 modules/source-editor/frontend/js/themes/ace/
+
+!**/fixtures/**/*.log

+ 3 - 4
services/web/cypress.json

@@ -1,11 +1,10 @@
 {
   "component": {
     "componentFolder": ".",
-    "testFiles": "./{test,modules/**/test}/frontend/components/**/*.spec.js",
-    "supportFile": "cypress/support/ct/index.js"
+    "testFiles": "./{test,modules/**/test}/frontend/components/**/*.spec.{js,ts,tsx}",
+    "supportFile": "cypress/support/ct/index.ts"
   },
-  "experimentalFetchPolyfill": true,
-  "fixturesFolder": false,
+  "fixturesFolder": "cypress/fixtures",
   "video": false,
   "viewportHeight": 800,
   "viewportWidth": 800

+ 0 - 0
services/web/test/frontend/features/pdf-preview/fixtures/test-example-2.pdf → services/web/cypress/fixtures/build/output-2.pdf


+ 0 - 0
services/web/test/frontend/features/pdf-preview/fixtures/test-example-corrupt.pdf → services/web/cypress/fixtures/build/output-corrupt.pdf


+ 21 - 0
services/web/cypress/fixtures/build/output-human-readable.log

@@ -0,0 +1,21 @@
+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
+entering extended mode
+ \write18 enabled.
+ %&-line parsing enabled.
+**main.tex
+(./main.tex
+LaTeX2e <2020-02-02> patch level 5
+
+LaTeX Warning: Reference `intorduction' on page 1 undefined on input line 11.
+
+
+LaTeX Warning: Reference `section1' on page 1 undefined on input line 13.
+
+[1
+
+{/usr/local/texlive/2020/texmf-var/fonts/map/pdftex/updmap/pdftex.map}] (/compi
+le/output.aux)
+
+LaTeX Warning: There were undefined references.
+
+ )

+ 10 - 0
services/web/cypress/fixtures/build/output-undefined-references.log

@@ -0,0 +1,10 @@
+Package rerunfilecheck Info: File `output.out' has not changed.
+(rerunfilecheck)             Checksum: 339DB29951BB30436898BC39909EA4FA;11265.
+
+Package rerunfilecheck Warning: File `output.brf' has changed.
+(rerunfilecheck)                Rerun to get bibliographical references right.
+
+Package rerunfilecheck Info: Checksums for `output.brf':
+(rerunfilecheck)             Before: D41D8CD98F00B204E9800998ECF8427E;0
+(rerunfilecheck)             After:  DF3260FAD3828D54C5E4E9337E97F7AF;4841.
+)

+ 1 - 0
services/web/cypress/fixtures/build/output.blg

@@ -0,0 +1 @@
+This is BibTeX, Version 4.0

+ 19 - 0
services/web/cypress/fixtures/build/output.log

@@ -0,0 +1,19 @@
+The LaTeX compiler output
+  * With a lot of details
+
+Wrapped in an HTML <pre> element with
+      preformatted text which is to be presented exactly
+            as written in the HTML file
+
+                                              (whitespace included™)
+
+The text is typically rendered using a non-proportional ("monospace") font.
+
+LaTeX Font Info:    External font `cmex10' loaded for size
+(Font)              <7> on input line 18.
+LaTeX Font Info:    External font `cmex10' loaded for size
+(Font)              <5> on input line 18.
+! Undefined control sequence.
+<recently read> \Zlpha
+
+ main.tex, line 23

+ 0 - 0
services/web/test/frontend/features/pdf-preview/fixtures/test-example.pdf → services/web/cypress/fixtures/build/output.pdf


+ 24 - 1
services/web/cypress/plugins/index.js

@@ -3,17 +3,40 @@ module.exports = (on, config) => {
     const { startDevServer } = require('@cypress/webpack-dev-server')
     const { merge } = require('webpack-merge')
     const path = require('path')
+    const webpack = require('webpack')
     const devConfig = require('../../webpack.config.dev')
 
     const webpackConfig = merge(devConfig, {
       devServer: {
-        static: path.join(__dirname, '../../../../public'),
+        static: path.join(__dirname, '../../public'),
       },
       stats: 'none',
+      plugins: [
+        new webpack.EnvironmentPlugin({
+          CYPRESS: true,
+        }),
+      ],
     })
 
     delete webpackConfig.devServer.client
 
+    webpackConfig.entry = {}
+    const addWorker = (name, importPath) => {
+      webpackConfig.entry[name] = require.resolve(importPath)
+    }
+
+    // add entrypoint under '/' for latex-linter worker
+    addWorker(
+      'latex-linter-worker',
+      '../../modules/source-editor/frontend/js/languages/latex/linter/latex-linter.worker.js'
+    )
+
+    // add entrypoints under '/' for pdfjs workers
+    const pdfjsVersions = ['pdfjs-dist210', 'pdfjs-dist213']
+    for (const name of pdfjsVersions) {
+      addWorker(name, `${name}/legacy/build/pdf.worker.js`)
+    }
+
     on('dev-server:start', options => {
       return startDevServer({ options, webpackConfig })
     })

+ 0 - 3
services/web/cypress/support/ct/index.js

@@ -1,3 +0,0 @@
-require('../../../frontend/stylesheets/style.less')
-require('../shared/exceptions')
-require('./i18n')

+ 5 - 0
services/web/cypress/support/ct/index.ts

@@ -0,0 +1,5 @@
+import '../../../frontend/stylesheets/style.less'
+import './window' // needs to be before i18n
+import '../../../frontend/js/i18n'
+import '../shared/commands'
+import '../shared/exceptions'

+ 0 - 2
services/web/cypress/support/ct/i18n.js → services/web/cypress/support/ct/window.ts

@@ -1,4 +1,2 @@
 window.i18n = { currentLangCode: 'en' }
 window.ExposedSettings = { appName: 'Overleaf' }
-
-require('../../../frontend/js/i18n')

+ 3 - 0
services/web/cypress/support/shared/commands.ts

@@ -0,0 +1,3 @@
+import '@testing-library/cypress/add-commands'
+import './compile'
+import './events'

+ 68 - 0
services/web/cypress/support/shared/compile.ts

@@ -0,0 +1,68 @@
+import { v4 as uuid } from 'uuid'
+
+const outputFiles = () => {
+  const build = uuid()
+
+  return [
+    {
+      path: 'output.pdf',
+      build,
+      url: `/build/${build}/output.pdf`,
+      type: 'pdf',
+    },
+    {
+      path: 'output.bbl',
+      build,
+      url: `/build/${build}/output.bbl`,
+      type: 'bbl',
+    },
+    {
+      path: 'output.bib',
+      build,
+      url: `/build/${build}/output.bib`,
+      type: 'bib',
+    },
+    {
+      path: 'example.txt',
+      build,
+      url: `/build/${build}/example.txt`,
+      type: 'txt',
+    },
+    {
+      path: 'output.log',
+      build,
+      url: `/build/${build}/output.log`,
+      type: 'log',
+    },
+    {
+      path: 'output.blg',
+      build,
+      url: `/build/${build}/output.blg`,
+      type: 'blg',
+    },
+  ]
+}
+
+Cypress.Commands.add('interceptCompile', (prefix = 'compile') => {
+  cy.intercept('POST', '/project/*/compile*', {
+    body: {
+      status: 'success',
+      clsiServerId: 'foo',
+      compileGroup: 'priority',
+      pdfDownloadDomain: 'https://clsi.test-overleaf.com',
+      outputFiles: outputFiles(),
+    },
+  }).as(`${prefix}`)
+
+  cy.intercept('/build/*/output.pdf*', {
+    fixture: 'build/output.pdf,null',
+  }).as(`${prefix}-pdf`)
+
+  cy.intercept('/build/*/output.log*', {
+    fixture: 'build/output.log',
+  }).as(`${prefix}-log`)
+
+  cy.intercept('/build/*/output.blg*', {
+    fixture: 'build/output.blg',
+  }).as(`${prefix}-blg`)
+})

+ 5 - 0
services/web/cypress/support/shared/events.ts

@@ -0,0 +1,5 @@
+Cypress.Commands.add('interceptEvents', () => {
+  cy.intercept('POST', '/event/*', {
+    statusCode: 204,
+  })
+})

+ 0 - 0
services/web/cypress/support/shared/exceptions.js → services/web/cypress/support/shared/exceptions.ts


+ 8 - 0
services/web/cypress/support/shared/types.d.ts

@@ -0,0 +1,8 @@
+// eslint-disable-next-line no-unused-vars
+declare namespace Cypress {
+  // eslint-disable-next-line no-unused-vars
+  interface Chainable {
+    interceptCompile(prefix?: string): void
+    interceptEvents(): void
+  }
+}

+ 1 - 1
services/web/frontend/js/shared/components/beta-badge.js

@@ -25,7 +25,7 @@ export default function BetaBadge({ tooltip, url = '/beta/participate' }) {
 }
 
 BetaBadge.propTypes = {
-  tooltip: PropTypes.exact({
+  tooltip: PropTypes.shape({
     id: PropTypes.string.isRequired,
     text: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
     placement: PropTypes.string,

+ 3 - 0
services/web/frontend/js/utils/worker.js

@@ -1,4 +1,7 @@
 export const createWorker = callback => {
+  if (process.env.CYPRESS) {
+    return callback()
+  }
   const webpackPublicPath = __webpack_public_path__
   __webpack_public_path__ = '/'
   callback()

+ 3 - 0
services/web/package.json

@@ -116,6 +116,7 @@
     "daterangepicker": "https://github.com/overleaf/daterangepicker/archive/e496d2d44ca53e208c930e4cb4bcf29bcefa4550.tar.gz",
     "downshift": "^6.1.0",
     "east": "^2.0.2",
+    "events": "^3.3.0",
     "express": "4.17.1",
     "express-bearer-token": "^2.4.0",
     "express-http-proxy": "^1.6.0",
@@ -218,6 +219,7 @@
     "@testing-library/react": "^11.2.7",
     "@testing-library/react-hooks": "^7.0.0",
     "@types/chai": "^4.3.0",
+    "@types/events": "^3.0.0",
     "@types/mocha": "^9.1.0",
     "@types/react": "^17.0.40",
     "@types/react-bootstrap": "^0.32.29",
@@ -264,6 +266,7 @@
     "fetch-mock": "^9.10.2",
     "glob": "^7.1.6",
     "handlebars-loader": "^1.7.1",
+    "html-webpack-plugin": "^5.5.0",
     "i18next-scanner": "^3.0.0",
     "jsdom": "^19.0.0",
     "jsdom-global": "^3.0.2",

+ 75 - 0
services/web/test/frontend/components/pdf-preview/detach-compile-button.spec.tsx

@@ -0,0 +1,75 @@
+import { mount } from '@cypress/react'
+import sysendTestHelper from '../../helpers/sysend'
+import { EditorProviders } from '../../helpers/editor-providers'
+import DetachCompileButton from '../../../../frontend/js/features/pdf-preview/components/detach-compile-button'
+import { mockScope } from './scope'
+
+describe('<DetachCompileButton/>', function () {
+  beforeEach(function () {
+    cy.interceptCompile()
+    cy.interceptEvents()
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+    sysendTestHelper.resetHistory()
+  })
+
+  it('detacher mode and not linked: does not show button ', function () {
+    cy.window().then(win => {
+      win.metaAttributesCache = new Map([['ol-detachRole', 'detacher']])
+    })
+
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <DetachCompileButton />
+      </EditorProviders>
+    )
+
+    cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
+  })
+
+  it('detacher mode and linked: show button ', function () {
+    cy.window().then(win => {
+      win.metaAttributesCache = new Map([['ol-detachRole', 'detacher']])
+    })
+
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <DetachCompileButton />
+      </EditorProviders>
+    ).then(() => {
+      sysendTestHelper.receiveMessage({
+        role: 'detached',
+        event: 'connected',
+      })
+    })
+
+    cy.findByRole('button', { name: 'Recompile' })
+  })
+
+  it('not detacher mode and linked: does not show button ', function () {
+    cy.window().then(win => {
+      win.metaAttributesCache = new Map([['ol-detachRole', 'detached']])
+    })
+
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <DetachCompileButton />
+      </EditorProviders>
+    ).then(() => {
+      sysendTestHelper.receiveMessage({
+        role: 'detacher',
+        event: 'connected',
+      })
+    })
+
+    cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
+  })
+})

+ 74 - 0
services/web/test/frontend/components/pdf-preview/pdf-js-viewer.spec.tsx

@@ -0,0 +1,74 @@
+import { mount, unmount } from '@cypress/react'
+import { EditorProviders } from '../../helpers/editor-providers'
+import PdfJsViewer from '../../../../frontend/js/features/pdf-preview/components/pdf-js-viewer'
+import { mockScope } from './scope'
+
+describe('<PdfJSViewer/>', function () {
+  beforeEach(function () {
+    cy.interceptCompile()
+    cy.interceptEvents()
+  })
+
+  it('loads all PDF pages', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfJsViewer url="/build/123/output.pdf" />
+        </div>
+      </EditorProviders>
+    )
+
+    cy.findByLabelText('Page 1')
+    cy.findByLabelText('Page 2')
+    cy.findByLabelText('Page 3')
+    cy.findByLabelText('Page 4').should('not.exist')
+
+    cy.contains('Your Paper')
+  })
+
+  it('renders pages in a "loading" state', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfJsViewer url="/build/123/output.pdf" />
+        </div>
+      </EditorProviders>
+    )
+
+    cy.findByLabelText('Loading…')
+  })
+
+  it('can be unmounted while loading a document', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfJsViewer url="/build/123/output.pdf" />
+        </div>
+      </EditorProviders>
+    )
+
+    unmount()
+  })
+
+  it('can be unmounted after loading a document', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfJsViewer url="/build/123/output.pdf" />
+        </div>
+      </EditorProviders>
+    )
+
+    cy.findByLabelText('Page 1')
+
+    unmount()
+  })
+})

+ 143 - 0
services/web/test/frontend/components/pdf-preview/pdf-logs-entries.spec.tsx

@@ -0,0 +1,143 @@
+import { mount } from '@cypress/react'
+import sysendTestHelper from '../../helpers/sysend'
+import { EditorProviders } from '../../helpers/editor-providers'
+import PdfLogsEntries from '../../../../frontend/js/features/pdf-preview/components/pdf-logs-entries'
+window.metaAttributesCache = new Map([['ol-debugPdfDetach', true]])
+
+describe('<PdfLogsEntries/>', function () {
+  const fakeEntity = { type: 'doc' }
+
+  const logEntries = [
+    {
+      file: 'main.tex',
+      line: 9,
+      column: 8,
+      level: 'error',
+      message: 'LaTeX Error',
+      content: 'See the LaTeX manual',
+      raw: '',
+      ruleId: 'hint_misplaced_alignment_tab_character',
+      key: '',
+    },
+  ]
+
+  let props
+
+  beforeEach(function () {
+    props = {
+      fileTreeManager: {
+        findEntityByPath: cy.stub().as('findEntityByPath').returns(fakeEntity),
+      },
+      editorManager: {
+        openDoc: cy.stub().as('openDoc'),
+      },
+    }
+
+    cy.interceptCompile()
+    cy.interceptEvents()
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+    sysendTestHelper.resetHistory()
+  })
+
+  it('displays human readable hint', function () {
+    mount(
+      <EditorProviders {...props}>
+        <PdfLogsEntries entries={logEntries} />
+      </EditorProviders>
+    )
+
+    cy.contains('You have placed an alignment tab character')
+  })
+
+  it('opens doc on click', function () {
+    mount(
+      <EditorProviders {...props}>
+        <PdfLogsEntries entries={logEntries} />
+      </EditorProviders>
+    )
+
+    cy.findByRole('button', {
+      name: 'Navigate to log position in source code: main.tex, 9',
+    })
+      .click()
+      .then(() => {
+        expect(props.fileTreeManager.findEntityByPath).to.be.calledOnce
+        expect(props.editorManager.openDoc).to.be.calledOnce
+        expect(props.editorManager.openDoc).to.be.calledWith(fakeEntity, {
+          gotoLine: 9,
+          gotoColumn: 8,
+        })
+      })
+  })
+
+  it('opens doc via detached action', function () {
+    cy.window().then(win => {
+      win.metaAttributesCache = new Map([['ol-detachRole', 'detacher']])
+    })
+
+    mount(
+      <EditorProviders {...props}>
+        <PdfLogsEntries entries={logEntries} />
+      </EditorProviders>
+    ).then(() => {
+      sysendTestHelper.receiveMessage({
+        role: 'detached',
+        event: 'action-sync-to-entry',
+        data: {
+          args: [
+            {
+              file: 'main.tex',
+              line: 7,
+              column: 6,
+            },
+          ],
+        },
+      })
+
+      expect(props.fileTreeManager.findEntityByPath).to.be.calledOnce
+      expect(props.editorManager.openDoc).to.be.calledOnce
+      expect(props.editorManager.openDoc).to.be.calledWith(fakeEntity, {
+        gotoLine: 7,
+        gotoColumn: 6,
+      })
+    })
+  })
+
+  it('sends open doc clicks via detached action', function () {
+    cy.window().then(win => {
+      win.metaAttributesCache = new Map([['ol-detachRole', 'detached']])
+    })
+
+    mount(
+      <EditorProviders {...props}>
+        <PdfLogsEntries entries={logEntries} />
+      </EditorProviders>
+    )
+
+    cy.findByRole('button', {
+      name: 'Navigate to log position in source code: main.tex, 9',
+    })
+      .click()
+      .then(() => {
+        expect(props.fileTreeManager.findEntityByPath).not.to.be.called
+        expect(props.editorManager.openDoc).not.to.be.called
+
+        expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
+          role: 'detached',
+          event: 'action-sync-to-entry',
+          data: {
+            args: [
+              {
+                file: 'main.tex',
+                line: 9,
+                column: 8,
+              },
+            ],
+          },
+        })
+      })
+  })
+})

+ 75 - 0
services/web/test/frontend/components/pdf-preview/pdf-preview-detached-root.spec.tsx

@@ -0,0 +1,75 @@
+import { mount } from '@cypress/react'
+import sysendTestHelper from '../../helpers/sysend'
+import PdfPreviewDetachedRoot from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-detached-root'
+
+describe('<PdfPreviewDetachedRoot/>', function () {
+  beforeEach(function () {
+    window.user = { id: 'user1' }
+
+    window.metaAttributesCache = new Map<string, unknown>([
+      ['ol-user', window.user],
+      ['ol-project_id', 'project1'],
+      ['ol-detachRole', 'detached'],
+      ['ol-projectName', 'Project Name'],
+    ])
+
+    cy.interceptCompile()
+    cy.interceptEvents()
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+    sysendTestHelper.resetHistory()
+  })
+
+  it('syncs compiling state', function () {
+    mount(<PdfPreviewDetachedRoot />).then(() => {
+      sysendTestHelper.receiveMessage({
+        role: 'detacher',
+        event: 'connected',
+      })
+
+      sysendTestHelper.receiveMessage({
+        role: 'detacher',
+        event: 'state-compiling',
+        data: { value: true },
+      })
+    })
+
+    cy.findByRole('button', { name: 'Compiling…' })
+    cy.findByRole('button', { name: 'Recompile' })
+      .should('not.exist')
+      .then(() => {
+        sysendTestHelper.receiveMessage({
+          role: 'detacher',
+          event: 'state-compiling',
+          data: { value: false },
+        })
+      })
+    cy.findByRole('button', { name: 'Recompile' })
+    cy.findByRole('button', { name: 'Compiling…' }).should('not.exist')
+  })
+
+  it('sends a clear cache request when the button is pressed', function () {
+    mount(<PdfPreviewDetachedRoot />).then(() => {
+      sysendTestHelper.receiveMessage({
+        role: 'detacher',
+        event: 'state-showLogs',
+        data: { value: true },
+      })
+    })
+
+    cy.findByRole('button', { name: 'Clear cached files' })
+      .should('not.be.disabled')
+      .click()
+      .then(() => {
+        expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
+          role: 'detached',
+          event: 'action-clearCache',
+          data: {
+            args: [],
+          },
+        })
+      })
+  })
+})

+ 102 - 0
services/web/test/frontend/components/pdf-preview/pdf-preview-hybrid-toolbar.spec.tsx

@@ -0,0 +1,102 @@
+import { mount } from '@cypress/react'
+import sysendTestHelper from '../../helpers/sysend'
+import { EditorProviders } from '../../helpers/editor-providers'
+import PdfPreviewHybridToolbar from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
+
+describe('<PdfPreviewHybridToolbar/>', function () {
+  beforeEach(function () {
+    cy.interceptCompile()
+    cy.interceptEvents()
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+    sysendTestHelper.resetHistory()
+  })
+
+  it('shows normal mode', function () {
+    mount(
+      <EditorProviders>
+        <PdfPreviewHybridToolbar />
+      </EditorProviders>
+    )
+
+    cy.findByRole('button', { name: 'Recompile' })
+  })
+
+  describe('orphan mode', function () {
+    it('shows connecting message  on load', function () {
+      cy.window().then(win => {
+        win.metaAttributesCache = new Map([['ol-detachRole', 'detached']])
+      })
+
+      mount(
+        <EditorProviders>
+          <PdfPreviewHybridToolbar />
+        </EditorProviders>
+      )
+
+      cy.contains('Connecting with the editor')
+    })
+
+    it('shows compile UI when connected', function () {
+      cy.window().then(win => {
+        win.metaAttributesCache = new Map([['ol-detachRole', 'detached']])
+      })
+
+      mount(
+        <EditorProviders>
+          <PdfPreviewHybridToolbar />
+        </EditorProviders>
+      ).then(() => {
+        sysendTestHelper.receiveMessage({
+          role: 'detacher',
+          event: 'connected',
+        })
+      })
+
+      cy.findByRole('button', { name: 'Recompile' })
+    })
+
+    it('shows connecting message when disconnected', function () {
+      cy.window().then(win => {
+        win.metaAttributesCache = new Map([['ol-detachRole', 'detached']])
+      })
+
+      mount(
+        <EditorProviders>
+          <PdfPreviewHybridToolbar />
+        </EditorProviders>
+      ).then(() => {
+        sysendTestHelper.receiveMessage({
+          role: 'detacher',
+          event: 'connected',
+        })
+        sysendTestHelper.receiveMessage({
+          role: 'detacher',
+          event: 'closed',
+        })
+      })
+
+      cy.contains('Connecting with the editor')
+    })
+
+    it('shows redirect button after timeout', function () {
+      cy.window().then(win => {
+        win.metaAttributesCache = new Map([['ol-detachRole', 'detached']])
+      })
+
+      cy.clock()
+
+      mount(
+        <EditorProviders>
+          <PdfPreviewHybridToolbar />
+        </EditorProviders>
+      )
+
+      cy.tick(6000)
+
+      cy.findByRole('button', { name: 'Redirect to editor' })
+    })
+  })
+})

+ 599 - 0
services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx

@@ -0,0 +1,599 @@
+import { mount } from '@cypress/react'
+import localStorage from '../../../../frontend/js/infrastructure/local-storage'
+import PdfPreview from '../../../../frontend/js/features/pdf-preview/components/pdf-preview'
+import { EditorProviders } from '../../helpers/editor-providers'
+import { mockScope } from './scope'
+
+const storeAndFireEvent = (win, key, value) => {
+  localStorage.setItem(key, value)
+  win.dispatchEvent(new StorageEvent('storage', { key }))
+}
+
+describe('<PdfPreview/>', function () {
+  beforeEach(function () {
+    cy.interceptCompile()
+    cy.interceptEvents()
+  })
+
+  it('renders the PDF preview', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfPreview />
+        </div>
+      </EditorProviders>
+    )
+
+    // wait for "compile on load" to finish
+    cy.findByRole('button', { name: 'Compiling…' })
+    cy.wait('@compile')
+    cy.findByRole('button', { name: 'Recompile' })
+    cy.wait('@compile-pdf')
+  })
+
+  it('runs a compile when the Recompile button is pressed', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfPreview />
+        </div>
+      </EditorProviders>
+    )
+
+    // wait for "compile on load" to finish
+    cy.findByRole('button', { name: 'Compiling…' })
+    cy.wait('@compile')
+    cy.wait('@compile-pdf')
+
+    cy.interceptCompile('recompile')
+
+    // press the Recompile button => compile
+    cy.findByRole('button', { name: 'Recompile' }).click()
+
+    // wait for "recompile" to finish
+    // cy.findByRole('button', { name: 'Compiling…' })
+    cy.wait('@recompile-pdf')
+    cy.wait('@recompile-log')
+    cy.wait('@recompile-blg')
+
+    cy.findByRole('button', { name: 'Recompile' })
+
+    cy.contains('Your Paper')
+  })
+
+  it('runs a compile on `pdf:recompile` event', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfPreview />
+        </div>
+      </EditorProviders>
+    )
+
+    // wait for "compile on load" to finish
+    cy.findByRole('button', { name: 'Compiling…' })
+    cy.wait('@compile')
+    cy.wait('@compile-pdf')
+
+    cy.interceptCompile('recompile')
+
+    cy.window().then(win => {
+      win.dispatchEvent(new CustomEvent('pdf:recompile'))
+    })
+
+    // wait for "recompile" to finish
+    // cy.findByRole('button', { name: 'Compiling…' })
+    cy.wait('@recompile')
+
+    cy.findByRole('button', { name: 'Recompile' })
+
+    cy.wait('@recompile-pdf')
+    cy.contains('Your Paper')
+  })
+
+  it('does not compile while compiling', function () {
+    let compileResolve
+    let counter = 0
+
+    const promise = new Promise(resolve => {
+      compileResolve = resolve
+    })
+
+    cy.intercept(
+      'POST',
+      '/project/project123/compile?auto_compile=true',
+      req => {
+        counter++
+
+        promise.then(() => {
+          req.reply({
+            body: {
+              status: 'success',
+              clsiServerId: 'foo',
+              compileGroup: 'priority',
+              pdfDownloadDomain: 'https://clsi.test-overleaf.com',
+              outputFiles: [
+                {
+                  path: 'output.pdf',
+                  build: '123',
+                  url: '/build/123/output.pdf',
+                  type: 'pdf',
+                },
+                {
+                  path: 'output.log',
+                  build: '123',
+                  url: '/build/123/output.log',
+                  type: 'log',
+                },
+              ],
+            },
+          })
+        })
+
+        return promise
+      }
+    ).as('compile')
+
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfPreview />
+        </div>
+      </EditorProviders>
+    ).then(() => {
+      cy.findByRole('button', { name: 'Compiling…' })
+
+      cy.window().then(win => {
+        win.dispatchEvent(new CustomEvent('pdf:recompile'))
+      })
+
+      compileResolve()
+
+      cy.findByRole('button', { name: 'Recompile' })
+
+      cy.contains('Your Paper').should(() => {
+        expect(counter).to.equal(1)
+      })
+    })
+  })
+
+  it('disables compile button while compile is running', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfPreview />
+        </div>
+      </EditorProviders>
+    )
+
+    cy.findByRole('button', { name: 'Compiling…' }).should('be.disabled')
+    cy.findByRole('button', { name: 'Recompile' }).should('not.be.disabled')
+  })
+
+  it('runs a compile on doc change if autocompile is enabled', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfPreview />
+        </div>
+      </EditorProviders>
+    )
+
+    // wait for "compile on load" to finish
+    cy.findByRole('button', { name: 'Compiling…' })
+    cy.wait('@compile')
+    cy.findByRole('button', { name: 'Recompile' })
+
+    cy.window().then(win => {
+      cy.clock()
+
+      // switch on auto compile
+      storeAndFireEvent(win, 'autocompile_enabled:project123', true)
+
+      // fire a doc:changed event => compile
+      win.dispatchEvent(new CustomEvent('doc:changed'))
+
+      cy.tick(5000) // AUTO_COMPILE_DEBOUNCE
+
+      cy.clock().invoke('restore')
+    })
+
+    cy.findByRole('button', { name: 'Compiling…' })
+    cy.findByRole('button', { name: 'Recompile' })
+  })
+
+  it('does not run a compile on doc change if autocompile is disabled', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfPreview />
+        </div>
+      </EditorProviders>
+    )
+
+    // wait for "compile on load" to finish
+    cy.findByRole('button', { name: 'Compiling…' })
+    cy.findByRole('button', { name: 'Recompile' })
+
+    cy.window().then(win => {
+      cy.clock()
+
+      // make sure auto compile is switched off
+      storeAndFireEvent(win, 'autocompile_enabled:project123', false)
+
+      // fire a doc:changed event => no compile
+      win.dispatchEvent(new CustomEvent('doc:changed'))
+
+      cy.tick(5000) // AUTO_COMPILE_DEBOUNCE
+
+      cy.clock().invoke('restore')
+    })
+
+    cy.findByRole('button', { name: 'Recompile' })
+  })
+
+  it('does not run a compile on doc change if autocompile is blocked by syntax check', function () {
+    const scope = mockScope()
+    // enable linting in the editor
+    scope.settings.syntaxValidation = true
+    // mock a linting error
+    scope.hasLintingError = true
+
+    mount(
+      <EditorProviders scope={scope}>
+        <div className="pdf-viewer">
+          <PdfPreview />
+        </div>
+      </EditorProviders>
+    )
+
+    // wait for "compile on load" to finish
+    cy.findByRole('button', { name: 'Compiling…' })
+    cy.findByRole('button', { name: 'Recompile' })
+
+    cy.window().then(win => {
+      cy.clock()
+
+      // switch on auto compile
+      storeAndFireEvent(win, 'autocompile_enabled:project123', true)
+
+      // switch on syntax checking
+      storeAndFireEvent(win, 'stop_on_validation_error', true)
+
+      // fire a doc:changed event => no compile
+      win.dispatchEvent(new CustomEvent('doc:changed'))
+
+      cy.tick(5000) // AUTO_COMPILE_DEBOUNCE
+
+      cy.clock().invoke('restore')
+    })
+
+    cy.findByRole('button', { name: 'Recompile' })
+    cy.findByText('Code check failed')
+  })
+
+  describe('displays error messages', function () {
+    const compileErrorStatuses = {
+      'clear-cache':
+        'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
+      'clsi-maintenance':
+        'The compile servers are down for maintenance, and will be back shortly.',
+      'compile-in-progress':
+        'A previous compile is still running. Please wait a minute and try compiling again.',
+      exited: 'Server Error',
+      failure: 'No PDF',
+      generic: 'Server Error',
+      'project-too-large': 'Project too large',
+      'rate-limited': 'Compile rate limit hit',
+      terminated: 'Compilation cancelled',
+      timedout: 'Timed out',
+      'too-recently-compiled':
+        'This project was compiled very recently, so this compile has been skipped.',
+      unavailable:
+        'Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.',
+      foo: 'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
+    }
+
+    for (const [status, message] of Object.entries(compileErrorStatuses)) {
+      it(`displays error message for '${status}' status`, function () {
+        cy.intercept('POST', '/project/*/compile?*', {
+          body: {
+            status,
+            clsiServerId: 'foo',
+            compileGroup: 'priority',
+          },
+        }).as('compile')
+
+        const scope = mockScope()
+
+        mount(
+          <EditorProviders scope={scope}>
+            <div className="pdf-viewer">
+              <PdfPreview />
+            </div>
+          </EditorProviders>
+        )
+
+        // wait for "compile on load" to finish
+        cy.findByRole('button', { name: 'Compiling…' })
+        cy.findByRole('button', { name: 'Recompile' })
+
+        cy.findByText(message)
+      })
+    }
+
+    it('displays expandable raw logs', function () {
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <div className="pdf-viewer">
+            <PdfPreview />
+          </div>
+        </EditorProviders>
+      )
+
+      // wait for "compile on load" to finish
+      cy.findByRole('button', { name: 'Compiling…' })
+      cy.findByRole('button', { name: 'Recompile' })
+
+      cy.findByRole('button', { name: 'View logs' }).click()
+      cy.findByRole('button', { name: 'View PDF' })
+
+      cy.findByRole('button', { name: 'Expand' }).click()
+      cy.findByRole('button', { name: 'Collapse' }).click()
+    })
+
+    it('displays error messages if there were validation problems', function () {
+      const validationProblems = {
+        sizeCheck: {
+          resources: [
+            { path: 'foo/bar', kbSize: 76221 },
+            { path: 'bar/baz', kbSize: 2342 },
+          ],
+        },
+        mainFile: true,
+        conflictedPaths: [
+          {
+            path: 'foo/bar',
+          },
+          {
+            path: 'foo/baz',
+          },
+        ],
+      }
+
+      cy.intercept('POST', '/project/*/compile?*', {
+        body: {
+          status: 'validation-problems',
+          validationProblems,
+          clsiServerId: 'foo',
+          compileGroup: 'priority',
+        },
+      }).as('compile')
+
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <div className="pdf-viewer">
+            <PdfPreview />
+          </div>
+        </EditorProviders>
+      )
+
+      // wait for "compile on load" to finish
+      cy.findByRole('button', { name: 'Compiling…' })
+      cy.findByRole('button', { name: 'Recompile' })
+
+      cy.wait('@compile')
+
+      cy.findByText('Project too large')
+      cy.findByText('Unknown main document')
+      cy.findByText('Conflicting Paths Found')
+    })
+
+    it('sends a clear cache request when the button is pressed', function () {
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <div className="pdf-viewer">
+            <PdfPreview />
+          </div>
+        </EditorProviders>
+      )
+
+      // wait for "compile on load" to finish
+      cy.findByRole('button', { name: 'Compiling…' })
+      cy.findByRole('button', { name: 'Recompile' })
+
+      cy.findByRole('button', { name: 'View logs' }).click()
+      cy.findByRole('button', { name: 'Clear cached files' }).should(
+        'not.be.disabled'
+      )
+
+      cy.intercept('DELETE', 'project/*/output?*', {
+        statusCode: 204,
+        delay: 100,
+      }).as('clear-cache')
+
+      // click the button
+      cy.findByRole('button', { name: 'Clear cached files' }).click()
+      cy.findByRole('button', { name: 'Clear cached files' }).should(
+        'be.disabled'
+      )
+      cy.wait('@clear-cache')
+      cy.findByRole('button', { name: 'Clear cached files' }).should(
+        'not.be.disabled'
+      )
+    })
+
+    it('handle "recompile from scratch"', function () {
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <div className="pdf-viewer">
+            <PdfPreview />
+          </div>
+        </EditorProviders>
+      )
+
+      // wait for "compile on load" to finish
+      cy.findByRole('button', { name: 'Compiling…' })
+      cy.findByRole('button', { name: 'Recompile' })
+
+      // show the logs UI
+      cy.findByRole('button', { name: 'View logs' }).click()
+
+      cy.findByRole('button', { name: 'Clear cached files' }).should(
+        'not.be.disabled'
+      )
+
+      cy.interceptCompile()
+
+      cy.intercept('DELETE', 'project/*/output?*', {
+        statusCode: 204,
+        delay: 100,
+      }).as('clear-cache')
+
+      // TODO: open the menu?
+      cy.findByRole('menuitem', {
+        name: 'Recompile from scratch',
+        hidden: true,
+      }).trigger('click', { force: true })
+
+      cy.findByRole('button', { name: 'Clear cached files' }).should(
+        'be.disabled'
+      )
+
+      cy.findByRole('button', { name: 'Compiling…' })
+      cy.wait('@clear-cache')
+      cy.findByRole('button', { name: 'Recompile' })
+
+      cy.wait('@compile')
+      cy.wait('@compile-pdf')
+    })
+
+    it('shows an error for an invalid URL', function () {
+      cy.intercept('/build/*/output.pdf?*', {
+        statusCode: 500,
+        body: {
+          message: 'something awful happened',
+          code: 'AWFUL_ERROR',
+        },
+      }).as('compile-pdf-error')
+
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <div className="pdf-viewer">
+            <PdfPreview />
+          </div>
+        </EditorProviders>
+      )
+
+      cy.wait('@compile-pdf-error')
+
+      cy.findByText('Something went wrong while rendering this PDF.')
+      cy.findByLabelText('Page 1').should('not.exist')
+    })
+
+    it('shows an error for a corrupt PDF', function () {
+      cy.intercept('/build/*/output.pdf?*', {
+        fixture: 'build/output-corrupt.pdf,null',
+      }).as('compile-pdf-corrupt')
+
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <div className="pdf-viewer">
+            <PdfPreview />
+          </div>
+        </EditorProviders>
+      )
+
+      cy.wait('@compile-pdf-corrupt')
+
+      cy.findByText('Something went wrong while rendering this PDF.')
+      cy.findByLabelText('Page 1').should('not.exist')
+    })
+  })
+
+  describe('human readable logs', function () {
+    it('shows human readable hint for undefined reference errors', function () {
+      cy.intercept('/build/*/output.log?*', {
+        fixture: 'build/output-human-readable.log',
+      }).as('log')
+
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <div className="pdf-viewer">
+            <PdfPreview />
+          </div>
+        </EditorProviders>
+      )
+
+      cy.wait('@log')
+      cy.findByRole('button', { name: 'View logs' }).click()
+
+      cy.findByText(
+        "Reference `intorduction' on page 1 undefined on input line 11."
+      )
+      cy.findByText(
+        "Reference `section1' on page 1 undefined on input line 13."
+      )
+      cy.findByText('There were undefined references.')
+
+      cy.findAllByText(
+        /You have referenced something which has not yet been labelled/
+      ).should('have.length', 3)
+    })
+
+    it('does not show human readable hint when no undefined reference errors', function () {
+      cy.intercept('/build/*/output.log?*', {
+        fixture: 'build/output-undefined-references.log',
+      }).as('log')
+
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <div className="pdf-viewer">
+            <PdfPreview />
+          </div>
+        </EditorProviders>
+      )
+
+      cy.wait('@log')
+      cy.findByRole('button', { name: 'View logs' }).click()
+
+      cy.findByText(
+        "Package rerunfilecheck Warning: File `output.brf' has changed. Rerun to get bibliographical references right."
+      )
+
+      cy.findByText(
+        /You have referenced something which has not yet been labelled/
+      ).should('not.exist')
+    })
+  })
+})

+ 380 - 0
services/web/test/frontend/components/pdf-preview/pdf-synctex-controls.spec.tsx

@@ -0,0 +1,380 @@
+import PdfSynctexControls from '../../../../frontend/js/features/pdf-preview/components/pdf-synctex-controls'
+import sysendTestHelper from '../../helpers/sysend'
+import { cloneDeep } from 'lodash'
+import { useDetachCompileContext as useCompileContext } from '../../../../frontend/js/shared/context/detach-compile-context'
+import { useFileTreeData } from '../../../../frontend/js/shared/context/file-tree-data-context'
+import { useEffect } from 'react'
+import { mount } from '@cypress/react'
+import { EditorProviders } from '../../helpers/editor-providers'
+import { mockScope } from './scope'
+
+const mockHighlights = [
+  {
+    page: 1,
+    h: 85.03936,
+    v: 509.999878,
+    width: 441.921265,
+    height: 8.855677,
+  },
+  {
+    page: 1,
+    h: 85.03936,
+    v: 486.089539,
+    width: 441.921265,
+    height: 8.855677,
+  },
+]
+
+const mockPosition = {
+  page: 1,
+  offset: { top: 10, left: 10 },
+  pageSize: { height: 500, width: 500 },
+}
+
+const mockSelectedEntities = [{ type: 'doc' }]
+
+const WithPosition = ({ mockPosition }) => {
+  const { setPosition } = useCompileContext()
+
+  // mock PDF scroll position update
+  useEffect(() => {
+    setPosition(mockPosition)
+  }, [mockPosition, setPosition])
+
+  return null
+}
+
+const WithSelectedEntities = ({ mockSelectedEntities = [] }) => {
+  const { setSelectedEntities } = useFileTreeData()
+
+  useEffect(() => {
+    setSelectedEntities(mockSelectedEntities)
+  }, [mockSelectedEntities, setSelectedEntities])
+
+  return null
+}
+describe('<PdfSynctexControls/>', function () {
+  beforeEach(function () {
+    window.metaAttributesCache = new Map()
+
+    cy.interceptCompile()
+    cy.interceptEvents()
+
+    cy.intercept('/project/*/sync/code?*', {
+      body: { pdf: cloneDeep(mockHighlights) },
+      delay: 100,
+    }).as('sync-code')
+
+    cy.intercept('/project/*/sync/pdf?*', {
+      body: { code: [{ file: 'main.tex', line: 100 }] },
+      delay: 100,
+    }).as('sync-pdf')
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+  })
+
+  it('handles clicks on sync buttons', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <WithPosition mockPosition={mockPosition} />
+        <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
+        <PdfSynctexControls />
+      </EditorProviders>
+    )
+
+    cy.get('.synctex-control-icon').should('have.length', 2)
+
+    // mock editor cursor position update
+    cy.window().then(win => {
+      win.dispatchEvent(
+        new CustomEvent('cursor:editor:update', {
+          detail: { row: 100, column: 10 },
+        })
+      )
+    })
+
+    cy.get('body')
+      .findByRole('button', { name: 'Go to code location in PDF' })
+      .click()
+    cy.get('body')
+      .findByRole('button', { name: 'Go to code location in PDF' })
+      .should('be.disabled')
+
+    cy.wait('@sync-code')
+
+    cy.get('body')
+      .findByRole('button', { name: /^Go to PDF location in code/ })
+      .click()
+    cy.get('body')
+      .findByRole('button', { name: /^Go to PDF location in code/ })
+      .should('be.disabled')
+
+    cy.wait('@sync-pdf')
+  })
+
+  it('disables button when multiple entities are selected', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <WithPosition mockPosition={mockPosition} />
+        <WithSelectedEntities
+          mockSelectedEntities={[{ type: 'doc' }, { type: 'doc' }]}
+        />
+        <PdfSynctexControls />
+      </EditorProviders>
+    )
+
+    cy.get('body')
+      .findByRole('button', { name: 'Go to code location in PDF' })
+      .should('be.disabled')
+
+    cy.get('body')
+      .findByRole('button', { name: /^Go to PDF location in code/ })
+      .should('be.disabled')
+  })
+
+  it('disables button when a file is selected', function () {
+    const scope = mockScope()
+
+    mount(
+      <EditorProviders scope={scope}>
+        <WithPosition mockPosition={mockPosition} />
+        <WithSelectedEntities mockSelectedEntities={[{ type: 'file' }]} />
+        <PdfSynctexControls />
+      </EditorProviders>
+    )
+
+    cy.get('body')
+      .findByRole('button', { name: 'Go to code location in PDF' })
+      .should('be.disabled')
+
+    cy.get('body')
+      .findByRole('button', { name: /^Go to PDF location in code/ })
+      .should('be.disabled')
+  })
+
+  describe('with detacher role', function () {
+    beforeEach(function () {
+      window.metaAttributesCache.set('ol-detachRole', 'detacher')
+    })
+
+    it('does not have go to PDF location button nor arrow icon', function () {
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <WithPosition mockPosition={mockPosition} />
+          <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
+          <PdfSynctexControls />
+        </EditorProviders>
+      )
+
+      cy.get('body')
+        .findByRole('button', { name: /^Go to PDF location in code/ })
+        .should('not.exist')
+
+      cy.get('.synctex-control-icon').should('not.exist')
+    })
+
+    it('send set highlights action', function () {
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <WithPosition mockPosition={mockPosition} />
+          <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
+          <PdfSynctexControls />
+        </EditorProviders>
+      ).then(() => {
+        sysendTestHelper.resetHistory()
+      })
+
+      cy.wait('@compile')
+
+      // mock editor cursor position update
+      cy.window().then(win => {
+        win.dispatchEvent(
+          new CustomEvent('cursor:editor:update', {
+            detail: { row: 100, column: 10 },
+          })
+        )
+      })
+
+      cy.findByRole('button', {
+        name: 'Go to code location in PDF',
+      })
+        .should('not.be.disabled')
+        .click()
+
+      cy.findByRole('button', {
+        name: 'Go to code location in PDF',
+      }).should('be.disabled')
+
+      cy.wait('@sync-code').then(() => {
+        // synctex is called locally and the result are broadcast for the detached tab
+        expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
+          role: 'detacher',
+          event: 'action-setHighlights',
+          data: { args: [mockHighlights] },
+        })
+      })
+    })
+
+    it('reacts to sync to code action', function () {
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <WithPosition mockPosition={mockPosition} />
+          <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
+          <PdfSynctexControls />
+        </EditorProviders>
+      )
+
+      cy.wait('@compile').then(() => {
+        sysendTestHelper.receiveMessage({
+          role: 'detached',
+          event: 'action-sync-to-code',
+          data: {
+            args: [mockPosition],
+          },
+        })
+      })
+
+      cy.wait('@sync-pdf')
+    })
+  })
+
+  describe('with detached role', function () {
+    beforeEach(function () {
+      window.metaAttributesCache.set('ol-detachRole', 'detached')
+    })
+
+    it('does not have go to code location button nor arrow icon', function () {
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <WithPosition mockPosition={mockPosition} />
+          <PdfSynctexControls />
+        </EditorProviders>
+      )
+
+      cy.findByRole('button', {
+        name: 'Go to code location in PDF',
+      }).should('not.exist')
+
+      cy.get('.synctex-control-icon').should('not.exist')
+    })
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('send go to code line action', function () {
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <WithPosition mockPosition={mockPosition} />
+          <PdfSynctexControls />
+        </EditorProviders>
+      )
+
+      cy.wait('@compile')
+
+      cy.get('body')
+        .findByRole('button', { name: /^Go to PDF location in code/ })
+        .should('be.disabled')
+        .then(() => {
+          sysendTestHelper.receiveMessage({
+            role: 'detached',
+            event: 'state-has-single-selected-doc',
+            data: { value: true },
+          })
+        })
+
+      cy.get('body')
+        .findByRole('button', { name: /^Go to PDF location in code/ })
+        .should('not.be.disabled')
+        .then(() => {
+          sysendTestHelper.resetHistory()
+        })
+
+      cy.get('body')
+        .findByRole('button', { name: /^Go to PDF location in code/ })
+        .click()
+
+      // the button is only disabled when the state is updated via sysend
+      cy.get('body')
+        .findByRole('button', { name: /^Go to PDF location in code/ })
+        .should('not.be.disabled')
+
+      cy.get('.synctex-spin-icon')
+        .should('not.exist')
+        .then(() => {
+          expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
+            role: 'detached',
+            event: 'action-sync-to-code',
+            data: {
+              args: [mockPosition, 72],
+            },
+          })
+        })
+    })
+
+    // eslint-disable-next-line mocha/no-skipped-tests
+    it.skip('update inflight state', function () {
+      const scope = mockScope()
+
+      mount(
+        <EditorProviders scope={scope}>
+          <WithPosition mockPosition={mockPosition} />
+          <PdfSynctexControls />
+        </EditorProviders>
+      ).then(() => {
+        sysendTestHelper.receiveMessage({
+          role: 'detached',
+          event: 'state-has-single-selected-doc',
+          data: { value: true },
+        })
+      })
+
+      cy.get('body')
+        .findByRole('button', { name: /^Go to PDF location in code/ })
+        .should('be.disabled')
+
+      cy.get('.synctex-spin-icon')
+        .should('not.exist')
+        .then(() => {
+          sysendTestHelper.receiveMessage({
+            role: 'detacher',
+            event: 'state-sync-to-code-inflight',
+            data: { value: true },
+          })
+        })
+
+      cy.get('body')
+        .findByRole('button', { name: /^Go to PDF location in code/ })
+        .should('be.disabled')
+
+      cy.get('.synctex-spin-icon')
+        .should('have.length', 1)
+        .then(() => {
+          sysendTestHelper.receiveMessage({
+            role: 'detacher',
+            event: 'state-sync-to-code-inflight',
+            data: { value: false },
+          })
+        })
+
+      cy.get('body')
+        .findByRole('button', { name: /^Go to PDF location in code/ })
+        .should('not.be.disabled')
+
+      cy.get('.synctex-spin-icon').should('not.exist')
+    })
+  })
+})

+ 13 - 0
services/web/test/frontend/components/pdf-preview/scope.tsx

@@ -0,0 +1,13 @@
+export const mockScope = () => ({
+  settings: {
+    syntaxValidation: false,
+    pdfViewer: 'pdfjs',
+  },
+  editor: {
+    sharejs_doc: {
+      doc_id: 'test-doc',
+      getSnapshot: () => 'some doc content',
+    },
+  },
+  hasLintingError: false,
+})

+ 0 - 0
services/web/test/frontend/components/shared/beta-badge.spec.js → services/web/test/frontend/components/shared/beta-badge.spec.tsx


+ 0 - 5
services/web/test/frontend/features/file-tree/components/file-tree-item/file-tree-item-inner.test.js

@@ -9,13 +9,8 @@ import FileTreeContextMenu from '../../../../../../frontend/js/features/file-tre
 describe('<FileTreeitemInner />', function () {
   const setContextMenuCoords = sinon.stub()
 
-  beforeEach(function () {
-    global.requestAnimationFrame = sinon.stub()
-  })
-
   afterEach(function () {
     setContextMenuCoords.reset()
-    delete global.requestAnimationFrame
   })
 
   describe('menu', function () {

+ 0 - 2
services/web/test/frontend/features/file-tree/components/file-tree-root.test.js

@@ -14,13 +14,11 @@ describe('<FileTreeRoot/>', function () {
   const onInit = sinon.stub()
 
   beforeEach(function () {
-    global.requestAnimationFrame = sinon.stub()
     window.metaAttributesCache = new Map()
     window.metaAttributesCache.set('ol-user', { id: 'user1' })
   })
 
   afterEach(function () {
-    delete global.requestAnimationFrame
     fetchMock.restore()
     onSelect.reset()
     onInit.reset()

+ 0 - 2
services/web/test/frontend/features/file-tree/flows/create-folder.test.js

@@ -15,13 +15,11 @@ describe('FileTree Create Folder Flow', function () {
   const onInit = sinon.stub()
 
   beforeEach(function () {
-    global.requestAnimationFrame = sinon.stub()
     window.metaAttributesCache = new Map()
     window.metaAttributesCache.set('ol-user', { id: 'user1' })
   })
 
   afterEach(function () {
-    delete global.requestAnimationFrame
     fetchMock.restore()
     onSelect.reset()
     onInit.reset()

+ 0 - 2
services/web/test/frontend/features/file-tree/flows/rename-entity.test.js

@@ -15,13 +15,11 @@ describe('FileTree Rename Entity Flow', function () {
   const onInit = sinon.stub()
 
   beforeEach(function () {
-    global.requestAnimationFrame = sinon.stub()
     window.metaAttributesCache = new Map()
     window.metaAttributesCache.set('ol-user', { id: 'user1' })
   })
 
   afterEach(function () {
-    delete global.requestAnimationFrame
     fetchMock.restore()
     onSelect.reset()
     onInit.reset()

+ 0 - 51
services/web/test/frontend/features/pdf-preview/components/detach-compile-button.test.js

@@ -1,51 +0,0 @@
-import DetachCompileButton from '../../../../../frontend/js/features/pdf-preview/components/detach-compile-button'
-import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import { screen } from '@testing-library/react'
-import sysendTestHelper from '../../../helpers/sysend'
-import { expect } from 'chai'
-
-describe('<DetachCompileButton/>', function () {
-  afterEach(function () {
-    window.metaAttributesCache = new Map()
-    sysendTestHelper.resetHistory()
-  })
-
-  it('detacher mode and linked: show button ', async function () {
-    window.metaAttributesCache.set('ol-detachRole', 'detacher')
-    renderWithEditorContext(<DetachCompileButton />)
-    sysendTestHelper.receiveMessage({
-      role: 'detached',
-      event: 'connected',
-    })
-
-    await screen.getByRole('button', {
-      name: 'Recompile',
-    })
-  })
-
-  it('detacher mode and not linked: does not show button ', async function () {
-    window.metaAttributesCache.set('ol-detachRole', 'detacher')
-    renderWithEditorContext(<DetachCompileButton />)
-
-    expect(
-      await screen.queryByRole('button', {
-        name: 'Recompile',
-      })
-    ).to.not.exist
-  })
-
-  it('not detacher mode and linked: does not show button ', async function () {
-    window.metaAttributesCache.set('ol-detachRole', 'detached')
-    renderWithEditorContext(<DetachCompileButton />)
-    sysendTestHelper.receiveMessage({
-      role: 'detacher',
-      event: 'connected',
-    })
-
-    expect(
-      await screen.queryByRole('button', {
-        name: 'Recompile',
-      })
-    ).to.not.exist
-  })
-})

+ 0 - 37
services/web/test/frontend/features/pdf-preview/components/pdf-js-viewer.test.js

@@ -1,37 +0,0 @@
-import { expect } from 'chai'
-import { screen } from '@testing-library/react'
-import path from 'path'
-import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import { pathToFileURL } from 'url'
-import PdfJsViewer from '../../../../../frontend/js/features/pdf-preview/components/pdf-js-viewer'
-
-const example = pathToFileURL(
-  path.join(__dirname, '../fixtures/test-example.pdf')
-).toString()
-
-describe('<PdfJSViewer/>', function () {
-  it('loads all PDF pages', async function () {
-    renderWithEditorContext(<PdfJsViewer url={example} />)
-
-    await screen.findByLabelText('Page 1')
-    await screen.findByLabelText('Page 2')
-    await screen.findByLabelText('Page 3')
-    expect(screen.queryByLabelText('Page 4')).to.not.exist
-  })
-
-  it('renders pages in a "loading" state', async function () {
-    renderWithEditorContext(<PdfJsViewer url={example} />)
-    await screen.findByLabelText('Loading…')
-  })
-
-  it('can be unmounted while loading a document', async function () {
-    const { unmount } = renderWithEditorContext(<PdfJsViewer url={example} />)
-    unmount()
-  })
-
-  it('can be unmounted after loading a document', async function () {
-    const { unmount } = renderWithEditorContext(<PdfJsViewer url={example} />)
-    await screen.findByLabelText('Page 1')
-    unmount()
-  })
-})

+ 0 - 120
services/web/test/frontend/features/pdf-preview/components/pdf-logs-entries.test.js

@@ -1,120 +0,0 @@
-import PdfLogsEntries from '../../../../../frontend/js/features/pdf-preview/components/pdf-logs-entries'
-import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import { screen, fireEvent } from '@testing-library/react'
-import sysendTestHelper from '../../../helpers/sysend'
-import { expect } from 'chai'
-import sinon from 'sinon'
-
-describe('<PdfLogsEntries/>', function () {
-  const fileTreeManager = {}
-  const editorManager = {}
-  const logEntries = [
-    {
-      file: 'main.tex',
-      line: 9,
-      column: 8,
-      level: 'error',
-      message: 'LaTeX Error',
-      content: 'See the LaTeX manual',
-      raw: '',
-      ruleId: 'hint_misplaced_alignment_tab_character',
-      key: '',
-    },
-  ]
-  const fakeEntity = { type: 'doc' }
-
-  beforeEach(function () {
-    fileTreeManager.findEntityByPath = sinon.stub().returns(fakeEntity)
-    editorManager.openDoc = sinon.stub()
-  })
-
-  afterEach(function () {
-    window.metaAttributesCache = new Map()
-    sysendTestHelper.resetHistory()
-    fileTreeManager.findEntityByPath.resetHistory()
-  })
-
-  it('displays human readable hint', async function () {
-    renderWithEditorContext(<PdfLogsEntries entries={logEntries} />, {
-      fileTreeManager,
-      editorManager,
-    })
-    screen.getByText(/You have placed an alignment tab character/)
-  })
-
-  it('opens doc on click', async function () {
-    renderWithEditorContext(<PdfLogsEntries entries={logEntries} />, {
-      fileTreeManager,
-      editorManager,
-    })
-
-    const button = await screen.getByRole('button', {
-      name: 'Navigate to log position in source code: main.tex, 9',
-    })
-    fireEvent.click(button)
-    sinon.assert.calledOnce(fileTreeManager.findEntityByPath)
-    sinon.assert.calledOnce(editorManager.openDoc)
-    sinon.assert.calledWith(editorManager.openDoc, fakeEntity, {
-      gotoLine: 9,
-      gotoColumn: 8,
-    })
-  })
-
-  it('opens doc via detached action', async function () {
-    window.metaAttributesCache.set('ol-detachRole', 'detacher')
-
-    renderWithEditorContext(<PdfLogsEntries entries={logEntries} />, {
-      fileTreeManager,
-      editorManager,
-    })
-
-    sysendTestHelper.receiveMessage({
-      role: 'detached',
-      event: 'action-sync-to-entry',
-      data: {
-        args: [
-          {
-            file: 'main.tex',
-            line: 7,
-            column: 6,
-          },
-        ],
-      },
-    })
-
-    sinon.assert.calledOnce(fileTreeManager.findEntityByPath)
-    sinon.assert.calledOnce(editorManager.openDoc)
-    sinon.assert.calledWith(editorManager.openDoc, fakeEntity, {
-      gotoLine: 7,
-      gotoColumn: 6,
-    })
-  })
-
-  it('sends open doc clicks via detached action', async function () {
-    window.metaAttributesCache.set('ol-detachRole', 'detached')
-    renderWithEditorContext(<PdfLogsEntries entries={logEntries} />, {
-      fileTreeManager,
-      editorManager,
-    })
-
-    const button = await screen.getByRole('button', {
-      name: 'Navigate to log position in source code: main.tex, 9',
-    })
-    fireEvent.click(button)
-    sinon.assert.notCalled(fileTreeManager.findEntityByPath)
-    sinon.assert.notCalled(editorManager.openDoc)
-    expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
-      role: 'detached',
-      event: 'action-sync-to-entry',
-      data: {
-        args: [
-          {
-            file: 'main.tex',
-            line: 9,
-            column: 8,
-          },
-        ],
-      },
-    })
-  })
-})

+ 0 - 70
services/web/test/frontend/features/pdf-preview/components/pdf-preview-detached-root.test.js

@@ -1,70 +0,0 @@
-import { expect } from 'chai'
-import { render, screen, fireEvent } from '@testing-library/react'
-import sysendTestHelper from '../../../helpers/sysend'
-import PdfPreviewDetachedRoot from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview-detached-root'
-
-describe('<PdfPreviewDetachedRoot/>', function () {
-  beforeEach(function () {
-    const user = { id: 'user1' }
-    window.user = user
-
-    window.metaAttributesCache = new Map()
-    window.metaAttributesCache.set('ol-user', user)
-    window.metaAttributesCache.set('ol-project_id', 'project1')
-    window.metaAttributesCache.set('ol-detachRole', 'detached')
-    window.metaAttributesCache.set('ol-projectName', 'Project Name')
-  })
-
-  afterEach(function () {
-    window.metaAttributesCache = new Map()
-  })
-
-  it('syncs compiling state', async function () {
-    render(<PdfPreviewDetachedRoot />)
-
-    sysendTestHelper.receiveMessage({
-      role: 'detacher',
-      event: 'connected',
-    })
-
-    sysendTestHelper.receiveMessage({
-      role: 'detacher',
-      event: 'state-compiling',
-      data: { value: true },
-    })
-    await screen.findByRole('button', { name: 'Compiling…' })
-    expect(screen.queryByRole('button', { name: 'Recompile' })).to.not.exist
-
-    sysendTestHelper.receiveMessage({
-      role: 'detacher',
-      event: 'state-compiling',
-      data: { value: false },
-    })
-    await screen.findByRole('button', { name: 'Recompile' })
-    expect(screen.queryByRole('button', { name: 'Compiling…' })).to.not.exist
-  })
-
-  it('sends a clear cache request when the button is pressed', async function () {
-    render(<PdfPreviewDetachedRoot />)
-
-    sysendTestHelper.receiveMessage({
-      role: 'detacher',
-      event: 'state-showLogs',
-      data: { value: true },
-    })
-
-    const clearCacheButton = await screen.findByRole('button', {
-      name: 'Clear cached files',
-    })
-    expect(clearCacheButton.hasAttribute('disabled')).to.be.false
-
-    fireEvent.click(clearCacheButton)
-    expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
-      role: 'detached',
-      event: 'action-clearCache',
-      data: {
-        args: [],
-      },
-    })
-  })
-})

+ 0 - 74
services/web/test/frontend/features/pdf-preview/components/pdf-preview-hybrid-toolbar.test.js

@@ -1,74 +0,0 @@
-import sinon from 'sinon'
-import PdfPreviewHybridToolbar from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
-import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import { screen } from '@testing-library/react'
-import sysendTestHelper from '../../../helpers/sysend'
-
-describe('<PdfPreviewHybridToolbar/>', function () {
-  let clock
-
-  beforeEach(function () {
-    clock = sinon.useFakeTimers()
-  })
-
-  afterEach(function () {
-    window.metaAttributesCache = new Map()
-    sysendTestHelper.resetHistory()
-    clock.runAll()
-    clock.restore()
-  })
-
-  it('shows normal mode', async function () {
-    renderWithEditorContext(<PdfPreviewHybridToolbar />)
-
-    await screen.getByRole('button', {
-      name: 'Recompile',
-    })
-  })
-
-  describe('orphan mode', async function () {
-    it('shows connecting message  on load', async function () {
-      window.metaAttributesCache.set('ol-detachRole', 'detached')
-      renderWithEditorContext(<PdfPreviewHybridToolbar />)
-
-      await screen.getByText(/Connecting with the editor/)
-    })
-
-    it('shows compile UI when connected', async function () {
-      window.metaAttributesCache.set('ol-detachRole', 'detached')
-      renderWithEditorContext(<PdfPreviewHybridToolbar />)
-      sysendTestHelper.receiveMessage({
-        role: 'detacher',
-        event: 'connected',
-      })
-      await screen.getByRole('button', {
-        name: 'Recompile',
-      })
-    })
-
-    it('shows connecting message when disconnected', async function () {
-      window.metaAttributesCache.set('ol-detachRole', 'detached')
-      renderWithEditorContext(<PdfPreviewHybridToolbar />)
-      sysendTestHelper.receiveMessage({
-        role: 'detacher',
-        event: 'connected',
-      })
-      sysendTestHelper.receiveMessage({
-        role: 'detacher',
-        event: 'closed',
-      })
-
-      await screen.getByText(/Connecting with the editor/)
-    })
-
-    it('shows redirect button after timeout', async function () {
-      window.metaAttributesCache.set('ol-detachRole', 'detached')
-      renderWithEditorContext(<PdfPreviewHybridToolbar />)
-      clock.tick(6000)
-
-      await screen.getByRole('button', {
-        name: 'Redirect to editor',
-      })
-    })
-  })
-})

+ 0 - 523
services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js

@@ -1,523 +0,0 @@
-import { expect } from 'chai'
-import sinon from 'sinon'
-import fetchMock from 'fetch-mock'
-import { screen, fireEvent, waitFor, cleanup } from '@testing-library/react'
-import PdfPreview from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview'
-import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import nock from 'nock'
-import {
-  corruptPDF,
-  defaultFileResponses,
-  mockBuildFile,
-  mockClearCache,
-  mockCompile,
-  mockCompileError,
-  mockValidationProblems,
-  mockValidPdf,
-} from '../utils/mock-compile'
-
-const mockDelayed = fn => {
-  let _resolve = null
-  const delayPromise = new Promise((resolve, reject) => {
-    _resolve = resolve
-  })
-  fn(delayPromise)
-  return _resolve
-}
-
-const storeAndFireEvent = (key, value) => {
-  localStorage.setItem(key, value)
-  fireEvent(window, new StorageEvent('storage', { key }))
-}
-
-const scope = {
-  settings: {
-    syntaxValidation: false,
-  },
-  editor: {
-    sharejs_doc: {
-      doc_id: 'test-doc',
-      getSnapshot: () => 'some doc content',
-    },
-  },
-}
-
-describe('<PdfPreview/>', function () {
-  let clock
-
-  beforeEach(function () {
-    clock = sinon.useFakeTimers({
-      shouldAdvanceTime: true,
-      now: Date.now(),
-    })
-    nock.cleanAll()
-  })
-
-  afterEach(function () {
-    clock.runAll()
-    clock.restore()
-    fetchMock.reset()
-    localStorage.clear()
-    sinon.restore()
-  })
-
-  it('renders the PDF preview', async function () {
-    mockCompile()
-    mockBuildFile()
-    mockValidPdf()
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    // wait for "compile on load" to finish
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-  })
-
-  it('runs a compile when the Recompile button is pressed', async function () {
-    mockCompile()
-    mockBuildFile()
-    mockValidPdf()
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    // wait for "compile on load" to finish
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    mockValidPdf()
-
-    // press the Recompile button => compile
-    const button = screen.getByRole('button', { name: 'Recompile' })
-    button.click()
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    expect(fetchMock.calls()).to.have.length(6)
-  })
-
-  it('runs a compile on `pdf:recompile` event', async function () {
-    mockCompile()
-    mockBuildFile()
-    mockValidPdf()
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    // wait for "compile on load" to finish
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    mockValidPdf()
-
-    fireEvent(window, new CustomEvent('pdf:recompile'))
-
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    expect(fetchMock.calls()).to.have.length(6)
-  })
-
-  it('does not compile while compiling', async function () {
-    mockDelayed(mockCompile)
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    // trigger compiles while "compile on load" is running
-    await screen.findByRole('button', { name: 'Compiling…' })
-    fireEvent(window, new CustomEvent('pdf:recompile'))
-
-    expect(fetchMock.calls()).to.have.length(1)
-  })
-
-  it('disables compile button while compile is running', async function () {
-    mockCompile()
-    mockBuildFile()
-    mockValidPdf()
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    let button = screen.getByRole('button', { name: 'Compiling…' })
-    expect(button.hasAttribute('disabled')).to.be.true
-
-    button = await screen.findByRole('button', { name: 'Recompile' })
-    expect(button.hasAttribute('disabled')).to.be.false
-  })
-
-  it('runs a compile on doc change if autocompile is enabled', async function () {
-    mockCompile()
-    mockBuildFile()
-    mockValidPdf()
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    // wait for "compile on load" to finish
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    // switch on auto compile
-    storeAndFireEvent('autocompile_enabled:project123', true)
-
-    mockValidPdf()
-
-    // fire a doc:changed event => compile
-    fireEvent(window, new CustomEvent('doc:changed'))
-    clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
-
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    expect(fetchMock.calls()).to.have.length(6)
-  })
-
-  it('does not run a compile on doc change if autocompile is disabled', async function () {
-    mockCompile()
-    mockBuildFile()
-    mockValidPdf()
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    // wait for "compile on load" to finish
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    // make sure auto compile is switched off
-    storeAndFireEvent('autocompile_enabled:project123', false)
-
-    // fire a doc:changed event => no compile
-    fireEvent(window, new CustomEvent('doc:changed'))
-    clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
-    screen.getByRole('button', { name: 'Recompile' })
-
-    expect(fetchMock.calls()).to.have.length(3)
-  })
-
-  it('does not run a compile on doc change if autocompile is blocked by syntax check', async function () {
-    mockCompile()
-    mockBuildFile()
-    mockValidPdf()
-
-    renderWithEditorContext(<PdfPreview />, {
-      scope: {
-        ...scope,
-        'settings.syntaxValidation': true, // enable linting in the editor
-        hasLintingError: true, // mock a linting error
-      },
-    })
-
-    // wait for "compile on load" to finish
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    // switch on auto compile and syntax checking
-    storeAndFireEvent('autocompile_enabled:project123', true)
-    storeAndFireEvent('stop_on_validation_error:project123', true)
-
-    // fire a doc:changed event => no compile
-    fireEvent(window, new CustomEvent('doc:changed'))
-    clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
-    screen.getByRole('button', { name: 'Recompile' })
-    await screen.findByText('Code check failed')
-
-    expect(fetchMock.calls()).to.have.length(3)
-  })
-
-  describe('displays error messages', function () {
-    const compileErrorStatuses = {
-      'clear-cache':
-        'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
-      'clsi-maintenance':
-        'The compile servers are down for maintenance, and will be back shortly.',
-      'compile-in-progress':
-        'A previous compile is still running. Please wait a minute and try compiling again.',
-      exited: 'Server Error',
-      failure: 'No PDF',
-      generic: 'Server Error',
-      'project-too-large': 'Project too large',
-      'rate-limited': 'Compile rate limit hit',
-      terminated: 'Compilation cancelled',
-      timedout: 'Timed out',
-      'too-recently-compiled':
-        'This project was compiled very recently, so this compile has been skipped.',
-      unavailable:
-        'Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.',
-      foo: 'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
-    }
-
-    for (const [status, message] of Object.entries(compileErrorStatuses)) {
-      it(`displays error message for '${status}' status`, async function () {
-        cleanup()
-        fetchMock.restore()
-        mockCompileError(status)
-
-        renderWithEditorContext(<PdfPreview />, { scope })
-
-        // wait for "compile on load" to finish
-        await screen.findByRole('button', { name: 'Compiling…' })
-        await screen.findByRole('button', { name: 'Recompile' })
-
-        screen.getByText(message)
-      })
-    }
-  })
-
-  it('displays expandable raw logs', async function () {
-    mockCompile()
-    mockBuildFile()
-    mockValidPdf()
-
-    // pretend that the content is large enough to trigger a "collapse"
-    // (in jsdom these values are always zero)
-    sinon.stub(HTMLElement.prototype, 'scrollHeight').value(500)
-    sinon.stub(HTMLElement.prototype, 'scrollWidth').value(500)
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    // wait for "compile on load" to finish
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    const logsButton = screen.getByRole('button', { name: 'View logs' })
-    logsButton.click()
-
-    await screen.findByRole('button', { name: 'View PDF' })
-
-    // expand the log
-    const [expandButton] = screen.getAllByRole('button', { name: 'Expand' })
-    expandButton.click()
-
-    // collapse the log
-    const [collapseButton] = screen.getAllByRole('button', { name: 'Collapse' })
-    collapseButton.click()
-  })
-
-  it('displays error messages if there were validation problems', async function () {
-    const validationProblems = {
-      sizeCheck: {
-        resources: [
-          { path: 'foo/bar', kbSize: 76221 },
-          { path: 'bar/baz', kbSize: 2342 },
-        ],
-      },
-      mainFile: true,
-      conflictedPaths: [
-        {
-          path: 'foo/bar',
-        },
-        {
-          path: 'foo/baz',
-        },
-      ],
-    }
-
-    mockValidationProblems(validationProblems)
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    // wait for "compile on load" to finish
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    screen.getByText('Project too large')
-    screen.getByText('Unknown main document')
-    screen.getByText('Conflicting Paths Found')
-
-    expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
-    expect(fetchMock.called('begin:https://clsi.test-overleaf.com/')).to.be
-      .false // TODO: actual path
-  })
-
-  it('sends a clear cache request when the button is pressed', async function () {
-    mockCompile()
-    mockBuildFile()
-    mockValidPdf()
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    // wait for "compile on load" to finish
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    const logsButton = screen.getByRole('button', {
-      name: 'View logs',
-    })
-    logsButton.click()
-
-    const clearCacheButton = await screen.findByRole('button', {
-      name: 'Clear cached files',
-    })
-    expect(clearCacheButton.hasAttribute('disabled')).to.be.false
-
-    mockClearCache()
-
-    // click the button
-    clearCacheButton.click()
-    await waitFor(() => {
-      expect(clearCacheButton.hasAttribute('disabled')).to.be.true
-    })
-
-    await waitFor(() => {
-      expect(clearCacheButton.hasAttribute('disabled')).to.be.false
-    })
-
-    expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
-    expect(fetchMock.called('begin:https://clsi.test-overleaf.com/')).to.be.true // TODO: actual path
-  })
-
-  it('handle "recompile from scratch"', async function () {
-    mockCompile()
-    mockBuildFile()
-    mockValidPdf()
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    // wait for "compile on load" to finish
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    // show the logs UI
-    const logsButton = screen.getByRole('button', {
-      name: 'View logs',
-    })
-    logsButton.click()
-
-    const clearCacheButton = await screen.findByRole('button', {
-      name: 'Clear cached files',
-    })
-    expect(clearCacheButton.hasAttribute('disabled')).to.be.false
-
-    mockValidPdf()
-    const finishClearCache = mockDelayed(mockClearCache)
-
-    const recompileFromScratch = screen.getByRole('menuitem', {
-      name: 'Recompile from scratch',
-      hidden: true,
-    })
-    recompileFromScratch.click()
-
-    await waitFor(() => {
-      expect(clearCacheButton.hasAttribute('disabled')).to.be.true
-    })
-
-    finishClearCache()
-
-    // wait for compile to finish
-    await screen.findByRole('button', { name: 'Compiling…' })
-    await screen.findByRole('button', { name: 'Recompile' })
-
-    expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
-    expect(fetchMock.called('express:/project/:projectId/output')).to.be.true
-    expect(fetchMock.called('begin:https://clsi.test-overleaf.com/')).to.be.true // TODO: actual path
-  })
-
-  it('shows an error for an invalid URL', async function () {
-    mockCompile()
-    mockBuildFile()
-
-    nock('https://clsi.test-overleaf.com')
-      .get(/^\/build\/output.pdf/)
-      .replyWithError({
-        message: 'something awful happened',
-        code: 'AWFUL_ERROR',
-      })
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    await screen.findByText('Something went wrong while rendering this PDF.')
-    expect(screen.queryByLabelText('Page 1')).to.not.exist
-
-    expect(nock.isDone()).to.be.true
-  })
-
-  it('shows an error for a corrupt PDF', async function () {
-    mockCompile()
-    mockBuildFile()
-
-    nock('https://clsi.test-overleaf.com')
-      .get(/^\/build\/output.pdf/)
-      .replyWithFile(200, corruptPDF)
-
-    renderWithEditorContext(<PdfPreview />, { scope })
-
-    await screen.findByText('Something went wrong while rendering this PDF.')
-    expect(screen.queryByLabelText('Page 1')).to.not.exist
-
-    expect(nock.isDone()).to.be.true
-  })
-
-  describe('human readable logs', function () {
-    it('shows human readable hint for undefined reference errors', async function () {
-      mockCompile()
-      mockBuildFile({
-        ...defaultFileResponses,
-        '/build/output.log': `
-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
-entering extended mode
- \\write18 enabled.
- %&-line parsing enabled.
-**main.tex
-(./main.tex
-LaTeX2e <2020-02-02> patch level 5
-
-LaTeX Warning: Reference \`intorduction' on page 1 undefined on input line 11.
-
-
-LaTeX Warning: Reference \`section1' on page 1 undefined on input line 13.
-
-[1
-
-{/usr/local/texlive/2020/texmf-var/fonts/map/pdftex/updmap/pdftex.map}] (/compi
-le/output.aux)
-
-LaTeX Warning: There were undefined references.
-
- )
-`,
-      })
-      mockValidPdf()
-
-      renderWithEditorContext(<PdfPreview />, { scope })
-
-      await screen.findByText(
-        "Reference `intorduction' on page 1 undefined on input line 11."
-      )
-      await screen.findByText(
-        "Reference `section1' on page 1 undefined on input line 13."
-      )
-      await screen.findByText('There were undefined references.')
-      const hints = await screen.findAllByText(
-        /You have referenced something which has not yet been labelled/
-      )
-      expect(hints.length).to.equal(3)
-    })
-
-    it('idoes not show human readable hint for undefined reference errors', async function () {
-      mockCompile()
-      mockBuildFile({
-        ...defaultFileResponses,
-        '/build/output.log': `
-Package rerunfilecheck Info: File \`output.out' has not changed.
-(rerunfilecheck)             Checksum: 339DB29951BB30436898BC39909EA4FA;11265.
-
-Package rerunfilecheck Warning: File \`output.brf' has changed.
-(rerunfilecheck)                Rerun to get bibliographical references right.
-
-Package rerunfilecheck Info: Checksums for \`output.brf':
-(rerunfilecheck)             Before: D41D8CD98F00B204E9800998ECF8427E;0
-(rerunfilecheck)             After:  DF3260FAD3828D54C5E4E9337E97F7AF;4841.
- )
-`,
-      })
-      mockValidPdf()
-
-      renderWithEditorContext(<PdfPreview />, { scope })
-
-      await screen.findByText(
-        /Package rerunfilecheck Warning: File `output.brf' has changed. Rerun to get bibliographical references right./
-      )
-      expect(
-        screen.queryByText(
-          /You have referenced something which has not yet been labelled/
-        )
-      ).to.not.exist
-    })
-  })
-})

+ 0 - 437
services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js

@@ -1,437 +0,0 @@
-import PdfSynctexControls from '../../../../../frontend/js/features/pdf-preview/components/pdf-synctex-controls'
-import { renderWithEditorContext } from '../../../helpers/render-with-context'
-import sysendTestHelper from '../../../helpers/sysend'
-import { cloneDeep } from 'lodash'
-import fetchMock from 'fetch-mock'
-import { fireEvent, screen, waitFor } from '@testing-library/react'
-import fs from 'fs'
-import path from 'path'
-import { expect } from 'chai'
-import { useDetachCompileContext as useCompileContext } from '../../../../../frontend/js/shared/context/detach-compile-context'
-import { useFileTreeData } from '../../../../../frontend/js/shared/context/file-tree-data-context'
-import { useEffect } from 'react'
-
-const examplePDF = path.join(__dirname, '../fixtures/test-example.pdf')
-
-const scope = {
-  settings: {
-    syntaxValidation: false,
-    pdfViewer: 'pdfjs',
-  },
-  editor: {
-    sharejs_doc: {
-      doc_id: 'test-doc',
-      getSnapshot: () => 'some doc content',
-    },
-  },
-}
-
-const outputFiles = [
-  {
-    path: 'output.pdf',
-    build: '123',
-    url: '/build/output.pdf',
-    type: 'pdf',
-  },
-  {
-    path: 'output.log',
-    build: '123',
-    url: '/build/output.log',
-    type: 'log',
-  },
-]
-
-const mockCompile = () =>
-  fetchMock.post('express:/project/:projectId/compile', {
-    body: {
-      status: 'success',
-      clsiServerId: 'foo',
-      compileGroup: 'standard',
-      pdfDownloadDomain: 'https://clsi.test-overleaf.com',
-      outputFiles: cloneDeep(outputFiles),
-    },
-  })
-
-const fileResponses = {
-  '/build/output.pdf': () => fs.createReadStream(examplePDF),
-  '/build/output.log': '',
-}
-
-const mockBuildFile = () =>
-  fetchMock.get('begin:https://clsi.test-overleaf.com/', _url => {
-    const url = new URL(_url, 'https://clsi.test-overleaf.com')
-
-    if (url.pathname in fileResponses) {
-      return fileResponses[url.pathname]
-    }
-
-    return 404
-  })
-
-const mockHighlights = [
-  {
-    page: 1,
-    h: 85.03936,
-    v: 509.999878,
-    width: 441.921265,
-    height: 8.855677,
-  },
-  {
-    page: 1,
-    h: 85.03936,
-    v: 486.089539,
-    width: 441.921265,
-    height: 8.855677,
-  },
-]
-
-const mockPosition = {
-  page: 1,
-  offset: { top: 10, left: 10 },
-  pageSize: { height: 500, width: 500 },
-}
-
-const mockSelectedEntities = [{ type: 'doc' }]
-
-const mockSynctex = () =>
-  fetchMock
-    .get('express:/project/:projectId/sync/code', () => {
-      return { pdf: cloneDeep(mockHighlights) }
-    })
-    .get('express:/project/:projectId/sync/pdf', () => {
-      return { code: [{ file: 'main.tex', line: 100 }] }
-    })
-
-const WithPosition = ({ mockPosition }) => {
-  const { setPosition } = useCompileContext()
-
-  // mock PDF scroll position update
-  useEffect(() => {
-    setPosition(mockPosition)
-  }, [mockPosition, setPosition])
-
-  return null
-}
-
-const WithSelectedEntities = ({ mockSelectedEntities = [] }) => {
-  const { setSelectedEntities } = useFileTreeData()
-
-  useEffect(() => {
-    setSelectedEntities(mockSelectedEntities)
-  }, [mockSelectedEntities, setSelectedEntities])
-
-  return null
-}
-describe('<PdfSynctexControls/>', function () {
-  beforeEach(function () {
-    window.metaAttributesCache = new Map()
-    fetchMock.restore()
-    mockCompile()
-    mockSynctex()
-    mockBuildFile()
-  })
-
-  afterEach(function () {
-    window.metaAttributesCache = new Map()
-    fetchMock.restore()
-  })
-
-  it('handles clicks on sync buttons', async function () {
-    const { container } = renderWithEditorContext(
-      <>
-        <WithPosition mockPosition={mockPosition} />
-        <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
-        <PdfSynctexControls />
-      </>,
-      { scope }
-    )
-
-    const syncToPdfButton = await screen.findByRole('button', {
-      name: 'Go to code location in PDF',
-    })
-
-    const syncToCodeButton = await screen.findByRole('button', {
-      name: /Go to PDF location in code/,
-    })
-
-    expect(container.querySelectorAll('.synctex-control-icon').length).to.equal(
-      2
-    )
-
-    // mock editor cursor position update
-    fireEvent(
-      window,
-      new CustomEvent('cursor:editor:update', {
-        detail: { row: 100, column: 10 },
-      })
-    )
-
-    fireEvent.click(syncToPdfButton)
-
-    expect(syncToPdfButton.disabled).to.be.true
-
-    await waitFor(() => {
-      expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be
-        .true
-    })
-
-    fireEvent.click(syncToCodeButton)
-
-    expect(syncToCodeButton.disabled).to.be.true
-
-    await waitFor(() => {
-      expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be
-        .true
-    })
-  })
-  it('disables button when multiple entities are selected', async function () {
-    renderWithEditorContext(
-      <>
-        <WithPosition mockPosition={mockPosition} />
-        <WithSelectedEntities
-          mockSelectedEntities={[{ type: 'doc' }, { type: 'doc' }]}
-        />
-        <PdfSynctexControls />
-      </>,
-      { scope }
-    )
-
-    const syncToPdfButton = await screen.findByRole('button', {
-      name: 'Go to code location in PDF',
-    })
-    expect(syncToPdfButton.disabled).to.be.true
-
-    const syncToCodeButton = await screen.findByRole('button', {
-      name: /Go to PDF location in code/,
-    })
-    expect(syncToCodeButton.disabled).to.be.true
-  })
-
-  it('disables button when a file is selected', async function () {
-    renderWithEditorContext(
-      <>
-        <WithPosition mockPosition={mockPosition} />
-        <WithSelectedEntities mockSelectedEntities={[{ type: 'file' }]} />
-        <PdfSynctexControls />
-      </>,
-      { scope }
-    )
-
-    const syncToPdfButton = await screen.findByRole('button', {
-      name: 'Go to code location in PDF',
-    })
-    expect(syncToPdfButton.disabled).to.be.true
-
-    const syncToCodeButton = await screen.findByRole('button', {
-      name: /Go to PDF location in code/,
-    })
-    expect(syncToCodeButton.disabled).to.be.true
-  })
-
-  describe('with detacher role', async function () {
-    beforeEach(function () {
-      window.metaAttributesCache.set('ol-detachRole', 'detacher')
-    })
-
-    it('does not have go to PDF location button nor arrow icon', async function () {
-      const { container } = renderWithEditorContext(
-        <>
-          <WithPosition mockPosition={mockPosition} />
-          <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
-          <PdfSynctexControls />
-        </>,
-        { scope }
-      )
-
-      expect(
-        await screen.queryByRole('button', {
-          name: 'Go to PDF location in code',
-        })
-      ).to.not.exist
-
-      expect(container.querySelector('.synctex-control-icon')).to.not.exist
-    })
-
-    it('send set highlights action', async function () {
-      renderWithEditorContext(
-        <>
-          <WithPosition mockPosition={mockPosition} />
-          <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
-          <PdfSynctexControls />
-        </>,
-        { scope }
-      )
-      sysendTestHelper.resetHistory()
-
-      const syncToPdfButton = await screen.findByRole('button', {
-        name: 'Go to code location in PDF',
-      })
-
-      // mock editor cursor position update
-      fireEvent(
-        window,
-        new CustomEvent('cursor:editor:update', {
-          detail: { row: 100, column: 10 },
-        })
-      )
-
-      expect(syncToPdfButton.disabled).to.be.false
-
-      fireEvent.click(syncToPdfButton)
-
-      expect(syncToPdfButton.disabled).to.be.true
-
-      await waitFor(() => {
-        expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be
-          .true
-      })
-
-      // synctex is called locally and the result are broadcast for the detached
-      // tab
-      expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
-        role: 'detacher',
-        event: 'action-setHighlights',
-        data: { args: [mockHighlights] },
-      })
-    })
-
-    it('reacts to sync to code action', async function () {
-      renderWithEditorContext(
-        <>
-          <WithPosition mockPosition={mockPosition} />
-          <WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
-          <PdfSynctexControls />
-        </>,
-        { scope }
-      )
-
-      await waitFor(() => {
-        expect(fetchMock.called('express:/project/:projectId/compile')).to.be
-          .true
-      })
-
-      sysendTestHelper.receiveMessage({
-        role: 'detached',
-        event: 'action-sync-to-code',
-        data: {
-          args: [mockPosition],
-        },
-      })
-
-      await waitFor(() => {
-        expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be
-          .true
-      })
-    })
-  })
-
-  describe('with detached role', async function () {
-    beforeEach(function () {
-      window.metaAttributesCache.set('ol-detachRole', 'detached')
-    })
-
-    it('does not have go to code location button nor arrow icon', async function () {
-      const { container } = renderWithEditorContext(
-        <>
-          <WithPosition mockPosition={mockPosition} />
-          <PdfSynctexControls />
-        </>,
-        { scope }
-      )
-
-      expect(
-        await screen.queryByRole('button', {
-          name: 'Go to code location in PDF',
-        })
-      ).to.not.exist
-
-      expect(container.querySelector('.synctex-control-icon')).to.not.exist
-    })
-
-    it('send go to code line action', async function () {
-      const { container } = renderWithEditorContext(
-        <>
-          <WithPosition mockPosition={mockPosition} />
-          <PdfSynctexControls />
-        </>,
-        { scope }
-      )
-
-      const syncToCodeButton = await screen.findByRole('button', {
-        name: /Go to PDF location in code/,
-      })
-      expect(syncToCodeButton.disabled).to.be.true
-
-      sysendTestHelper.receiveMessage({
-        role: 'detached',
-        event: 'state-has-single-selected-doc',
-        data: { value: true },
-      })
-      expect(syncToCodeButton.disabled).to.be.false
-
-      sysendTestHelper.resetHistory()
-
-      fireEvent.click(syncToCodeButton)
-
-      // the button is only disabled when the state is updated via sysend
-      expect(syncToCodeButton.disabled).to.be.false
-      expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
-        0
-      )
-
-      expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
-        role: 'detached',
-        event: 'action-sync-to-code',
-        data: {
-          args: [mockPosition, 72],
-        },
-      })
-    })
-
-    it('update inflight state', async function () {
-      const { container } = renderWithEditorContext(
-        <>
-          <WithPosition mockPosition={mockPosition} />
-          <PdfSynctexControls />
-        </>,
-        { scope }
-      )
-      sysendTestHelper.receiveMessage({
-        role: 'detached',
-        event: 'state-has-single-selected-doc',
-        data: { value: true },
-      })
-
-      const syncToCodeButton = await screen.findByRole('button', {
-        name: /Go to PDF location in code/,
-      })
-
-      expect(syncToCodeButton.disabled).to.be.false
-      expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
-        0
-      )
-
-      sysendTestHelper.receiveMessage({
-        role: 'detacher',
-        event: 'state-sync-to-code-inflight',
-        data: { value: true },
-      })
-
-      expect(syncToCodeButton.disabled).to.be.true
-      expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
-        1
-      )
-
-      sysendTestHelper.receiveMessage({
-        role: 'detacher',
-        event: 'state-sync-to-code-inflight',
-        data: { value: false },
-      })
-
-      expect(syncToCodeButton.disabled).to.be.false
-      expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
-        0
-      )
-    })
-  })
-})

+ 0 - 148
services/web/test/frontend/features/pdf-preview/utils/mock-compile.js

@@ -1,148 +0,0 @@
-import fetchMock from 'fetch-mock'
-import { cloneDeep } from 'lodash'
-import nock from 'nock'
-import fs from 'fs'
-import path from 'path'
-
-export const examplePDF = path.join(__dirname, '../fixtures/test-example.pdf')
-export const corruptPDF = path.join(
-  __dirname,
-  '../fixtures/test-example-corrupt.pdf'
-)
-
-const outputFiles = [
-  {
-    path: 'output.pdf',
-    build: '123',
-    url: '/build/output.pdf',
-    type: 'pdf',
-  },
-  {
-    path: 'output.bbl',
-    build: '123',
-    url: '/build/output.bbl',
-    type: 'bbl',
-  },
-  {
-    path: 'output.bib',
-    build: '123',
-    url: '/build/output.bib',
-    type: 'bib',
-  },
-  {
-    path: 'example.txt',
-    build: '123',
-    url: '/build/example.txt',
-    type: 'txt',
-  },
-  {
-    path: 'output.log',
-    build: '123',
-    url: '/build/output.log',
-    type: 'log',
-  },
-  {
-    path: 'output.blg',
-    build: '123',
-    url: '/build/output.blg',
-    type: 'blg',
-  },
-]
-
-export const mockCompile = (delayPromise = Promise.resolve()) =>
-  fetchMock.post(
-    'express:/project/:projectId/compile',
-    delayPromise.then(() => ({
-      body: {
-        status: 'success',
-        clsiServerId: 'foo',
-        compileGroup: 'priority',
-        pdfDownloadDomain: 'https://clsi.test-overleaf.com',
-        outputFiles: cloneDeep(outputFiles),
-      },
-    }))
-  )
-
-export const mockCompileError = status =>
-  fetchMock.post('express:/project/:projectId/compile', {
-    body: {
-      status,
-      clsiServerId: 'foo',
-      compileGroup: 'priority',
-    },
-  })
-
-export const mockValidationProblems = validationProblems =>
-  fetchMock.post('express:/project/:projectId/compile', {
-    body: {
-      status: 'validation-problems',
-      validationProblems,
-      clsiServerId: 'foo',
-      compileGroup: 'priority',
-    },
-  })
-
-export const mockClearCache = (delayPromise = Promise.resolve()) =>
-  fetchMock.delete(
-    'express:/project/:projectId/output',
-    delayPromise.then(() => ({
-      body: {
-        status: 204,
-      },
-    }))
-  )
-
-export const mockValidPdf = () => {
-  nock('https://clsi.test-overleaf.com')
-    .get(/^\/build\/output\.pdf/)
-    .replyWithFile(200, examplePDF)
-}
-
-export const defaultFileResponses = {
-  '/build/output.pdf': () => fs.createReadStream(examplePDF),
-  '/build/output.blg': 'This is BibTeX, Version 4.0', // FIXME
-  '/build/output.log': `
-The LaTeX compiler output
-  * With a lot of details
-
-Wrapped in an HTML <pre> element with
-      preformatted text which is to be presented exactly
-            as written in the HTML file
-
-                                              (whitespace included™)
-
-The text is typically rendered using a non-proportional ("monospace") font.
-
-LaTeX Font Info:    External font \`cmex10' loaded for size
-(Font)              <7> on input line 18.
-LaTeX Font Info:    External font \`cmex10' loaded for size
-(Font)              <5> on input line 18.
-! Undefined control sequence.
-<recently read> \\Zlpha
-
- main.tex, line 23
-
-`,
-}
-
-export const mockBuildFile = (responses = defaultFileResponses) => {
-  fetchMock.get('begin:https://clsi.test-overleaf.com/', _url => {
-    const url = new URL(_url, 'https://clsi.test-overleaf.com')
-
-    if (url.pathname in responses) {
-      return responses[url.pathname]
-    }
-
-    return 404
-  })
-
-  fetchMock.get('express:/build/:file', (_url, options, request) => {
-    const url = new URL(_url, 'https://example.com')
-
-    if (url.pathname in responses) {
-      return responses[url.pathname]
-    }
-
-    return 404
-  })
-}

+ 1 - 1
services/web/test/frontend/helpers/editor-providers.js

@@ -28,7 +28,7 @@ export function EditorProviders({
   },
   isRestrictedTokenMember = false,
   clsiServerId = '1234',
-  scope,
+  scope = {},
   features = {
     referencesSearch: true,
   },

+ 3 - 1
services/web/tsconfig.json

@@ -10,13 +10,15 @@
     "moduleResolution": "node" /* Specify module resolution strategy */,
     "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
     "skipLibCheck": true /* Skip type checking of declaration files. */,
-    "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
+    "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
+    "types": ["cypress", "@testing-library/cypress"]
   },
   "include": [
     "frontend/js/**/*.*",
     "modules/**/frontend/js/**/*.*",
     "test/frontend/**/*.*",
     "modules/**/test/frontend/**/*.*",
+    "cypress",
     "types"
   ]
 }

+ 5 - 0
services/web/types/window.ts

@@ -5,6 +5,11 @@ declare global {
     user: {
       id: string
     }
+    metaAttributesCache: Map<string, unknown>
+    i18n: {
+      currentLangCode: string
+    }
+    ExposedSettings: Record<string, unknown>
   }
 }
 export {} // pretend this is a module

+ 4 - 2
services/web/webpack.config.js

@@ -234,10 +234,12 @@ module.exports = {
       output: 'manifest.json',
     }),
 
-    // Ensure that process.env.RESET_APP_DATA_TIMER is defined, to avoid an error.
-    // https://github.com/algolia/algoliasearch-client-javascript/issues/756
     new webpack.EnvironmentPlugin({
+      // Ensure that process.env.RESET_APP_DATA_TIMER is defined, to avoid an error.
+      // https://github.com/algolia/algoliasearch-client-javascript/issues/756
       RESET_APP_DATA_TIMER: '120000',
+      // Ensure that process.env.CYPRESS is defined (see utils/worker.js)
+      CYPRESS: false,
     }),
 
     // Prevent moment from loading (very large) locale files that aren't used

Некоторые файлы не были показаны из-за большого количества измененных файлов