Przeglądaj źródła

[clsi-cache] check compiler settings before using compile from cache (#24845)

* [web] provide an actual rootFolder from EditorProviders in tests

- Fixup SocketIOMock and ShareJS mocks to provide the complete interface
- Extend SocketIOMock interface to count event listeners
- Fixup test that did not expect to find a working rootDoc

* [web] expose imageName from ProjectContext

* [clsi-cache] check compiler settings before using compile from cache

* [web] avoid fetching initial compile from clsi-cache in PDF detach tab

GitOrigin-RevId: e3c754a7ceca55f03a317e1bc8ae45ed12cc2f02
Jakob Ackermann 1 rok temu
rodzic
commit
39110d9da9

+ 3 - 0
services/clsi/app/js/CLSICacheHandler.js

@@ -28,6 +28,7 @@ const MAX_ENTRIES_IN_OUTPUT_TAR = 100
  * @param {string} editorId
  * @param {[{path: string}]} outputFiles
  * @param {string} compileGroup
+ * @param {Record<string, any>} options
  */
 function notifyCLSICacheAboutBuild({
   projectId,
@@ -36,6 +37,7 @@ function notifyCLSICacheAboutBuild({
   editorId,
   outputFiles,
   compileGroup,
+  options,
 }) {
   if (!Settings.apis.clsiCache.enabled) return
 
@@ -55,6 +57,7 @@ function notifyCLSICacheAboutBuild({
         downloadHost: Settings.apis.clsi.downloadHost,
         clsiServerId: Settings.apis.clsi.clsiServerId,
         compileGroup,
+        options,
       },
       signal: AbortSignal.timeout(15_000),
     }).catch(err => {

+ 10 - 0
services/clsi/app/js/CompileController.js

@@ -1,3 +1,4 @@
+const Path = require('node:path')
 const RequestParser = require('./RequestParser')
 const CompileManager = require('./CompileManager')
 const Settings = require('@overleaf/settings')
@@ -123,6 +124,15 @@ function compile(req, res, next) {
                 editorId: request.editorId,
                 outputFiles,
                 compileGroup: request.compileGroup,
+                options: {
+                  compiler: request.compiler,
+                  draft: request.draft,
+                  imageName: request.imageName
+                    ? Path.basename(request.imageName)
+                    : undefined,
+                  rootResourcePath: request.rootResourcePath,
+                  stopOnFirstError: request.stopOnFirstError,
+                },
               })
             }
 

+ 3 - 1
services/web/app/src/Features/Compile/ClsiCacheController.js

@@ -133,7 +133,8 @@ async function getLatestBuildFromCache(req, res) {
       baseURL += `/user/${userId}`
     }
 
-    const { ranges, contentId, clsiServerId, compileGroup, size } = meta
+    const { ranges, contentId, clsiServerId, compileGroup, size, options } =
+      meta
 
     const outputFiles = allFiles
       .filter(
@@ -175,6 +176,7 @@ async function getLatestBuildFromCache(req, res) {
       clsiServerId,
       pdfDownloadDomain,
       pdfCachingMinChunkSize,
+      options,
     })
   } catch (err) {
     if (err instanceof NotFoundError) {

+ 70 - 26
services/web/cypress/support/shared/commands/compile.ts

@@ -43,52 +43,86 @@ const outputFiles = () => {
   ]
 }
 
+const compileFromCacheResponse = () => {
+  return {
+    fromCache: true,
+    status: 'success',
+    clsiServerId: 'foo',
+    compileGroup: 'priority',
+    pdfDownloadDomain: 'https://clsi.test-overleaf.com',
+    outputFiles: outputFiles(),
+    options: {
+      rootResourcePath: 'main.tex',
+      imageName: 'texlive-full:2024.1',
+      compiler: 'pdflatex',
+      stopOnFirstError: false,
+      draft: false,
+    },
+  }
+}
+
+export const interceptCompileFromCacheRequest = ({
+  times,
+  promise,
+}: {
+  times: number
+  promise: Promise<void>
+}) => {
+  return cy.intercept(
+    { path: '/project/*/output/cached/output.overleaf.json', times },
+    async req => {
+      await promise
+      req.reply({ body: compileFromCacheResponse() })
+    }
+  )
+}
+
+export const interceptCompileRequest = ({ times = 1 } = {}) => {
+  return cy.intercept(
+    { method: 'POST', pathname: '/project/*/compile', times },
+    {
+      body: {
+        status: 'success',
+        clsiServerId: 'foo',
+        compileGroup: 'priority',
+        pdfDownloadDomain: 'https://clsi.test-overleaf.com',
+        outputFiles: outputFiles(),
+      },
+    }
+  )
+}
+
 export const interceptCompile = ({
   prefix = 'compile',
   times = 1,
   cached = false,
+  regular = true,
   outputPDFFixture = 'output.pdf',
 } = {}) => {
   if (cached) {
     cy.intercept(
-      { pathname: '/project/*/output/cached/output.overleaf.json', times },
-      {
-        body: {
-          fromCache: true,
-          status: 'success',
-          clsiServerId: 'foo',
-          compileGroup: 'priority',
-          pdfDownloadDomain: 'https://clsi.test-overleaf.com',
-          outputFiles: outputFiles(),
-        },
-      }
+      { path: '/project/*/output/cached/output.overleaf.json', times },
+      { body: compileFromCacheResponse() }
     ).as(`${prefix}-cached`)
-    cy.intercept(
-      { method: 'POST', pathname: '/project/*/compile', times },
-      {
-        body: {
-          status: 'unavailable',
-          clsiServerId: 'foo',
-          compileGroup: 'priority',
-          pdfDownloadDomain: 'https://clsi.test-overleaf.com',
-          outputFiles: [],
-        },
-      }
-    ).as(`${prefix}`)
   } else {
     cy.intercept(
       { pathname: '/project/*/output/cached/output.overleaf.json', times },
       { statusCode: 404 }
     ).as(`${prefix}-cached`)
+  }
+
+  if (regular) {
+    interceptCompileRequest({ times }).as(`${prefix}`)
+  } else {
     cy.intercept(
       { method: 'POST', pathname: '/project/*/compile', times },
       {
         body: {
-          status: 'success',
+          status: 'unavailable',
           clsiServerId: 'foo',
           compileGroup: 'priority',
           pdfDownloadDomain: 'https://clsi.test-overleaf.com',
-          outputFiles: outputFiles(),
+          outputFiles: [],
         },
       }
     ).as(`${prefix}`)
@@ -114,12 +148,22 @@ export const waitForCompile = ({
   prefix = 'compile',
   pdf = false,
   cached = false,
+  regular = true,
 } = {}) => {
   if (cached) {
     cy.wait(`@${prefix}-cached`)
-  } else {
+  }
+  if (regular) {
     cy.wait(`@${prefix}`)
   }
+  return waitForCompileOutput({ prefix, pdf, cached })
+}
+
+export const waitForCompileOutput = ({
+  prefix = 'compile',
+  pdf = false,
+  cached = false,
+} = {}) => {
   cy.wait(`@${prefix}-log`)
     .its('request.query.clsiserverid')
     .should('eq', cached ? 'cache' : 'foo') // straight from cache if cached

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

@@ -1,8 +1,10 @@
 import '@testing-library/cypress/add-commands'
 import {
   interceptCompile,
+  interceptCompileFromCacheRequest,
   waitForCompile,
   interceptDeferredCompile,
+  interceptCompileRequest,
 } from './compile'
 import { interceptEvents } from './events'
 import { interceptAsync } from './intercept-async'
@@ -21,6 +23,8 @@ declare global {
     interface Chainable {
       interceptAsync: typeof interceptAsync
       interceptCompile: typeof interceptCompile
+      interceptCompileRequest: typeof interceptCompileRequest
+      interceptCompileFromCacheRequest: typeof interceptCompileFromCacheRequest
       interceptEvents: typeof interceptEvents
       interceptMetadata: typeof interceptMetadata
       waitForCompile: typeof waitForCompile
@@ -36,6 +40,11 @@ declare global {
 
 Cypress.Commands.add('interceptAsync', interceptAsync)
 Cypress.Commands.add('interceptCompile', interceptCompile)
+Cypress.Commands.add('interceptCompileRequest', interceptCompileRequest)
+Cypress.Commands.add(
+  'interceptCompileFromCacheRequest',
+  interceptCompileFromCacheRequest
+)
 Cypress.Commands.add('interceptEvents', interceptEvents)
 Cypress.Commands.add('interceptMetadata', interceptMetadata)
 Cypress.Commands.add('waitForCompile', waitForCompile)

+ 28 - 3
services/web/frontend/js/ide/connection/SocketIoShim.js

@@ -277,13 +277,34 @@ if (typeof io === 'undefined' || !io) {
   current = SocketShimV2
 }
 
-export class SocketIOMock extends EventEmitter {
+export class SocketIOMock extends SocketShimBase {
+  constructor() {
+    super(new EventEmitter())
+    this.socket = {
+      get connected() {
+        return false
+      },
+      get sessionid() {
+        return undefined
+      },
+      get transport() {
+        return {}
+      },
+      get transports() {
+        return []
+      },
+
+      connect() {},
+      disconnect(reason) {},
+    }
+  }
+
   addListener(event, listener) {
-    this.on(event, listener)
+    this._socket.on(event, listener)
   }
 
   removeListener(event, listener) {
-    this.off(event, listener)
+    this._socket.off(event, listener)
   }
 
   disconnect() {
@@ -294,6 +315,10 @@ export class SocketIOMock extends EventEmitter {
     // Round-trip through JSON.parse/stringify to simulate (de-)serializing on network layer.
     this.emit(...JSON.parse(JSON.stringify(args)))
   }
+
+  countEventListeners(event) {
+    return this._socket.events[event].length
+  }
 }
 
 export default {

+ 91 - 29
services/web/frontend/js/shared/context/local-compile-context.tsx

@@ -34,6 +34,7 @@ import { buildFileList } from '../../features/pdf-preview/util/file-list'
 import { useLayoutContext } from './layout-context'
 import { useUserContext } from './user-context'
 import { useFileTreeData } from '@/shared/context/file-tree-data-context'
+import { useDetachContext } from '@/shared/context/detach-context'
 import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
 import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
@@ -46,6 +47,8 @@ import {
 } from '@/shared/hooks/use-pdf-scroll-position'
 import { PdfFileDataList } from '@/features/pdf-preview/util/types'
 import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+import { captureException } from '@/infrastructure/error-reporter'
+import OError from '@overleaf/o-error'
 
 type PdfFile = Record<string, any>
 
@@ -117,8 +120,15 @@ export const LocalCompileContext = createContext<CompileContext | undefined>(
 export const LocalCompileProvider: FC = ({ children }) => {
   const { hasPremiumCompile, isProjectOwner } = useEditorContext()
   const { openDocWithId, openDocs, currentDocument } = useEditorManagerContext()
+  const { role } = useDetachContext()
 
-  const { _id: projectId, rootDocId, joinedOnce } = useProjectContext()
+  const {
+    _id: projectId,
+    rootDocId,
+    joinedOnce,
+    imageName,
+    compiler: compilerName,
+  } = useProjectContext()
 
   const { pdfPreviewOpen } = useLayoutContext()
 
@@ -190,10 +200,15 @@ export const LocalCompileProvider: FC = ({ children }) => {
   const [compiledOnce, setCompiledOnce] = useState(false)
   // fetch initial compile response from cache
   const [initialCompileFromCache, setInitialCompileFromCache] = useState(
-    isSplitTestEnabled('initial-compile-from-clsi-cache')
+    isSplitTestEnabled('initial-compile-from-clsi-cache') &&
+      // Avoid fetching the initial compile from cache in PDF detach tab
+      role !== 'detached'
   )
-  // Compile triggered while fetching the initial compile from cache
-  const upgradeInitialCompileFromCacheRef = useRef(false)
+  // fetch of initial compile from cache is pending
+  const [pendingInitialCompileFromCache, setPendingInitialCompileFromCache] =
+    useState(false)
+  // Raw data from clsi-cache, will need post-processing and check settings
+  const [dataFromCache, setDataFromCache] = useState<CompileResponseData>()
 
   // whether the cache is being cleared
   const [clearingCache, setClearingCache] = useState(false)
@@ -337,41 +352,88 @@ export const LocalCompileProvider: FC = ({ children }) => {
 
   // try to fetch the last compile result after opening the project, potentially before joining the project.
   useEffect(() => {
-    if (initialCompileFromCache) {
-      setInitialCompileFromCache(false)
-      setCompiling(true)
-      setCompiledOnce(true)
+    if (initialCompileFromCache && !pendingInitialCompileFromCache) {
+      setPendingInitialCompileFromCache(true)
       getJSON(`/project/${projectId}/output/cached/output.overleaf.json`)
         .then((data: any) => {
-          setCompiling(false)
-          setData({
-            ...data,
-            options: compiler.defaultOptions,
-          })
-          if (upgradeInitialCompileFromCacheRef.current) {
-            compilingRef.current = false
-            compiler.compile() // trigger regular compile
-          }
+          // Hand data over to next effect, it will wait for project/doc loading.
+          setDataFromCache(data)
         })
         .catch(() => {
-          setCompiling(false)
-          if (upgradeInitialCompileFromCacheRef.current) {
-            compilingRef.current = false
-            compiler.compile() // trigger regular compile
-          } else {
-            setCompiledOnce(false) // trigger auto compile
-          }
+          // Let the isAutoCompileOnLoad effect take over
+          setInitialCompileFromCache(false)
+          setPendingInitialCompileFromCache(false)
         })
     }
-  }, [projectId, initialCompileFromCache, compiler])
+  }, [projectId, initialCompileFromCache, pendingInitialCompileFromCache])
+
+  // Maybe adopt the compile from cache
+  useEffect(() => {
+    if (!dataFromCache) return // no compile from cache available
+    if (!joinedOnce) return // wait for joinProject, it populates the file-tree.
+    if (!currentDocument) return // wait for current doc to load, it affects the rootDoc override
+    if (compiledOnce) return // regular compile triggered
+
+    // Gracefully access file-tree and getRootDocOverride
+    let settingsUpToDate = false
+    try {
+      dataFromCache.rootDocId = findEntityByPath(
+        dataFromCache.options?.rootResourcePath || ''
+      )?.entity?._id
+      const rootDocOverride = compiler.getRootDocOverrideId() || rootDocId
+      settingsUpToDate =
+        rootDocOverride === dataFromCache.rootDocId &&
+        dataFromCache.options.imageName === imageName &&
+        dataFromCache.options.compiler === compilerName &&
+        dataFromCache.options.stopOnFirstError === stopOnFirstError &&
+        dataFromCache.options.draft === draft
+    } catch (err) {
+      captureException(
+        OError.tag(err as unknown as Error, 'validate compile options', {
+          options: dataFromCache.options,
+        })
+      )
+    }
+
+    if (settingsUpToDate) {
+      setData(dataFromCache)
+      setCompiledOnce(true)
+    }
+    setDataFromCache(undefined)
+    setInitialCompileFromCache(false)
+    setPendingInitialCompileFromCache(false)
+  }, [
+    dataFromCache,
+    joinedOnce,
+    currentDocument,
+    compiledOnce,
+    rootDocId,
+    findEntityByPath,
+    compiler,
+    compilerName,
+    imageName,
+    stopOnFirstError,
+    draft,
+  ])
 
   // always compile the PDF once after opening the project, after the doc has loaded
   useEffect(() => {
-    if (!compiledOnce && currentDocument && !initialCompileFromCache) {
+    if (
+      !compiledOnce &&
+      currentDocument &&
+      !initialCompileFromCache &&
+      !pendingInitialCompileFromCache
+    ) {
       setCompiledOnce(true)
       compiler.compile({ isAutoCompileOnLoad: true })
     }
-  }, [compiledOnce, currentDocument, initialCompileFromCache, compiler])
+  }, [
+    compiledOnce,
+    currentDocument,
+    initialCompileFromCache,
+    pendingInitialCompileFromCache,
+    compiler,
+  ])
 
   useEffect(() => {
     setHasShortCompileTimeout(
@@ -619,10 +681,10 @@ export const LocalCompileProvider: FC = ({ children }) => {
   // start a compile manually
   const startCompile = useCallback(
     options => {
-      upgradeInitialCompileFromCacheRef.current = true
+      setCompiledOnce(true)
       compiler.compile(options)
     },
-    [compiler, upgradeInitialCompileFromCacheRef]
+    [compiler, setCompiledOnce]
   )
 
   // stop a compile manually

+ 3 - 0
services/web/frontend/js/shared/context/project-context.tsx

@@ -34,6 +34,7 @@ export const ProjectProvider: FC = ({ children }) => {
   const {
     _id,
     compiler,
+    imageName,
     name,
     rootDoc_id: rootDocId,
     members,
@@ -59,6 +60,7 @@ export const ProjectProvider: FC = ({ children }) => {
     return {
       _id,
       compiler,
+      imageName,
       name,
       rootDocId,
       members,
@@ -75,6 +77,7 @@ export const ProjectProvider: FC = ({ children }) => {
   }, [
     _id,
     compiler,
+    imageName,
     name,
     rootDocId,
     members,

+ 1 - 0
services/web/frontend/js/shared/context/types/project-context.tsx

@@ -18,6 +18,7 @@ export type ProjectContextValue = {
   rootDocId?: string
   mainBibliographyDocId?: string
   compiler: string
+  imageName: string
   members: ProjectContextMember[]
   invites: ProjectContextMember[]
   features: {

+ 1 - 0
services/web/frontend/stories/decorators/scope.tsx

@@ -89,6 +89,7 @@ const initialize = () => {
       sharejs_doc: {
         doc_id: 'test-doc',
         getSnapshot: () => 'some doc content',
+        hasBufferedOps: () => false,
       },
       open_doc_name: 'testfile.tex',
     },

+ 1 - 0
services/web/frontend/stories/fixtures/document.ts

@@ -2,6 +2,7 @@ export function mockDocument(text: string) {
   return {
     doc_id: 'story-doc',
     getSnapshot: () => text,
+    hasBufferedOps: () => false,
   }
 }
 

+ 1 - 0
services/web/frontend/stories/source-editor/source-editor.stories.tsx

@@ -211,6 +211,7 @@ const mockDoc = (content: string, changes: Array<Record<string, any>> = []) => {
       return null
     },
     ranges: new RangesTracker(changes, []),
+    hasBufferedOps: () => false,
   }
 }
 

+ 174 - 4
services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx

@@ -28,7 +28,19 @@ const Layout: FC<{ layout: IdeLayout; view?: IdeView }> = ({
 }
 
 describe('<PdfPreview/>', function () {
+  let projectId: string
   beforeEach(function () {
+    /**
+     * There are time sensitive tests in this test suite. They need to wait for a Promise before resolving a request.
+     *
+     * Using a promise across the test-env (browser) vs stub-env (server) causes additional latency.
+     *
+     * This latency seems to stack up when adding more intercepts for the same path. Using static responses for some of these intercepts does not help.
+     *
+     * All of that seems like a bug in Cypress. For now just work around it by using a unique projectId for each intercept.
+     */
+    projectId = Math.random().toString().slice(2)
+
     window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
     window.metaAttributesCache.set(
       'ol-compilesUserContentDomain',
@@ -61,7 +73,12 @@ describe('<PdfPreview/>', function () {
   })
 
   it('uses the cache when available', function () {
-    cy.interceptCompile({ prefix: 'compile', times: 1, cached: true })
+    cy.interceptCompile({
+      prefix: 'compile',
+      times: 1,
+      cached: true,
+      regular: false,
+    })
 
     const scope = mockScope()
 
@@ -74,13 +91,18 @@ describe('<PdfPreview/>', function () {
     )
 
     // wait for "compile from cache on load" to finish
-    cy.waitForCompile({ pdf: true, cached: true })
+    cy.waitForCompile({ pdf: true, cached: true, regular: false })
 
     cy.contains('Your Paper')
   })
 
   it('uses the cache when available then compiles', function () {
-    cy.interceptCompile({ prefix: 'compile', times: 1, cached: true })
+    cy.interceptCompile({
+      prefix: 'compile',
+      times: 1,
+      cached: true,
+      regular: false,
+    })
 
     const scope = mockScope()
 
@@ -93,7 +115,7 @@ describe('<PdfPreview/>', function () {
     )
 
     // wait for "compile from cache on load" to finish
-    cy.waitForCompile({ pdf: true, cached: true })
+    cy.waitForCompile({ pdf: true, cached: true, regular: false })
     cy.contains('Your Paper')
 
     // Then trigger a new compile
@@ -112,6 +134,154 @@ describe('<PdfPreview/>', function () {
     cy.contains('Modern Authoring Tools for Science')
   })
 
+  describe('racing compile from cache and regular compile trigger', function () {
+    for (const [timing] of ['before rendering', 'after rendering']) {
+      it(`replaces the compile from cache with a regular compile - ${timing}`, function () {
+        const requestedOnce = new Set()
+        ;['log', 'pdf', 'blg'].forEach(ext => {
+          cy.intercept({ pathname: `/build/*/output.${ext}` }, req => {
+            if (requestedOnce.has(ext)) {
+              throw new Error(
+                `compile from cache triggered extra ${ext} request: ${req.url}`
+              )
+            }
+            requestedOnce.add(ext)
+            req.reply({ fixture: `build/output.${ext},null` })
+          }).as(`compile-${ext}`)
+        })
+        const { promise, resolve } = Promise.withResolvers<void>()
+        cy.interceptCompileFromCacheRequest({
+          promise,
+          times: 1,
+        }).as('cached-compile')
+        cy.interceptCompileRequest().as('compile')
+
+        const scope = mockScope()
+        cy.mount(
+          <EditorProviders scope={scope} projectId={projectId}>
+            <div className="pdf-viewer">
+              <PdfPreview />
+            </div>
+          </EditorProviders>
+        )
+
+        // press the Recompile button => compile
+        cy.findByRole('button', { name: 'Recompile' }).click()
+
+        if (timing === 'before rendering') {
+          cy.then(() => resolve())
+          cy.wait('@cached-compile')
+        }
+
+        // wait for rendering to finish
+        cy.waitForCompile({ pdf: true, cached: false })
+
+        if (timing === 'after rendering') {
+          cy.then(() => resolve())
+          cy.wait('@cached-compile')
+        }
+
+        cy.contains('Your Paper')
+        cy.then(() => Array.from(requestedOnce).sort().join(',')).should(
+          'equal',
+          'blg,log,pdf'
+        )
+      })
+    }
+  })
+
+  describe('clsi-cache project settings validation', function () {
+    const cases = {
+      // Flaky, skip for now
+      'uses compile from cache when nothing changed': {
+        cached: true,
+        setup: () => {},
+        props: {},
+      },
+      'ignores the compile from cache when imageName changed': {
+        cached: false,
+        setup: () => {},
+        props: {
+          imageName: 'texlive-full:2025.1',
+        },
+      },
+      'ignores the compile from cache when compiler changed': {
+        cached: false,
+        setup: () => {},
+        props: {
+          compiler: 'lualatex',
+        },
+      },
+      'ignores the compile from cache when draft mode changed': {
+        cached: false,
+        setup: () => {
+          cy.window().then(w =>
+            w.localStorage.setItem(`draft:${projectId}`, 'true')
+          )
+        },
+        props: {},
+      },
+      'ignores the compile from cache when stopOnFirstError mode changed': {
+        cached: false,
+        setup: () => {
+          cy.window().then(w =>
+            w.localStorage.setItem(`stop_on_first_error:${projectId}`, 'true')
+          )
+        },
+        props: {},
+      },
+      'ignores the compile from cache when rootDoc changed': {
+        cached: false,
+        setup: () => {},
+        props: {
+          rootDocId: 'new-root-doc-id',
+          rootFolder: [
+            {
+              _id: 'root-folder-id',
+              name: 'rootFolder',
+              docs: [
+                {
+                  _id: '_root_doc_id',
+                  name: 'main.tex',
+                },
+                {
+                  _id: 'new-root-doc-id',
+                  name: 'new-main.tex',
+                },
+              ],
+              folders: [],
+              fileRefs: [],
+            },
+          ],
+        },
+      },
+    }
+    Object.entries(cases).forEach(([name, { cached, setup, props }]) => {
+      it(name, function () {
+        cy.interceptCompile({
+          cached: true,
+          regular: !cached,
+        })
+
+        const scope = mockScope()
+        window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
+        setup()
+
+        cy.mount(
+          <EditorProviders scope={scope} projectId={projectId} {...props}>
+            <div className="pdf-viewer">
+              <PdfPreview />
+            </div>
+          </EditorProviders>
+        )
+
+        // wait for compile to finish
+        cy.waitForCompile({ pdf: true, cached, regular: !cached })
+        cy.contains('Your Paper')
+      })
+    })
+  })
+
   it('runs a compile when the Recompile button is pressed', function () {
     cy.interceptCompile()
 

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

@@ -10,6 +10,7 @@ export const mockScope = () => ({
     sharejs_doc: {
       doc_id: 'test-doc',
       getSnapshot: () => 'some doc content',
+      hasBufferedOps: () => false,
     },
     view: new EditorView({
       doc: '\\documentclass{article}',

+ 2 - 4
services/web/test/frontend/features/chat/context/chat-context.test.jsx

@@ -56,8 +56,7 @@ describe('ChatContext', function () {
     it('subscribes when mounted', function () {
       const socket = new SocketIOMock()
       renderChatContextHook({ socket })
-      // Assert that there is 1 listener
-      expect(socket.events['new-chat-message']).to.have.length(1)
+      expect(socket.countEventListeners('new-chat-message')).to.equal(1)
     })
 
     it('unsubscribes when unmounted', function () {
@@ -66,8 +65,7 @@ describe('ChatContext', function () {
 
       unmount()
 
-      // Assert that there is 0 listeners
-      expect(socket.events['new-chat-message'].length).to.equal(0)
+      expect(socket.countEventListeners('new-chat-message')).to.equal(0)
     })
 
     it('adds received messages to the list', async function () {

+ 1 - 1
services/web/test/frontend/features/file-tree/components/file-tree-toolbar.spec.tsx

@@ -5,7 +5,7 @@ import { FileTreeProvider } from '../helpers/file-tree-provider'
 describe('<FileTreeToolbar/>', function () {
   it('without selected files', function () {
     cy.mount(
-      <EditorProviders>
+      <EditorProviders rootDocId="">
         <FileTreeProvider>
           <FileTreeToolbar />
         </FileTreeProvider>

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

@@ -44,6 +44,8 @@ export function EditorProviders({
     email: 'owner@example.com',
   },
   rootDocId = '_root_doc_id',
+  imageName = 'texlive-full:2024.1',
+  compiler = 'pdflatex',
   socket = new SocketIOMock(),
   isRestrictedTokenMember = false,
   clsiServerId = '1234',
@@ -58,7 +60,12 @@ export function EditorProviders({
     {
       _id: 'root-folder-id',
       name: 'rootFolder',
-      docs: [],
+      docs: [
+        {
+          _id: '_root_doc_id',
+          name: 'main.tex',
+        },
+      ],
       folders: [],
       fileRefs: [],
     },
@@ -99,6 +106,10 @@ export function EditorProviders({
         sharejs_doc: {
           doc_id: 'test-doc',
           getSnapshot: () => 'some doc content',
+          hasBufferedOps: () => false,
+          on: () => {},
+          off: () => {},
+          leaveAndCleanUpPromise: async () => {},
         },
       },
       project: {
@@ -108,6 +119,8 @@ export function EditorProviders({
         features: projectFeatures,
         rootDoc_id: rootDocId,
         rootFolder,
+        imageName,
+        compiler,
       },
       ui,
       $watch: (path, callback) => {