Parcourir la source

[saas-e2e] harden recompiling (#28514)

* [saas-e2e] harden recompiling

* [server-ce] import changes from SaaS e2e tests

GitOrigin-RevId: 9b86ed864457b068b8dded94a7e12658a047aac2
Jakob Ackermann il y a 1 an
Parent
commit
a13fca15d3

+ 5 - 4
server-ce/test/create-and-compile-project.spec.ts

@@ -4,7 +4,7 @@ import {
   openProjectViaInviteNotification,
   openProjectViaInviteNotification,
 } from './helpers/project'
 } from './helpers/project'
 import { isExcludedBySharding, startWith } from './helpers/config'
 import { isExcludedBySharding, startWith } from './helpers/config'
-import { throttledRecompile } from './helpers/compile'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
 
 
 const USER = 'user@example.com'
 const USER = 'user@example.com'
 const COLLABORATOR = 'collaborator@example.com'
 const COLLABORATOR = 'collaborator@example.com'
@@ -17,8 +17,10 @@ describe('Project creation and compilation', function () {
 
 
   it('users can create project and compile it', function () {
   it('users can create project and compile it', function () {
     login(USER)
     login(USER)
-    createProject('test-project')
-    const recompile = throttledRecompile()
+    const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+    waitForCompile(() => {
+      createProject('test-project')
+    })
     cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
     cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
       cy.findByText('\\maketitle').parent().click()
       cy.findByText('\\maketitle').parent().click()
       cy.findByText('\\maketitle').parent().type('\n\\section{{}Test Section}')
       cy.findByText('\\maketitle').parent().type('\n\\section{{}Test Section}')
@@ -109,7 +111,6 @@ describe('Project creation and compilation', function () {
     cy.findByRole('navigation', { name: 'Project files and outline' })
     cy.findByRole('navigation', { name: 'Project files and outline' })
       .findByRole('button', { name: 'New file' })
       .findByRole('button', { name: 'New file' })
       .click()
       .click()
-
     cy.findByRole('dialog').within(() => {
     cy.findByRole('dialog').within(() => {
       cy.findByRole('button', { name: 'From another project' }).click()
       cy.findByRole('button', { name: 'From another project' }).click()
       cy.findByLabelText('Select a Project').select(sourceProjectName)
       cy.findByLabelText('Select a Project').select(sourceProjectName)

+ 3 - 4
server-ce/test/editor.spec.ts

@@ -22,20 +22,19 @@ describe('editor', () => {
   let projectName: string
   let projectName: string
   let projectId: string
   let projectId: string
   let recompile: () => void
   let recompile: () => void
-  let waitForCompileRateLimitCoolOff: (fn: () => void) => void
+  let waitForCompile: (fn: () => void) => void
   beforeWithReRunOnTestRetry(function () {
   beforeWithReRunOnTestRetry(function () {
     projectName = `project-${uuid()}`
     projectName = `project-${uuid()}`
     login(USER)
     login(USER)
     createProject(projectName, { type: 'Example project', open: false }).then(
     createProject(projectName, { type: 'Example project', open: false }).then(
       id => (projectId = id)
       id => (projectId = id)
     )
     )
-    ;({ recompile, waitForCompileRateLimitCoolOff } =
-      prepareWaitForNextCompileSlot())
+    ;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
   })
   })
 
 
   beforeEach(() => {
   beforeEach(() => {
     login(USER)
     login(USER)
-    waitForCompileRateLimitCoolOff(() => {
+    waitForCompile(() => {
       openProjectById(projectId)
       openProjectById(projectId)
     })
     })
   })
   })

+ 6 - 7
server-ce/test/filestore-migration.spec.ts

@@ -39,7 +39,7 @@ describe('filestore migration', function () {
   const projectName = `project-${uuid()}`
   const projectName = `project-${uuid()}`
   let defaultImage: string
   let defaultImage: string
   let projectId: string
   let projectId: string
-  let waitForCompileRateLimitCoolOff: (fn: () => void) => void
+  let waitForCompile: (fn: () => void) => void
   const previousBinaryFiles: (() => void)[] = []
   const previousBinaryFiles: (() => void)[] = []
 
 
   function avoid502() {
   function avoid502() {
@@ -55,7 +55,7 @@ describe('filestore migration', function () {
   ) {
   ) {
     before(function () {
     before(function () {
       login(email)
       login(email)
-      waitForCompileRateLimitCoolOff(() => {
+      waitForCompile(() => {
         cy.visit(`/project/${projectId}`)
         cy.visit(`/project/${projectId}`)
       })
       })
       previousBinaryFiles.push(prepareFileUploadTest(true))
       previousBinaryFiles.push(prepareFileUploadTest(true))
@@ -112,8 +112,7 @@ describe('filestore migration', function () {
         .should('match', /\/project\/[a-fA-F0-9]{24}/)
         .should('match', /\/project\/[a-fA-F0-9]{24}/)
         .then(url => (projectId = url.split('/').pop()!))
         .then(url => (projectId = url.split('/').pop()!))
       let queueReset
       let queueReset
-      ;({ waitForCompileRateLimitCoolOff, queueReset } =
-        prepareWaitForNextCompileSlot())
+      ;({ waitForCompile, queueReset } = prepareWaitForNextCompileSlot())
       queueReset()
       queueReset()
 
 
       // Create a new binary file
       // Create a new binary file
@@ -265,7 +264,7 @@ describe('filestore migration', function () {
       createProject(projectName, { type: 'Example project', open: false }).then(
       createProject(projectName, { type: 'Example project', open: false }).then(
         id => (projectId = id)
         id => (projectId = id)
       )
       )
-      ;({ waitForCompileRateLimitCoolOff } = prepareWaitForNextCompileSlot())
+      ;({ waitForCompile } = prepareWaitForNextCompileSlot())
     })
     })
   }
   }
   addNewBinaryFileAndCheckPrevious()
   addNewBinaryFileAndCheckPrevious()
@@ -298,7 +297,7 @@ describe('filestore migration', function () {
   // filestore-migration
   // filestore-migration
   beforeEach(() => {
   beforeEach(() => {
     login(email)
     login(email)
-    waitForCompileRateLimitCoolOff(() => {
+    waitForCompile(() => {
       openProjectById(projectId)
       openProjectById(projectId)
     })
     })
     ensureStopOnFirstErrorIsActive()
     ensureStopOnFirstErrorIsActive()
@@ -327,7 +326,7 @@ describe('filestore migration', function () {
         .parent()
         .parent()
         .type(`\n\\section{{}Test Section ${id}}`)
         .type(`\n\\section{{}Test Section ${id}}`)
 
 
-      waitForCompileRateLimitCoolOff(() => {
+      waitForCompile(() => {
         cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
         cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
 
 
         cy.findByRole('menuitem', {
         cy.findByRole('menuitem', {

+ 213 - 181
server-ce/test/git-bridge.spec.ts

@@ -12,7 +12,7 @@ import {
 import git from 'isomorphic-git'
 import git from 'isomorphic-git'
 import http from 'isomorphic-git/http/web'
 import http from 'isomorphic-git/http/web'
 import LightningFS from '@isomorphic-git/lightning-fs'
 import LightningFS from '@isomorphic-git/lightning-fs'
-import { throttledRecompile } from './helpers/compile'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
 
 
 const USER = 'user@example.com'
 const USER = 'user@example.com'
 
 
@@ -52,13 +52,13 @@ describe('git-bridge', function () {
     function maybeClearAllTokens() {
     function maybeClearAllTokens() {
       cy.visit('/user/settings')
       cy.visit('/user/settings')
       cy.findByRole('heading', { name: 'Git integration' })
       cy.findByRole('heading', { name: 'Git integration' })
-      cy.get('button')
-        .contains(/Generate token|Add another token/)
-        .then(btn => {
-          if (btn.text() === 'Add another token') {
-            clearAllTokens()
-          }
-        })
+      cy.findByRole('button', {
+        name: /Generate token|Add another token/i,
+      }).then(btn => {
+        if (btn.text() === 'Add another token') {
+          clearAllTokens()
+        }
+      })
     }
     }
 
 
     beforeEach(function () {
     beforeEach(function () {
@@ -130,9 +130,12 @@ describe('git-bridge', function () {
       cy.findByTestId('left-menu').within(() => {
       cy.findByTestId('left-menu').within(() => {
         cy.findByRole('button', { name: 'Git' }).click()
         cy.findByRole('button', { name: 'Git' }).click()
       })
       })
+
       cy.findByTestId('git-bridge-modal').within(() => {
       cy.findByTestId('git-bridge-modal').within(() => {
         cy.get('@projectId').then(id => {
         cy.get('@projectId').then(id => {
-          cy.get('code').contains(`git clone ${gitURL(id.toString())}`)
+          cy.findByLabelText('Git clone project command').contains(
+            `git clone ${gitURL(id.toString())}`
+          )
         })
         })
         cy.findByRole('button', {
         cy.findByRole('button', {
           name: 'Generate token',
           name: 'Generate token',
@@ -151,14 +154,19 @@ describe('git-bridge', function () {
       ensureUserExists({ email: 'collaborator-link-ro@example.com' })
       ensureUserExists({ email: 'collaborator-link-ro@example.com' })
 
 
       let projectName: string
       let projectName: string
+      let recompile: () => void
+      let waitForCompile: (triggerCompile: () => void) => void
       beforeEach(() => {
       beforeEach(() => {
         projectName = uuid()
         projectName = uuid()
         createProject(projectName, { open: false }).as('projectId')
         createProject(projectName, { open: false }).as('projectId')
+        ;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
       })
       })
 
 
       it('should expose r/w interface to owner', () => {
       it('should expose r/w interface to owner', () => {
         maybeClearAllTokens()
         maybeClearAllTokens()
-        openProjectByName(projectName)
+        waitForCompile(() => {
+          openProjectByName(projectName)
+        })
         checkGitAccess('readAndWrite')
         checkGitAccess('readAndWrite')
       })
       })
 
 
@@ -169,7 +177,9 @@ describe('git-bridge', function () {
           'Editor'
           'Editor'
         )
         )
         maybeClearAllTokens()
         maybeClearAllTokens()
-        openProjectByName(projectName)
+        waitForCompile(() => {
+          openProjectByName(projectName)
+        })
         checkGitAccess('readAndWrite')
         checkGitAccess('readAndWrite')
       })
       })
 
 
@@ -180,7 +190,9 @@ describe('git-bridge', function () {
           'Viewer'
           'Viewer'
         )
         )
         maybeClearAllTokens()
         maybeClearAllTokens()
-        openProjectByName(projectName)
+        waitForCompile(() => {
+          openProjectByName(projectName)
+        })
         checkGitAccess('readOnly')
         checkGitAccess('readOnly')
       })
       })
 
 
@@ -190,212 +202,232 @@ describe('git-bridge', function () {
           const email = 'collaborator-link-rw@example.com'
           const email = 'collaborator-link-rw@example.com'
           login(email)
           login(email)
           maybeClearAllTokens()
           maybeClearAllTokens()
-          openProjectViaLinkSharingAsUser(
-            linkSharingReadAndWrite,
-            projectName,
-            email
-          )
+          waitForCompile(() => {
+            openProjectViaLinkSharingAsUser(
+              linkSharingReadAndWrite,
+              projectName,
+              email
+            )
+          })
           checkGitAccess('readAndWrite')
           checkGitAccess('readAndWrite')
         })
         })
       })
       })
 
 
       it('should expose r/o interface to link-sharing r/o collaborator', () => {
       it('should expose r/o interface to link-sharing r/o collaborator', () => {
-        openProjectByName(projectName)
+        waitForCompile(() => {
+          openProjectByName(projectName)
+        })
         enableLinkSharing().then(({ linkSharingReadOnly }) => {
         enableLinkSharing().then(({ linkSharingReadOnly }) => {
           const email = 'collaborator-link-ro@example.com'
           const email = 'collaborator-link-ro@example.com'
           login(email)
           login(email)
           maybeClearAllTokens()
           maybeClearAllTokens()
-          openProjectViaLinkSharingAsUser(
-            linkSharingReadOnly,
-            projectName,
-            email
-          )
+          waitForCompile(() => {
+            openProjectViaLinkSharingAsUser(
+              linkSharingReadOnly,
+              projectName,
+              email
+            )
+          })
           checkGitAccess('readOnly')
           checkGitAccess('readOnly')
         })
         })
       })
       })
-    })
-
-    function checkGitAccess(access: 'readOnly' | 'readAndWrite') {
-      const recompile = throttledRecompile()
 
 
-      cy.findByRole('navigation', {
-        name: 'Project actions',
-      })
-        .findByRole('button', { name: 'Menu' })
-        .click()
-      cy.findByTestId('left-menu').within(() => {
-        cy.findByRole('heading', { name: 'Sync' })
-        cy.findByRole('button', { name: 'Git' }).click()
-      })
-      cy.get('@projectId').then(projectId => {
-        cy.findByTestId('git-bridge-modal').within(() => {
-          cy.findByLabelText('Git clone project command').contains(
-            `git clone ${gitURL(projectId.toString())}`
-          )
+      function checkGitAccess(access: 'readOnly' | 'readAndWrite') {
+        cy.findByRole('navigation', {
+          name: 'Project actions',
         })
         })
-        cy.findByRole('heading', { name: 'Clone with Git' })
-        cy.findByRole('button', {
-          name: 'Generate token',
-        }).click()
-        cy.get('code')
-          .contains(/olp_[a-zA-Z0-9]{16}/)
-          .then(async tokenEl => {
-            const token = tokenEl.text()
-
-            // close Git modal
-            cy.findAllByText('Close').last().click()
-            // close editor menu
-            cy.get('.left-menu-modal-backdrop').click()
-
-            const fs = new LightningFS('fs')
-            const dir = `/${projectId}`
-
-            async function readFile(path: string): Promise<string> {
-              return new Promise((resolve, reject) => {
-                fs.readFile(path, { encoding: 'utf8' }, (err, blob) => {
-                  if (err) return reject(err)
-                  resolve(blob as string)
+          .findByRole('button', { name: 'Menu' })
+          .click()
+        cy.findByTestId('left-menu').within(() => {
+          cy.findByRole('heading', { name: 'Sync' })
+          cy.findByRole('button', { name: 'Git' }).click()
+        })
+        cy.get('@projectId').then(projectId => {
+          cy.findByTestId('git-bridge-modal').within(() => {
+            cy.findByLabelText('Git clone project command').contains(
+              `git clone ${gitURL(projectId.toString())}`
+            )
+            cy.findByRole('heading', { name: 'Clone with Git' })
+            cy.findByRole('button', {
+              name: 'Generate token',
+            }).click()
+          })
+          cy.findByLabelText('Git authentication token')
+            .contains(/olp_[a-zA-Z0-9]{16}/)
+            .then(async tokenEl => {
+              const token = tokenEl.text()
+
+              // close Git modal
+              cy.get('body').type('{esc}')
+              cy.findByTestId('git-bridge-modal').should('not.exist')
+              // close the modal
+              cy.get('body').type('{esc}')
+              cy.findByTestId('left-menu').should('not.exist')
+              const fs = new LightningFS('fs')
+              const dir = `/${projectId}`
+
+              async function readFile(path: string): Promise<string> {
+                return new Promise((resolve, reject) => {
+                  fs.readFile(path, { encoding: 'utf8' }, (err, blob) => {
+                    if (err) return reject(err)
+                    resolve(blob as string)
+                  })
                 })
                 })
-              })
-            }
+              }
 
 
-            async function writeFile(path: string, data: string) {
-              return new Promise<void>((resolve, reject) => {
-                fs.writeFile(path, data, undefined, err => {
-                  if (err) return reject(err)
-                  resolve()
+              async function writeFile(path: string, data: string) {
+                return new Promise<void>((resolve, reject) => {
+                  fs.writeFile(path, data, undefined, err => {
+                    if (err) return reject(err)
+                    resolve()
+                  })
+                })
+              }
+
+              const commonOptions = {
+                dir,
+                fs,
+              }
+              const url = gitURL(projectId.toString())
+              url.username = '' // basic auth is specified separately.
+              const httpOptions = {
+                http,
+                url: url.toString(),
+                headers: {
+                  Authorization: `Basic ${Buffer.from(`git:${token}`).toString('base64')}`,
+                },
+              }
+              const authorOptions = {
+                author: { name: 'user', email: USER },
+                committer: { name: 'user', email: USER },
+              }
+              const mainTex = `${dir}/main.tex`
+
+              // Clone
+              cy.then({ timeout: 10_000 }, async () => {
+                await git.clone({
+                  ...commonOptions,
+                  ...httpOptions,
                 })
                 })
               })
               })
-            }
-
-            const commonOptions = {
-              dir,
-              fs,
-            }
-            const url = gitURL(projectId.toString())
-            url.username = '' // basic auth is specified separately.
-            const httpOptions = {
-              http,
-              url: url.toString(),
-              headers: {
-                Authorization: `Basic ${Buffer.from(`git:${token}`).toString('base64')}`,
-              },
-            }
-            const authorOptions = {
-              author: { name: 'user', email: USER },
-              committer: { name: 'user', email: USER },
-            }
-            const mainTex = `${dir}/main.tex`
-
-            // Clone
-            cy.then({ timeout: 10_000 }, async () => {
-              await git.clone({
-                ...commonOptions,
-                ...httpOptions,
-              })
-            })
-
-            cy.findByText(/\\documentclass/)
-              .parent()
-              .parent()
-              .then(async editor => {
-                const onDisk = await readFile(mainTex)
-                expect(onDisk.replaceAll('\n', '')).to.equal(editor.text())
-              })
-
-            const text = `
+              cy.findByText(/\\documentclass/)
+                .parent()
+                .parent()
+                .then(async editor => {
+                  const onDisk = await readFile(mainTex)
+                  expect(onDisk.replaceAll('\n', '')).to.equal(editor.text())
+                })
+              const text = `
 \\documentclass{article}
 \\documentclass{article}
 \\begin{document}
 \\begin{document}
 Hello world
 Hello world
 \\end{document}
 \\end{document}
 `
 `
 
 
-            // Make a change
-            cy.then(async () => {
-              await writeFile(mainTex, text)
-              await git.add({
-                ...commonOptions,
-                filepath: 'main.tex',
-              })
-              await git.commit({
-                ...commonOptions,
-                ...authorOptions,
-                message: 'Swap main.tex',
-              })
-            })
-
-            if (access === 'readAndWrite') {
-              // check history before push
-              cy.findAllByText('History').last().click()
-              cy.findByText('(via Git)').should('not.exist')
-              cy.findAllByText('Back to editor').last().click()
-
+              // Make a change
               cy.then(async () => {
               cy.then(async () => {
-                await git.push({
+                await writeFile(mainTex, text)
+                await git.add({
                   ...commonOptions,
                   ...commonOptions,
-                  ...httpOptions,
+                  filepath: 'main.tex',
+                })
+                await git.commit({
+                  ...commonOptions,
+                  ...authorOptions,
+                  message: 'Swap main.tex',
                 })
                 })
               })
               })
-            } else {
-              cy.then(async () => {
-                try {
+
+              if (access === 'readAndWrite') {
+                // check history before push
+                cy.findByRole('navigation', {
+                  name: 'Project actions',
+                })
+                  .findByRole('button', { name: 'History' })
+                  .click()
+                cy.findByText('(via Git)').should('not.exist')
+                cy.findAllByText('Back to editor').last().click()
+                cy.then(async () => {
                   await git.push({
                   await git.push({
                     ...commonOptions,
                     ...commonOptions,
                     ...httpOptions,
                     ...httpOptions,
                   })
                   })
-                  expect.fail('push should have failed')
-                } catch (err) {
-                  expect(err).to.match(/branches were not updated/)
-                  expect(err).to.match(/forbidden/)
-                }
+                })
+              } else {
+                cy.then(async () => {
+                  try {
+                    await git.push({
+                      ...commonOptions,
+                      ...httpOptions,
+                    })
+                    expect.fail('push should have failed')
+                  } catch (err) {
+                    expect(err).to.match(/branches were not updated/)
+                    expect(err).to.match(/forbidden/)
+                  }
+                })
+
+                return // return early, below are write access bits
+              }
+
+              // check push in editor
+              cy.findByText(/\\documentclass/)
+                .parent()
+                .parent()
+                .should('have.text', text.replaceAll('\n', ''))
+
+              // Wait for history sync - trigger flush by toggling the UI
+              cy.findByRole('navigation', {
+                name: 'Project actions',
               })
               })
+                .findByRole('button', { name: 'History' })
+                .click()
+              cy.findAllByText('Back to editor').last().click()
 
 
-              return // return early, below are write access bits
-            }
-
-            // check push in editor
-            cy.findByText(/\\documentclass/)
-              .parent()
-              .parent()
-              .should('have.text', text.replaceAll('\n', ''))
-
-            // Wait for history sync - trigger flush by toggling the UI
-            cy.findAllByText('History').last().click()
-            cy.findAllByText('Back to editor').last().click()
-
-            // check push in history
-            cy.findAllByText('History').last().click()
-            cy.findByText(/Hello world/)
-            cy.findByText('(via Git)').should('exist')
-
-            // Back to the editor
-            cy.findAllByText('Back to editor').last().click()
-            cy.findByText(/\\documentclass/)
-              .parent()
-              .parent()
-              .click()
-              .type('% via editor{enter}')
-
-            // Trigger flush via compile
-            recompile()
-
-            // Back into the history, check what we just added
-            cy.findAllByText('History').last().click()
-            cy.findByText(/% via editor/)
-
-            // Pull the change
-            cy.then(async () => {
-              await git.pull({
-                ...commonOptions,
-                ...httpOptions,
-                ...authorOptions,
+              // check push in history
+              cy.findByRole('navigation', {
+                name: 'Project actions',
               })
               })
+                .findByRole('button', { name: 'History' })
+                .click()
+              cy.findByText(/Hello world/)
+              cy.findByText('(via Git)').should('exist')
+
+              // Back to the editor
+              cy.findAllByText('Back to editor').last().click()
+              cy.findByText(/\\documentclass/)
+                .parent()
+                .parent()
+                .click()
+                .type('% via editor{enter}')
+
+              // Trigger flush via compile
+              recompile()
+
+              // Back into the history, check what we just added
+              cy.findByRole('navigation', {
+                name: 'Project actions',
+              })
+                .findByRole('button', { name: 'History' })
+                .click()
+              cy.findByText(/% via editor/)
+
+              // Pull the change
+              cy.then(async () => {
+                await git.pull({
+                  ...commonOptions,
+                  ...httpOptions,
+                  ...authorOptions,
+                })
 
 
-              expect(await readFile(mainTex)).to.equal(text + '% via editor\n')
+                expect(await readFile(mainTex)).to.equal(
+                  text + '% via editor\n'
+                )
+              })
             })
             })
-          })
-      })
-    }
+        })
+      }
+    })
   })
   })
 
 
   function checkDisabled() {
   function checkDisabled() {

+ 6 - 4
server-ce/test/graceful-shutdown.spec.ts

@@ -6,7 +6,7 @@ import {
 } from './helpers/config'
 } from './helpers/config'
 import { dockerCompose, getRedisKeys } from './helpers/hostAdminClient'
 import { dockerCompose, getRedisKeys } from './helpers/hostAdminClient'
 import { createProject } from './helpers/project'
 import { createProject } from './helpers/project'
-import { throttledRecompile } from './helpers/compile'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
 
 
 const USER = 'user@example.com'
 const USER = 'user@example.com'
 const PROJECT_NAME = 'Old Project'
 const PROJECT_NAME = 'Old Project'
@@ -31,10 +31,12 @@ describe('GracefulShutdown', function () {
   it('should display banner and flush changes out of redis', () => {
   it('should display banner and flush changes out of redis', () => {
     bringServerProBackUp()
     bringServerProBackUp()
     login(USER)
     login(USER)
-    createProject(PROJECT_NAME).then(id => {
-      projectId = id
+    const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+    waitForCompile(() => {
+      createProject(PROJECT_NAME).then(id => {
+        projectId = id
+      })
     })
     })
-    const recompile = throttledRecompile()
 
 
     cy.log('add additional content')
     cy.log('add additional content')
     cy.findByText('\\maketitle').parent().click()
     cy.findByText('\\maketitle').parent().click()

+ 29 - 8
server-ce/test/helpers/compile.ts

@@ -3,11 +3,6 @@
  * The naive approach is waiting a fixed a mount of time (3s) just before clicking the button.
  * The naive approach is waiting a fixed a mount of time (3s) just before clicking the button.
  * This helper takes into account that other UI interactions take time. We can deduce that latency from the fixed delay (3s minus other latency). This can bring down the effective waiting time to 0s.
  * This helper takes into account that other UI interactions take time. We can deduce that latency from the fixed delay (3s minus other latency). This can bring down the effective waiting time to 0s.
  */
  */
-export function throttledRecompile() {
-  const { queueReset, recompile } = prepareWaitForNextCompileSlot()
-  queueReset()
-  return recompile
-}
 
 
 export function stopCompile(options: { delay?: number } = {}) {
 export function stopCompile(options: { delay?: number } = {}) {
   const { delay = 0 } = options
   const { delay = 0 } = options
@@ -24,25 +19,51 @@ export function prepareWaitForNextCompileSlot() {
       lastCompile = Date.now()
       lastCompile = Date.now()
     })
     })
   }
   }
-  function waitForCompileRateLimitCoolOff(triggerCompile: () => void) {
+  function waitForCompileRateLimitCoolOff() {
     cy.then(() => {
     cy.then(() => {
       cy.log('Wait for recompile rate-limit to cool off')
       cy.log('Wait for recompile rate-limit to cool off')
       const msSinceLastCompile = Date.now() - lastCompile
       const msSinceLastCompile = Date.now() - lastCompile
       cy.wait(Math.max(0, 1_000 - msSinceLastCompile))
       cy.wait(Math.max(0, 1_000 - msSinceLastCompile))
       queueReset()
       queueReset()
+    })
+  }
+  function waitForCompile(triggerCompile: () => void) {
+    waitForCompileRateLimitCoolOff()
+    cy.then(() => {
+      let compilingVisible: () => void
+      const waitForCompilingVisible = new Promise<void>(resolve => {
+        compilingVisible = resolve
+      })
+      cy.intercept(
+        {
+          method: 'POST',
+          pathname: /\/project\/[a-fA-F0-9]{24}\/compile$/,
+          times: 1,
+        },
+        async req => {
+          await waitForCompilingVisible
+          req.continue()
+        }
+      ).as('recompile')
       triggerCompile()
       triggerCompile()
       cy.log('Wait for compile to finish')
       cy.log('Wait for compile to finish')
+      cy.findByRole('button', { name: 'Compiling…' }).then(() =>
+        compilingVisible()
+      )
+      cy.wait('@recompile')
+      cy.findByRole('button', { name: 'Compiling…' }).should('not.exist')
       cy.findByRole('button', { name: 'Recompile' }).should('be.visible')
       cy.findByRole('button', { name: 'Recompile' }).should('be.visible')
     })
     })
   }
   }
   function recompile() {
   function recompile() {
-    waitForCompileRateLimitCoolOff(() => {
-      cy.findByText('Recompile').click()
+    waitForCompile(() => {
+      cy.findByRole('button', { name: 'Recompile' }).click()
     })
     })
   }
   }
   return {
   return {
     queueReset,
     queueReset,
     waitForCompileRateLimitCoolOff,
     waitForCompileRateLimitCoolOff,
+    waitForCompile,
     recompile,
     recompile,
   }
   }
 }
 }

+ 5 - 3
server-ce/test/history.spec.ts

@@ -1,5 +1,5 @@
 import { createProject } from './helpers/project'
 import { createProject } from './helpers/project'
-import { throttledRecompile } from './helpers/compile'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
 import { ensureUserExists, login } from './helpers/login'
 import { ensureUserExists, login } from './helpers/login'
 import { isExcludedBySharding, startWith } from './helpers/config'
 import { isExcludedBySharding, startWith } from './helpers/config'
 
 
@@ -62,8 +62,10 @@ describe('History', function () {
   const CLASS_DELETION = 'ol-cm-deletion-marker'
   const CLASS_DELETION = 'ol-cm-deletion-marker'
 
 
   it('should support labels, comparison and download', () => {
   it('should support labels, comparison and download', () => {
-    createProject('labels')
-    const recompile = throttledRecompile()
+    const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+    waitForCompile(() => {
+      createProject('labels')
+    })
 
 
     cy.log('add content, including a line that will get removed soon')
     cy.log('add content, including a line that will get removed soon')
     cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
     cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {

+ 32 - 11
server-ce/test/project-sharing.spec.ts

@@ -16,7 +16,7 @@ import {
   shareProjectByEmailAndAcceptInviteViaDash,
   shareProjectByEmailAndAcceptInviteViaDash,
   shareProjectByEmailAndAcceptInviteViaEmail,
   shareProjectByEmailAndAcceptInviteViaEmail,
 } from './helpers/project'
 } from './helpers/project'
-import { throttledRecompile } from './helpers/compile'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
 import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
 import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
 
 
 describe('Project Sharing', function () {
 describe('Project Sharing', function () {
@@ -25,8 +25,11 @@ describe('Project Sharing', function () {
   startWith({ withDataDir: true, pro: true })
   startWith({ withDataDir: true, pro: true })
 
 
   let projectName: string
   let projectName: string
+  let recompile: () => void
+  let waitForCompile: (triggerCompile: () => void) => void
   beforeWithReRunOnTestRetry(function () {
   beforeWithReRunOnTestRetry(function () {
     projectName = getSpamSafeProjectName()
     projectName = getSpamSafeProjectName()
+    ;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
     setupTestProject()
     setupTestProject()
   })
   })
 
 
@@ -40,7 +43,9 @@ describe('Project Sharing', function () {
 
 
   function setupTestProject() {
   function setupTestProject() {
     login('user@example.com')
     login('user@example.com')
-    createProject(projectName)
+    waitForCompile(() => {
+      createProject(projectName)
+    })
 
 
     // Add chat message
     // Add chat message
     cy.findByRole('button', { name: 'Chat' }).click()
     cy.findByRole('button', { name: 'Chat' }).click()
@@ -75,7 +80,6 @@ describe('Project Sharing', function () {
   function expectContentWriteAccess() {
   function expectContentWriteAccess() {
     const section = `Test Section ${uuid()}`
     const section = `Test Section ${uuid()}`
     cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
     cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
-    const recompile = throttledRecompile()
     // wait for the editor to finish loading
     // wait for the editor to finish loading
     cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
     cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
       'contain.text',
       'contain.text',
@@ -87,8 +91,10 @@ describe('Project Sharing', function () {
       'contenteditable',
       'contenteditable',
       'true'
       'true'
     )
     )
-    cy.findByText('\\maketitle').parent().click()
-    cy.findByText('\\maketitle').parent().type(`\n\\section{{}${section}}`)
+    cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
+      cy.findByText('\\maketitle').parent().click()
+      cy.findByText('\\maketitle').parent().type(`\n\\section{{}${section}}`)
+    })
     // should have written
     // should have written
     cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
     cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
       'contain.text',
       'contain.text',
@@ -128,13 +134,28 @@ describe('Project Sharing', function () {
   }
   }
 
 
   function expectHistoryAccess() {
   function expectHistoryAccess() {
-    cy.findByText('History').click()
-    cy.findByText('Labels')
+    cy.findByRole('button', { name: 'History' }).click()
+    // The input is not clickable due to being visually hidden, click its label instead
+    cy.findByRole('complementary', {
+      name: 'Project history and labels',
+    }).within(() => {
+      cy.findByRole('group', {
+        name: 'Show all of the project history or only labelled versions.',
+      }).within(() => {
+        cy.findByText('All history').click()
+      })
+      cy.findByRole('radio', { name: 'Labels' }).should('not.be.checked')
+      cy.findByRole('radio', { name: 'All history' }).should('be.checked')
+    })
     cy.findByText(/\\begin\{document}/)
     cy.findByText(/\\begin\{document}/)
-    cy.findAllByTestId('history-version-metadata-users')
-      .last()
-      .should('have.text', 'user')
-    cy.findByText('Back to editor').click()
+    cy.findByRole('complementary', {
+      name: 'Project history and labels',
+    }).within(() => {
+      cy.findAllByTestId('history-version-metadata-users')
+        .last()
+        .should('have.text', 'user')
+    })
+    cy.findByRole('button', { name: 'Back to editor' }).click()
   }
   }
 
 
   function expectNoChatAccess() {
   function expectNoChatAccess() {

+ 24 - 13
server-ce/test/sandboxed-compiles.spec.ts

@@ -1,10 +1,9 @@
 import { ensureUserExists, login } from './helpers/login'
 import { ensureUserExists, login } from './helpers/login'
 import { createProject } from './helpers/project'
 import { createProject } from './helpers/project'
 import { isExcludedBySharding, startWith } from './helpers/config'
 import { isExcludedBySharding, startWith } from './helpers/config'
-import { throttledRecompile, stopCompile } from './helpers/compile'
+import { prepareWaitForNextCompileSlot, stopCompile } from './helpers/compile'
 import { v4 as uuid } from 'uuid'
 import { v4 as uuid } from 'uuid'
 import { waitUntilScrollingFinished } from './helpers/waitUntilScrollingFinished'
 import { waitUntilScrollingFinished } from './helpers/waitUntilScrollingFinished'
-import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
 
 
 const LABEL_TEX_LIVE_VERSION = 'TeX Live version'
 const LABEL_TEX_LIVE_VERSION = 'TeX Live version'
 
 
@@ -27,8 +26,10 @@ describe('SandboxedCompiles', function () {
     })
     })
 
 
     it('should offer TexLive images and switch the compiler', function () {
     it('should offer TexLive images and switch the compiler', function () {
-      createProject('sandboxed')
-      const recompile = throttledRecompile()
+      const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+      waitForCompile(() => {
+        createProject('sandboxed')
+      })
       cy.log('wait for compile')
       cy.log('wait for compile')
       cy.findByRole('region', { name: 'PDF preview and logs' }).should(
       cy.findByRole('region', { name: 'PDF preview and logs' }).should(
         'contain.text',
         'contain.text',
@@ -53,7 +54,6 @@ describe('SandboxedCompiles', function () {
       })
       })
       cy.get('body').type('{esc}')
       cy.get('body').type('{esc}')
       cy.findByRole('dialog').should('not.exist')
       cy.findByRole('dialog').should('not.exist')
-
       cy.log('Trigger compile with other TeX Live version')
       cy.log('Trigger compile with other TeX Live version')
       recompile()
       recompile()
 
 
@@ -71,13 +71,18 @@ describe('SandboxedCompiles', function () {
   function checkStopCompile() {
   function checkStopCompile() {
     it('users can stop a running compile', function () {
     it('users can stop a running compile', function () {
       login('user@example.com')
       login('user@example.com')
-      createProject('test-project')
+      const { recompile, waitForCompile, waitForCompileRateLimitCoolOff } =
+        prepareWaitForNextCompileSlot()
+      waitForCompile(() => {
+        createProject('test-project')
+      })
       // create an infinite loop in the main document
       // create an infinite loop in the main document
       // this will cause the compile to run indefinitely
       // this will cause the compile to run indefinitely
       cy.findByText('\\maketitle').parent().click()
       cy.findByText('\\maketitle').parent().click()
       cy.findByText('\\maketitle')
       cy.findByText('\\maketitle')
         .parent()
         .parent()
         .type('\n\\def\\x{{}Hello!\\par\\x}\\x')
         .type('\n\\def\\x{{}Hello!\\par\\x}\\x')
+      waitForCompileRateLimitCoolOff()
       cy.log('Start compile')
       cy.log('Start compile')
       // We need to start the compile manually because we do not want to wait for it to finish
       // We need to start the compile manually because we do not want to wait for it to finish
       cy.findByText('Recompile').click()
       cy.findByText('Recompile').click()
@@ -90,7 +95,7 @@ describe('SandboxedCompiles', function () {
       // disabling the infinite loop and recompiling
       // disabling the infinite loop and recompiling
       cy.findByText('\\def').parent().click()
       cy.findByText('\\def').parent().click()
       cy.findByText('\\def').parent().type('{home}disabled loop% ')
       cy.findByText('\\def').parent().type('{home}disabled loop% ')
-      cy.findByText('Recompile').click()
+      recompile()
       cy.get('.pdf-viewer').should('contain.text', 'disabled loop')
       cy.get('.pdf-viewer').should('contain.text', 'disabled loop')
       cy.get('.logs-pane').should(
       cy.get('.logs-pane').should(
         'not.contain.text',
         'not.contain.text',
@@ -104,8 +109,10 @@ describe('SandboxedCompiles', function () {
       let projectName: string
       let projectName: string
       beforeEach(function () {
       beforeEach(function () {
         projectName = `Project ${uuid()}`
         projectName = `Project ${uuid()}`
-        createProject(projectName)
-        const recompile = throttledRecompile()
+        const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+        waitForCompile(() => {
+          createProject(projectName)
+        })
         cy.findByRole('textbox', { name: 'Source Editor editing' }).within(
         cy.findByRole('textbox', { name: 'Source Editor editing' }).within(
           () => {
           () => {
             cy.findByText('\\maketitle').parent().click()
             cy.findByText('\\maketitle').parent().click()
@@ -205,8 +212,10 @@ describe('SandboxedCompiles', function () {
   function checkRecompilesAfterErrors() {
   function checkRecompilesAfterErrors() {
     it('recompiles even if there are Latex errors', function () {
     it('recompiles even if there are Latex errors', function () {
       login('user@example.com')
       login('user@example.com')
-      createProject('test-project')
-      const recompile = throttledRecompile()
+      const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+      waitForCompile(() => {
+        createProject('test-project')
+      })
       cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
       cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
         cy.findByText('\\maketitle').parent().click()
         cy.findByText('\\maketitle').parent().click()
         cy.findByText('\\maketitle')
         cy.findByText('\\maketitle')
@@ -224,8 +233,10 @@ describe('SandboxedCompiles', function () {
 
 
   function checkXeTeX() {
   function checkXeTeX() {
     it('should be able to use XeLaTeX', function () {
     it('should be able to use XeLaTeX', function () {
-      createProject('XeLaTeX')
-      const recompile = throttledRecompile()
+      const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
+      waitForCompile(() => {
+        createProject('XeLaTeX')
+      })
       cy.log('wait for compile')
       cy.log('wait for compile')
       cy.findByRole('region', { name: 'PDF preview and logs' }).should(
       cy.findByRole('region', { name: 'PDF preview and logs' }).should(
         'contain.text',
         'contain.text',

+ 16 - 9
server-ce/test/upgrading.spec.ts

@@ -2,7 +2,7 @@ import { ensureUserExists, login } from './helpers/login'
 import { isExcludedBySharding, startWith } from './helpers/config'
 import { isExcludedBySharding, startWith } from './helpers/config'
 import { dockerCompose, runScript } from './helpers/hostAdminClient'
 import { dockerCompose, runScript } from './helpers/hostAdminClient'
 import { createProject, openProjectByName } from './helpers/project'
 import { createProject, openProjectByName } from './helpers/project'
-import { throttledRecompile } from './helpers/compile'
+import { prepareWaitForNextCompileSlot } from './helpers/compile'
 import { v4 as uuid } from 'uuid'
 import { v4 as uuid } from 'uuid'
 
 
 const USER = 'user@example.com'
 const USER = 'user@example.com'
@@ -11,6 +11,9 @@ const PROJECT_NAME = 'Old Project'
 describe('Upgrading', function () {
 describe('Upgrading', function () {
   if (isExcludedBySharding('PRO_CUSTOM_3')) return
   if (isExcludedBySharding('PRO_CUSTOM_3')) return
 
 
+  let recompile: () => void
+  let waitForCompile: (triggerCompile: () => void) => void
+
   function testUpgrade(
   function testUpgrade(
     steps: {
     steps: {
       version: string
       version: string
@@ -38,10 +41,13 @@ describe('Upgrading', function () {
     before(() => {
     before(() => {
       cy.log('Populate old instance')
       cy.log('Populate old instance')
       login(USER)
       login(USER)
-      createProject(PROJECT_NAME, {
-        newProjectButtonMatcher: startOptions.newProjectButtonMatcher,
+      ;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
+      waitForCompile(() => {
+        createProject(PROJECT_NAME, {
+          newProjectButtonMatcher: startOptions.newProjectButtonMatcher,
+        })
       })
       })
-      const recompile = throttledRecompile()
+
       cy.log('Wait for successful compile')
       cy.log('Wait for successful compile')
       cy.get('.pdf-viewer').should('contain.text', PROJECT_NAME)
       cy.get('.pdf-viewer').should('contain.text', PROJECT_NAME)
 
 
@@ -117,7 +123,9 @@ describe('Upgrading', function () {
     })
     })
 
 
     it('should open the old project', () => {
     it('should open the old project', () => {
-      openProjectByName(PROJECT_NAME)
+      waitForCompile(() => {
+        openProjectByName(PROJECT_NAME)
+      })
 
 
       cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
       cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
       cy.findByRole('navigation', {
       cy.findByRole('navigation', {
@@ -125,7 +133,6 @@ describe('Upgrading', function () {
       }).within(() => {
       }).within(() => {
         cy.findByText(PROJECT_NAME)
         cy.findByText(PROJECT_NAME)
       })
       })
-      const recompile = throttledRecompile()
 
 
       cy.log('wait for successful compile')
       cy.log('wait for successful compile')
       cy.get('.pdf-viewer').should('contain.text', PROJECT_NAME)
       cy.get('.pdf-viewer').should('contain.text', PROJECT_NAME)
@@ -189,9 +196,9 @@ describe('Upgrading', function () {
         hook() {
         hook() {
           before(function () {
           before(function () {
             login(USER)
             login(USER)
-            cy.visit('/')
-            cy.findByText(PROJECT_NAME).click()
-            const recompile = throttledRecompile()
+            waitForCompile(() => {
+              openProjectByName(PROJECT_NAME)
+            })
 
 
             cy.log('Make a change')
             cy.log('Make a change')
             cy.findByText('\\maketitle').parent().click()
             cy.findByText('\\maketitle').parent().click()