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

[web] Promisify ProjectRootDocManager (#30327)

* Remove ESLint disable

* Unnest ProjectRootDocManager methods

* Promisify `setRootDocAutomatically`

* Promisify `findRootDocFileFromDirectory`

* Promisify `setRootDocFromName`

* Promisify `ensureRootDocumentIsSet`

* Promisify `ensureRootDocumentIsValid`

* Promisify `_sortFileList`

* Fixup export

* Call `ProjectRootDocManager.promises.setRootDocAutomatically` so mocks work

* Update ProjectRootDocManager.test.mjs

* Remove expects on callbacks

* Add `return` on the first matching entry in `setRootDocAutomatically`

See https://github.com/overleaf/internal/pull/30327#discussion_r2624011209

Co-authored-by: Brian Gough <brian.gough@overleaf.com>

---------

Co-authored-by: Brian Gough <brian.gough@overleaf.com>
GitOrigin-RevId: f83e8239f65776d23b49cb7a569181abc7c9a276
Antoine Clausse 9 месяцев назад
Родитель
Сommit
16b83b128b

+ 181 - 293
services/web/app/src/Features/Project/ProjectRootDocManager.mjs

@@ -1,9 +1,3 @@
-/* eslint-disable
-    n/handle-callback-err,
-    max-len,
-    no-unused-vars,
-    no-useless-escape,
-*/
 // TODO: This file was created by bulk-decaffeinate.
 // Fix any style issues and re-enable lint.
 /*
@@ -18,321 +12,215 @@ import ProjectGetter from './ProjectGetter.mjs'
 import DocumentHelper from '../Documents/DocumentHelper.mjs'
 import Path from 'node:path'
 import fs from 'node:fs'
-import async from 'async'
+import pLimit from 'p-limit'
 import globby from 'globby'
-import _ from 'lodash'
-import { promisifyAll } from '@overleaf/promise-utils'
+import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
 import logger from '@overleaf/logger'
 import { BackgroundTaskTracker } from '../../infrastructure/GracefulShutdown.mjs'
 
 const rootDocResets = new BackgroundTaskTracker('root doc resets')
-const ProjectRootDocManager = {
-  setRootDocAutomaticallyInBackground(projectId) {
-    rootDocResets.add()
-    setTimeout(async () => {
-      try {
-        await ProjectRootDocManager.promises.setRootDocAutomatically(projectId)
-      } catch (err) {
-        logger.warn(
-          { err },
-          'failed to set root doc automatically in background'
-        )
-      } finally {
-        rootDocResets.done()
-      }
-    }, 30 * 1000)
-  },
 
-  setRootDocAutomatically(projectId, callback) {
-    if (callback == null) {
-      callback = function () {}
+function setRootDocAutomaticallyInBackground(projectId) {
+  rootDocResets.add()
+  setTimeout(async () => {
+    try {
+      await ProjectRootDocManager.promises.setRootDocAutomatically(projectId)
+    } catch (err) {
+      logger.warn({ err }, 'failed to set root doc automatically in background')
+    } finally {
+      rootDocResets.done()
     }
-    return ProjectEntityHandler.getAllDocs(projectId, function (error, docs) {
-      if (error != null) {
-        return callback(error)
-      }
+  }, 30 * 1000)
+}
 
-      const jobs = _.map(
-        docs,
-        (doc, path) =>
-          function (cb) {
-            if (
-              ProjectEntityUpdateHandler.isPathValidForRootDoc(path) &&
-              DocumentHelper.contentHasDocumentclass(doc.lines)
-            ) {
-              async.setImmediate(function () {
-                cb(doc._id)
-              })
-            } else {
-              async.setImmediate(function () {
-                cb(null)
-              })
-            }
-          }
+async function setRootDocAutomatically(projectId) {
+  const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
+
+  for (const [path, doc] of Object.entries(docs)) {
+    if (
+      ProjectEntityUpdateHandler.isPathValidForRootDoc(path) &&
+      DocumentHelper.contentHasDocumentclass(doc.lines) &&
+      doc._id
+    ) {
+      return await ProjectEntityUpdateHandler.promises.setRootDoc(
+        projectId,
+        doc._id
       )
-
-      return async.series(jobs, function (rootDocId) {
-        if (rootDocId != null) {
-          return ProjectEntityUpdateHandler.setRootDoc(
-            projectId,
-            rootDocId,
-            callback
-          )
-        } else {
-          return callback()
-        }
-      })
-    })
-  },
-
-  findRootDocFileFromDirectory(directoryPath, callback) {
-    if (callback == null) {
-      callback = function () {}
     }
-    const filePathsPromise = globby(['**/*.{tex,Rtex,Rnw}'], {
-      cwd: directoryPath,
-      followSymlinkedDirectories: false,
-      onlyFiles: true,
-      case: false,
-    })
-
-    // the search order is such that we prefer files closer to the project root, then
-    // we go by file size in ascending order, because people often have a main
-    // file that just includes a bunch of other files; then we go by name, in
-    // order to be deterministic
-    filePathsPromise.then(
-      unsortedFiles =>
-        ProjectRootDocManager._sortFileList(
-          unsortedFiles,
-          directoryPath,
-          function (err, files) {
-            if (err != null) {
-              return callback(err)
-            }
-            let firstFileInRootFolder
-            let doc = null
-
-            return async.until(
-              cb => cb(null, doc != null || files.length === 0),
-              function (cb) {
-                const file = files.shift()
-                return fs.readFile(
-                  Path.join(directoryPath, file),
-                  'utf8',
-                  function (error, content) {
-                    if (error != null) {
-                      return cb(error)
-                    }
-                    content = (content || '').replace(/\r/g, '')
-                    if (DocumentHelper.contentHasDocumentclass(content)) {
-                      doc = { path: file, content }
-                    }
+  }
+}
 
-                    if (!firstFileInRootFolder && !file.includes('/')) {
-                      firstFileInRootFolder = { path: file, content }
-                    }
-                    cb(null)
-                  }
-                )
-              },
-              err => {
-                if (err) {
-                  return callback(err)
-                }
+async function findRootDocFileFromDirectory(directoryPath) {
+  const unsortedFiles = await globby(['**/*.{tex,Rtex,Rnw}'], {
+    cwd: directoryPath,
+    followSymlinkedDirectories: false,
+    onlyFiles: true,
+    case: false,
+  })
+
+  // the search order is such that we prefer files closer to the project root, then
+  // we go by file size in ascending order, because people often have a main
+  // file that just includes a bunch of other files; then we go by name, in
+  // order to be deterministic
+
+  const files = await _sortFileList(unsortedFiles, directoryPath)
+  let firstFileInRootFolder
+  let doc = null
+
+  while (files.length > 0 && doc == null) {
+    const file = files.shift()
+    const content = await fs.promises.readFile(
+      Path.join(directoryPath, file),
+      'utf8'
+    )
+    const normalizedContent = (content || '').replace(/\r/g, '')
+    if (DocumentHelper.contentHasDocumentclass(normalizedContent)) {
+      doc = { path: file, content: normalizedContent }
+    }
 
-                // if no doc was found, use the first file in the root folder as the main doc
-                if (!doc && firstFileInRootFolder) {
-                  doc = firstFileInRootFolder
-                }
+    if (!firstFileInRootFolder && !file.includes('/')) {
+      firstFileInRootFolder = { path: file, content: normalizedContent }
+    }
+  }
 
-                callback(null, doc?.path, doc?.content)
-              }
-            )
-          }
-        ),
-      err => callback(err)
-    )
+  // if no doc was found, use the first file in the root folder as the main doc
+  if (!doc && firstFileInRootFolder) {
+    doc = firstFileInRootFolder
+  }
 
-    // coffeescript's implicit-return mechanism returns filePathsPromise from this method, which confuses mocha
-    return null
-  },
+  return { path: doc?.path, content: doc?.content }
+}
 
-  setRootDocFromName(projectId, rootDocName, callback) {
-    if (callback == null) {
-      callback = function () {}
+async function setRootDocFromName(projectId, rootDocName) {
+  const docPaths =
+    await ProjectEntityHandler.promises.getAllDocPathsFromProjectById(projectId)
+
+  let docId, path
+  // strip off leading and trailing quotes from rootDocName
+  rootDocName = rootDocName.replace(/^'|'$/g, '')
+  // prepend a slash for the root folder if not present
+  if (rootDocName[0] !== '/') {
+    rootDocName = `/${rootDocName}`
+  }
+  // find the root doc from the filename
+  let rootDocId = null
+  for (docId in docPaths) {
+    // docpaths have a leading / so allow matching "folder/filename" and "/folder/filename"
+    path = docPaths[docId]
+    if (path === rootDocName) {
+      rootDocId = docId
     }
-    return ProjectEntityHandler.getAllDocPathsFromProjectById(
-      projectId,
-      function (error, docPaths) {
-        let docId, path
-        if (error != null) {
-          return callback(error)
-        }
-        // strip off leading and trailing quotes from rootDocName
-        rootDocName = rootDocName.replace(/^\'|\'$/g, '')
-        // prepend a slash for the root folder if not present
-        if (rootDocName[0] !== '/') {
-          rootDocName = `/${rootDocName}`
-        }
-        // find the root doc from the filename
-        let rootDocId = null
-        for (docId in docPaths) {
-          // docpaths have a leading / so allow matching "folder/filename" and "/folder/filename"
-          path = docPaths[docId]
-          if (path === rootDocName) {
-            rootDocId = docId
-          }
-        }
-        // try a basename match if there was no match
-        if (!rootDocId) {
-          for (docId in docPaths) {
-            path = docPaths[docId]
-            if (Path.basename(path) === Path.basename(rootDocName)) {
-              rootDocId = docId
-            }
-          }
-        }
-        // set the root doc id if we found a match
-        if (rootDocId != null) {
-          return ProjectEntityUpdateHandler.setRootDoc(
-            projectId,
-            rootDocId,
-            callback
-          )
-        } else {
-          return callback()
-        }
+  }
+  // try a basename match if there was no match
+  if (!rootDocId) {
+    for (docId in docPaths) {
+      path = docPaths[docId]
+      if (Path.basename(path) === Path.basename(rootDocName)) {
+        rootDocId = docId
       }
-    )
-  },
-
-  ensureRootDocumentIsSet(projectId, callback) {
-    if (callback == null) {
-      callback = function () {}
     }
-    return ProjectGetter.getProject(
+  }
+  // set the root doc id if we found a match
+  if (rootDocId != null) {
+    return await ProjectEntityUpdateHandler.promises.setRootDoc(
       projectId,
-      { rootDoc_id: 1 },
-      function (error, project) {
-        if (error != null) {
-          return callback(error)
-        }
-        if (project == null) {
-          return callback(new Error('project not found'))
-        }
-
-        if (project.rootDoc_id != null) {
-          return callback()
-        } else {
-          return ProjectRootDocManager.setRootDocAutomatically(
-            projectId,
-            callback
-          )
-        }
-      }
+      rootDocId
     )
-  },
-
-  /**
-   * @param {ObjectId | string} project_id
-   * @param {Function} callback
-   */
-  ensureRootDocumentIsValid(projectId, callback) {
-    ProjectGetter.getProjectWithoutDocLines(
-      projectId,
-      function (error, project) {
-        if (error != null) {
-          return callback(error)
-        }
-        if (project == null) {
-          return callback(new Error('project not found'))
-        }
+  }
+}
 
-        if (project.rootDoc_id != null) {
-          ProjectEntityHandler.getDocPathFromProjectByDocId(
-            project,
-            project.rootDoc_id,
-            (err, docPath) => {
-              if (docPath) return callback()
-              ProjectEntityUpdateHandler.unsetRootDoc(projectId, () =>
-                ProjectRootDocManager.setRootDocAutomatically(
-                  projectId,
-                  callback
-                )
-              )
-            }
-          )
-        } else {
-          return ProjectRootDocManager.setRootDocAutomatically(
-            projectId,
-            callback
-          )
-        }
-      }
-    )
-  },
+async function ensureRootDocumentIsSet(projectId) {
+  const project = await ProjectGetter.promises.getProject(projectId, {
+    rootDoc_id: 1,
+  })
+  if (!project) {
+    throw new Error('project not found')
+  }
+  if (project.rootDoc_id != null) {
+    return
+  }
+  return await ProjectRootDocManager.promises.setRootDocAutomatically(projectId)
+}
 
-  _sortFileList(listToSort, rootDirectory, callback) {
-    if (callback == null) {
-      callback = function () {}
+/**
+ * @param {ObjectId | string} projectId
+ */
+async function ensureRootDocumentIsValid(projectId) {
+  const project =
+    await ProjectGetter.promises.getProjectWithoutDocLines(projectId)
+  if (!project) {
+    throw new Error('project not found')
+  }
+  if (project.rootDoc_id != null) {
+    const docPath =
+      await ProjectEntityHandler.promises.getDocPathFromProjectByDocId(
+        project,
+        project.rootDoc_id
+      )
+    if (docPath) {
+      return
     }
-    return async.mapLimit(
-      listToSort,
-      5,
-      (filePath, cb) =>
-        fs.stat(Path.join(rootDirectory, filePath), function (err, stat) {
-          if (err != null) {
-            return cb(err)
-          }
-          return cb(null, {
-            size: stat.size,
-            path: filePath,
-            elements: filePath.split(Path.sep).length,
-            name: Path.basename(filePath),
-          })
-        }),
-      function (err, files) {
-        if (err != null) {
-          return callback(err)
-        }
+    await ProjectEntityUpdateHandler.promises.unsetRootDoc(projectId)
+  }
+  return await ProjectRootDocManager.promises.setRootDocAutomatically(projectId)
+}
 
-        return callback(
-          null,
-          _.map(
-            files.sort(ProjectRootDocManager._rootDocSort),
-            file => file.path
-          )
-        )
-      }
+async function _sortFileList(listToSort, rootDirectory) {
+  const limit = pLimit(5)
+  const files = await Promise.all(
+    listToSort.map(filePath =>
+      limit(async () => {
+        const fullPath = Path.join(rootDirectory, filePath)
+        const stat = await fs.promises.stat(fullPath)
+        return {
+          size: stat.size,
+          path: filePath,
+          elements: filePath.split(Path.sep).length,
+          name: Path.basename(filePath),
+        }
+      })
     )
-  },
+  )
+  return files.sort(_rootDocSort).map(file => file.path)
+}
 
-  _rootDocSort(a, b) {
-    // sort first by folder depth
-    if (a.elements !== b.elements) {
-      return a.elements - b.elements
-    }
-    // ensure main.tex is at the start of each folder
-    if (a.name === 'main.tex' && b.name !== 'main.tex') {
-      return -1
-    }
-    if (a.name !== 'main.tex' && b.name === 'main.tex') {
-      return 1
-    }
-    // prefer smaller files
-    if (a.size !== b.size) {
-      return a.size - b.size
-    }
-    // otherwise, use the full path name
-    return a.path.localeCompare(b.path)
-  },
+function _rootDocSort(a, b) {
+  // sort first by folder depth
+  if (a.elements !== b.elements) {
+    return a.elements - b.elements
+  }
+  // ensure main.tex is at the start of each folder
+  if (a.name === 'main.tex' && b.name !== 'main.tex') {
+    return -1
+  }
+  if (a.name !== 'main.tex' && b.name === 'main.tex') {
+    return 1
+  }
+  // prefer smaller files
+  if (a.size !== b.size) {
+    return a.size - b.size
+  }
+  // otherwise, use the full path name
+  return a.path.localeCompare(b.path)
 }
 
-ProjectRootDocManager.promises = promisifyAll(ProjectRootDocManager, {
-  without: ['_rootDocSort', 'setRootDocAutomaticallyInBackground'],
-  multiResult: {
-    findRootDocFileFromDirectory: ['path', 'content'],
+const ProjectRootDocManager = {
+  setRootDocAutomaticallyInBackground,
+  setRootDocAutomatically: callbackify(setRootDocAutomatically),
+  findRootDocFileFromDirectory: callbackifyMultiResult(
+    findRootDocFileFromDirectory,
+    ['path', 'content']
+  ),
+  setRootDocFromName: callbackify(setRootDocFromName),
+  ensureRootDocumentIsSet: callbackify(ensureRootDocumentIsSet),
+  ensureRootDocumentIsValid: callbackify(ensureRootDocumentIsValid),
+  promises: {
+    setRootDocAutomatically,
+    findRootDocFileFromDirectory,
+    setRootDocFromName,
+    ensureRootDocumentIsSet,
+    ensureRootDocumentIsValid,
   },
-})
+}
 
 export default ProjectRootDocManager

+ 166 - 176
services/web/test/unit/src/Project/ProjectRootDocManager.test.mjs

@@ -19,31 +19,41 @@ describe('ProjectRootDocManager', function () {
     ctx.docPaths[ctx.docId3] = '/nested/chapter1a.tex'
     ctx.docPaths[ctx.docId4] = '/nested/chapter1b.tex'
     ctx.sl_req_id = 'sl-req-id-123'
-    ctx.callback = sinon.stub()
     ctx.globbyFiles = ['a.tex', 'b.tex', 'main.tex']
     ctx.globby = sinon.stub().resolves(ctx.globbyFiles)
 
     ctx.fs = {
-      readFile: sinon.stub().callsArgWith(2, new Error('file not found')),
-      stat: sinon.stub().callsArgWith(1, null, { size: 100 }),
+      promises: {
+        readFile: sinon.stub().rejects(new Error('file not found')),
+        stat: sinon.stub().resolves({ size: 100 }),
+      },
+    }
+    ctx.ProjectEntityHandler = {
+      promises: {},
+    }
+    ctx.ProjectEntityUpdateHandler = {
+      promises: {},
+    }
+    ctx.ProjectGetter = {
+      promises: {},
     }
 
     vi.doMock(
       '../../../../app/src/Features/Project/ProjectEntityHandler',
       () => ({
-        default: (ctx.ProjectEntityHandler = {}),
+        default: ctx.ProjectEntityHandler,
       })
     )
 
     vi.doMock(
       '../../../../app/src/Features/Project/ProjectEntityUpdateHandler',
       () => ({
-        default: (ctx.ProjectEntityUpdateHandler = {}),
+        default: ctx.ProjectEntityUpdateHandler,
       })
     )
 
     vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({
-      default: (ctx.ProjectGetter = {}),
+      default: ctx.ProjectGetter,
     }))
 
     vi.doMock('../../../../app/src/infrastructure/GracefulShutdown', () => ({
@@ -66,7 +76,9 @@ describe('ProjectRootDocManager', function () {
 
   describe('setRootDocAutomatically', function () {
     beforeEach(function (ctx) {
-      ctx.ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
+      ctx.ProjectEntityUpdateHandler.promises.setRootDoc = sinon
+        .stub()
+        .resolves()
       ctx.ProjectEntityUpdateHandler.isPathValidForRootDoc = sinon
         .stub()
         .returns(true)
@@ -100,22 +112,22 @@ describe('ProjectRootDocManager', function () {
             lines: ['Hello world'],
           },
         }
-        ctx.ProjectEntityHandler.getAllDocs = sinon
+        ctx.ProjectEntityHandler.promises.getAllDocs = sinon
           .stub()
-          .callsArgWith(1, null, ctx.docs)
+          .resolves(ctx.docs)
         await ctx.ProjectRootDocManager.promises.setRootDocAutomatically(
           ctx.project_id
         )
       })
 
       it('should check the docs of the project', function (ctx) {
-        ctx.ProjectEntityHandler.getAllDocs
+        ctx.ProjectEntityHandler.promises.getAllDocs
           .calledWith(ctx.project_id)
           .should.equal(true)
       })
 
       it('should set the root doc to the doc containing a documentclass', function (ctx) {
-        ctx.ProjectEntityUpdateHandler.setRootDoc
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc
           .calledWith(ctx.project_id, ctx.docId2)
           .should.equal(true)
       })
@@ -133,16 +145,16 @@ describe('ProjectRootDocManager', function () {
             lines: ['\\documentclass{article}', '\\input{chapter1}'],
           },
         }
-        ctx.ProjectEntityHandler.getAllDocs = sinon
+        ctx.ProjectEntityHandler.promises.getAllDocs = sinon
           .stub()
-          .callsArgWith(1, null, ctx.docs)
+          .resolves(ctx.docs)
         await ctx.ProjectRootDocManager.promises.setRootDocAutomatically(
           ctx.project_id
         )
       })
 
       it('should set the root doc to the doc containing a documentclass', function (ctx) {
-        ctx.ProjectEntityUpdateHandler.setRootDoc
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc
           .calledWith(ctx.project_id, ctx.docId2)
           .should.equal(true)
       })
@@ -160,37 +172,37 @@ describe('ProjectRootDocManager', function () {
             lines: ['%Example: \\documentclass{article}'],
           },
         }
-        ctx.ProjectEntityHandler.getAllDocs = sinon
+        ctx.ProjectEntityHandler.promises.getAllDocs = sinon
           .stub()
-          .callsArgWith(1, null, ctx.docs)
+          .resolves(ctx.docs)
         await ctx.ProjectRootDocManager.promises.setRootDocAutomatically(
           ctx.project_id
         )
       })
 
       it('should not set the root doc to the doc containing a documentclass', function (ctx) {
-        ctx.ProjectEntityUpdateHandler.setRootDoc.called.should.equal(false)
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc.called.should.equal(
+          false
+        )
       })
     })
   })
 
   describe('findRootDocFileFromDirectory', function () {
     beforeEach(function (ctx) {
-      ctx.fs.readFile
-        .withArgs('/foo/a.tex')
-        .callsArgWith(2, null, 'Hello World!')
-      ctx.fs.readFile
+      ctx.fs.promises.readFile.withArgs('/foo/a.tex').resolves('Hello World!')
+      ctx.fs.promises.readFile
         .withArgs('/foo/b.tex')
-        .callsArgWith(2, null, "I'm a little teapot, get me out of here.")
-      ctx.fs.readFile
+        .resolves("I'm a little teapot, get me out of here.")
+      ctx.fs.promises.readFile
         .withArgs('/foo/main.tex')
-        .callsArgWith(2, null, "Help, I'm trapped in a unit testing factory")
-      ctx.fs.readFile
+        .resolves("Help, I'm trapped in a unit testing factory")
+      ctx.fs.promises.readFile
         .withArgs('/foo/c.tex')
-        .callsArgWith(2, null, 'Tomato, tomahto.')
-      ctx.fs.readFile
+        .resolves('Tomato, tomahto.')
+      ctx.fs.promises.readFile
         .withArgs('/foo/a/a.tex')
-        .callsArgWith(2, null, 'Potato? Potahto. Potootee!')
+        .resolves('Potato? Potahto. Potootee!')
       ctx.documentclassContent = '% test\n\\documentclass\n% test'
     })
 
@@ -214,34 +226,34 @@ describe('ProjectRootDocManager', function () {
           )
         expect(path).to.equal('a.tex')
         sinon.assert.callOrder(
-          ctx.fs.readFile.withArgs('/foo/a.tex'),
-          ctx.fs.readFile.withArgs('/foo/b.tex'),
-          ctx.fs.readFile.withArgs('/foo/c.tex'),
-          ctx.fs.readFile.withArgs('/foo/a/a.tex')
+          ctx.fs.promises.readFile.withArgs('/foo/a.tex'),
+          ctx.fs.promises.readFile.withArgs('/foo/b.tex'),
+          ctx.fs.promises.readFile.withArgs('/foo/c.tex'),
+          ctx.fs.promises.readFile.withArgs('/foo/a/a.tex')
         )
       })
 
       it('processes smaller files first', async function (ctx) {
-        ctx.fs.stat.withArgs('/foo/c.tex').callsArgWith(1, null, { size: 1 })
+        ctx.fs.promises.stat.withArgs('/foo/c.tex').resolves({ size: 1 })
         const { path } =
           await ctx.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
             '/foo'
           )
         expect(path).to.equal('c.tex')
         sinon.assert.callOrder(
-          ctx.fs.readFile.withArgs('/foo/c.tex'),
-          ctx.fs.readFile.withArgs('/foo/a.tex'),
-          ctx.fs.readFile.withArgs('/foo/b.tex'),
-          ctx.fs.readFile.withArgs('/foo/a/a.tex')
+          ctx.fs.promises.readFile.withArgs('/foo/c.tex'),
+          ctx.fs.promises.readFile.withArgs('/foo/a.tex'),
+          ctx.fs.promises.readFile.withArgs('/foo/b.tex'),
+          ctx.fs.promises.readFile.withArgs('/foo/a/a.tex')
         )
       })
     })
 
     describe('when main.tex contains a documentclass', function () {
       beforeEach(function (ctx) {
-        ctx.fs.readFile
+        ctx.fs.promises.readFile
           .withArgs('/foo/main.tex')
-          .callsArgWith(2, null, ctx.documentclassContent)
+          .resolves(ctx.documentclassContent)
       })
 
       it('returns main.tex', async function (ctx) {
@@ -254,20 +266,22 @@ describe('ProjectRootDocManager', function () {
       })
 
       it('processes main.text first and stops processing when it finds the content', async function (ctx) {
-        await ctx.ProjectRootDocManager.findRootDocFileFromDirectory('/foo')
-        expect(ctx.fs.readFile).to.be.calledWith('/foo/main.tex')
-        expect(ctx.fs.readFile).not.to.be.calledWith('/foo/a.tex')
+        await ctx.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
+          '/foo'
+        )
+        expect(ctx.fs.promises.readFile).to.be.calledWith('/foo/main.tex')
+        expect(ctx.fs.promises.readFile).not.to.be.calledWith('/foo/a.tex')
       })
     })
 
     describe('when main.tex does not contain a line starting with \\documentclass', function () {
       beforeEach(function (ctx) {
-        ctx.fs.readFile.withArgs('/foo/a.tex').callsArgWith(2, null, 'foo')
-        ctx.fs.readFile.withArgs('/foo/main.tex').callsArgWith(2, null, 'foo')
-        ctx.fs.readFile.withArgs('/foo/z.tex').callsArgWith(2, null, 'foo')
-        ctx.fs.readFile
+        ctx.fs.promises.readFile.withArgs('/foo/a.tex').resolves('foo')
+        ctx.fs.promises.readFile.withArgs('/foo/main.tex').resolves('foo')
+        ctx.fs.promises.readFile.withArgs('/foo/z.tex').resolves('foo')
+        ctx.fs.promises.readFile
           .withArgs('/foo/nested/chapter1a.tex')
-          .callsArgWith(2, null, 'foo')
+          .resolves('foo')
       })
 
       it('returns the first .tex file from the root folder', async function (ctx) {
@@ -308,9 +322,9 @@ describe('ProjectRootDocManager', function () {
 
     describe('when a.tex contains a documentclass', function () {
       beforeEach(function (ctx) {
-        ctx.fs.readFile
+        ctx.fs.promises.readFile
           .withArgs('/foo/a.tex')
-          .callsArgWith(2, null, ctx.documentclassContent)
+          .resolves(ctx.documentclassContent)
       })
 
       it('returns a.tex', async function (ctx) {
@@ -326,9 +340,9 @@ describe('ProjectRootDocManager', function () {
         await ctx.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
           '/foo'
         )
-        expect(ctx.fs.readFile).to.be.calledWith('/foo/main.tex')
-        expect(ctx.fs.readFile).to.be.calledWith('/foo/a.tex')
-        expect(ctx.fs.readFile).not.to.be.calledWith('/foo/b.tex')
+        expect(ctx.fs.promises.readFile).to.be.calledWith('/foo/main.tex')
+        expect(ctx.fs.promises.readFile).to.be.calledWith('/foo/a.tex')
+        expect(ctx.fs.promises.readFile).not.to.be.calledWith('/foo/b.tex')
       })
     })
 
@@ -343,17 +357,17 @@ describe('ProjectRootDocManager', function () {
         await ctx.ProjectRootDocManager.promises.findRootDocFileFromDirectory(
           '/foo'
         )
-        expect(ctx.fs.readFile).to.be.calledWith('/foo/main.tex')
-        expect(ctx.fs.readFile).to.be.calledWith('/foo/a.tex')
-        expect(ctx.fs.readFile).to.be.calledWith('/foo/b.tex')
+        expect(ctx.fs.promises.readFile).to.be.calledWith('/foo/main.tex')
+        expect(ctx.fs.promises.readFile).to.be.calledWith('/foo/a.tex')
+        expect(ctx.fs.promises.readFile).to.be.calledWith('/foo/b.tex')
       })
     })
 
     describe('when there is an error reading a file', function () {
       beforeEach(function (ctx) {
-        ctx.fs.readFile
+        ctx.fs.promises.readFile
           .withArgs('/foo/a.tex')
-          .callsArgWith(2, new Error('something went wrong'))
+          .rejects(new Error('something went wrong'))
       })
 
       it('returns an error', async function (ctx) {
@@ -375,10 +389,12 @@ describe('ProjectRootDocManager', function () {
   describe('setRootDocFromName', function () {
     describe('when there is a suitable root doc', function () {
       beforeEach(async function (ctx) {
-        ctx.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
+        ctx.ProjectEntityHandler.promises.getAllDocPathsFromProjectById = sinon
           .stub()
-          .callsArgWith(1, null, ctx.docPaths)
-        ctx.ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
+          .resolves(ctx.docPaths)
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc = sinon
+          .stub()
+          .resolves()
         await ctx.ProjectRootDocManager.promises.setRootDocFromName(
           ctx.project_id,
           '/main.tex'
@@ -386,13 +402,13 @@ describe('ProjectRootDocManager', function () {
       })
 
       it('should check the docs of the project', function (ctx) {
-        ctx.ProjectEntityHandler.getAllDocPathsFromProjectById
+        ctx.ProjectEntityHandler.promises.getAllDocPathsFromProjectById
           .calledWith(ctx.project_id)
           .should.equal(true)
       })
 
       it('should set the root doc to main.tex', function (ctx) {
-        ctx.ProjectEntityUpdateHandler.setRootDoc
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc
           .calledWith(ctx.project_id, ctx.docId2.toString())
           .should.equal(true)
       })
@@ -400,10 +416,12 @@ describe('ProjectRootDocManager', function () {
 
     describe('when there is a suitable root doc but the leading slash is missing', function () {
       beforeEach(async function (ctx) {
-        ctx.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
+        ctx.ProjectEntityHandler.promises.getAllDocPathsFromProjectById = sinon
+          .stub()
+          .resolves(ctx.docPaths)
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc = sinon
           .stub()
-          .callsArgWith(1, null, ctx.docPaths)
-        ctx.ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
+          .resolves()
         await ctx.ProjectRootDocManager.promises.setRootDocFromName(
           ctx.project_id,
           'main.tex'
@@ -411,13 +429,13 @@ describe('ProjectRootDocManager', function () {
       })
 
       it('should check the docs of the project', function (ctx) {
-        ctx.ProjectEntityHandler.getAllDocPathsFromProjectById
+        ctx.ProjectEntityHandler.promises.getAllDocPathsFromProjectById
           .calledWith(ctx.project_id)
           .should.equal(true)
       })
 
       it('should set the root doc to main.tex', function (ctx) {
-        ctx.ProjectEntityUpdateHandler.setRootDoc
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc
           .calledWith(ctx.project_id, ctx.docId2.toString())
           .should.equal(true)
       })
@@ -425,10 +443,12 @@ describe('ProjectRootDocManager', function () {
 
     describe('when there is a suitable root doc with a basename match', function () {
       beforeEach(async function (ctx) {
-        ctx.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
+        ctx.ProjectEntityHandler.promises.getAllDocPathsFromProjectById = sinon
+          .stub()
+          .resolves(ctx.docPaths)
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc = sinon
           .stub()
-          .callsArgWith(1, null, ctx.docPaths)
-        ctx.ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
+          .resolves()
         await ctx.ProjectRootDocManager.promises.setRootDocFromName(
           ctx.project_id,
           'chapter1a.tex'
@@ -436,13 +456,13 @@ describe('ProjectRootDocManager', function () {
       })
 
       it('should check the docs of the project', function (ctx) {
-        ctx.ProjectEntityHandler.getAllDocPathsFromProjectById
+        ctx.ProjectEntityHandler.promises.getAllDocPathsFromProjectById
           .calledWith(ctx.project_id)
           .should.equal(true)
       })
 
       it('should set the root doc using the basename', function (ctx) {
-        ctx.ProjectEntityUpdateHandler.setRootDoc
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc
           .calledWith(ctx.project_id, ctx.docId3.toString())
           .should.equal(true)
       })
@@ -450,10 +470,12 @@ describe('ProjectRootDocManager', function () {
 
     describe('when there is a suitable root doc but the filename is in quotes', function () {
       beforeEach(async function (ctx) {
-        ctx.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
+        ctx.ProjectEntityHandler.promises.getAllDocPathsFromProjectById = sinon
           .stub()
-          .callsArgWith(1, null, ctx.docPaths)
-        ctx.ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
+          .resolves(ctx.docPaths)
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc = sinon
+          .stub()
+          .resolves()
         await ctx.ProjectRootDocManager.promises.setRootDocFromName(
           ctx.project_id,
           "'main.tex'"
@@ -461,13 +483,13 @@ describe('ProjectRootDocManager', function () {
       })
 
       it('should check the docs of the project', function (ctx) {
-        ctx.ProjectEntityHandler.getAllDocPathsFromProjectById
+        ctx.ProjectEntityHandler.promises.getAllDocPathsFromProjectById
           .calledWith(ctx.project_id)
           .should.equal(true)
       })
 
       it('should set the root doc to main.tex', function (ctx) {
-        ctx.ProjectEntityUpdateHandler.setRootDoc
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc
           .calledWith(ctx.project_id, ctx.docId2.toString())
           .should.equal(true)
       })
@@ -475,10 +497,12 @@ describe('ProjectRootDocManager', function () {
 
     describe('when there is no suitable root doc', function () {
       beforeEach(async function (ctx) {
-        ctx.ProjectEntityHandler.getAllDocPathsFromProjectById = sinon
+        ctx.ProjectEntityHandler.promises.getAllDocPathsFromProjectById = sinon
+          .stub()
+          .resolves(ctx.docPaths)
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc = sinon
           .stub()
-          .callsArgWith(1, null, ctx.docPaths)
-        ctx.ProjectEntityUpdateHandler.setRootDoc = sinon.stub().callsArgWith(2)
+          .resolves()
         await ctx.ProjectRootDocManager.promises.setRootDocFromName(
           ctx.project_id,
           'other.tex'
@@ -486,7 +510,9 @@ describe('ProjectRootDocManager', function () {
       })
 
       it('should not set the root doc', function (ctx) {
-        ctx.ProjectEntityUpdateHandler.setRootDoc.called.should.equal(false)
+        ctx.ProjectEntityUpdateHandler.promises.setRootDoc.called.should.equal(
+          false
+        )
       })
     })
   })
@@ -494,82 +520,65 @@ describe('ProjectRootDocManager', function () {
   describe('ensureRootDocumentIsSet', function () {
     beforeEach(function (ctx) {
       ctx.project = {}
-      ctx.ProjectGetter.getProject = sinon
-        .stub()
-        .callsArgWith(2, null, ctx.project)
-      ctx.ProjectRootDocManager.setRootDocAutomatically = sinon
+      ctx.ProjectGetter.promises.getProject = sinon.stub().resolves(ctx.project)
+      ctx.ProjectRootDocManager.promises.setRootDocAutomatically = sinon
         .stub()
-        .callsArgWith(1, null)
+        .resolves()
     })
 
     describe('when the root doc is set', function () {
-      beforeEach(function (ctx) {
+      beforeEach(async function (ctx) {
         ctx.project.rootDoc_id = ctx.docId2
-        ctx.ProjectRootDocManager.ensureRootDocumentIsSet(
-          ctx.project_id,
-          ctx.callback
+
+        await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet(
+          ctx.project_id
         )
       })
 
       it('should find the project fetching only the rootDoc_id field', function (ctx) {
-        ctx.ProjectGetter.getProject
+        ctx.ProjectGetter.promises.getProject
           .calledWith(ctx.project_id, { rootDoc_id: 1 })
           .should.equal(true)
       })
 
       it('should not try to update the project rootDoc_id', function (ctx) {
-        ctx.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
+        ctx.ProjectRootDocManager.promises.setRootDocAutomatically.called.should.equal(
           false
         )
       })
-
-      it('should call the callback', function (ctx) {
-        ctx.callback.called.should.equal(true)
-      })
     })
 
     describe('when the root doc is not set', function () {
-      beforeEach(function (ctx) {
-        ctx.ProjectRootDocManager.ensureRootDocumentIsSet(
-          ctx.project_id,
-          ctx.callback
+      beforeEach(async function (ctx) {
+        await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet(
+          ctx.project_id
         )
       })
 
       it('should find the project with only the rootDoc_id field', function (ctx) {
-        ctx.ProjectGetter.getProject
+        ctx.ProjectGetter.promises.getProject
           .calledWith(ctx.project_id, { rootDoc_id: 1 })
           .should.equal(true)
       })
 
       it('should update the project rootDoc_id', function (ctx) {
-        ctx.ProjectRootDocManager.setRootDocAutomatically
+        ctx.ProjectRootDocManager.promises.setRootDocAutomatically
           .calledWith(ctx.project_id)
           .should.equal(true)
       })
-
-      it('should call the callback', function (ctx) {
-        ctx.callback.called.should.equal(true)
-      })
     })
 
     describe('when the project does not exist', function () {
       beforeEach(function (ctx) {
-        ctx.ProjectGetter.getProject = sinon.stub().callsArgWith(2, null, null)
-        ctx.ProjectRootDocManager.ensureRootDocumentIsSet(
-          ctx.project_id,
-          ctx.callback
-        )
+        ctx.ProjectGetter.promises.getProject = sinon.stub().resolves(null)
       })
 
-      it('should call the callback with an error', function (ctx) {
-        ctx.callback
-          .calledWith(
-            sinon.match
-              .instanceOf(Error)
-              .and(sinon.match.has('message', 'project not found'))
+      it('should reject', async function (ctx) {
+        await expect(
+          ctx.ProjectRootDocManager.promises.ensureRootDocumentIsSet(
+            ctx.project_id
           )
-          .should.equal(true)
+        ).to.be.rejectedWith('project not found')
       })
     })
   })
@@ -577,129 +586,110 @@ describe('ProjectRootDocManager', function () {
   describe('ensureRootDocumentIsValid', function () {
     beforeEach(function (ctx) {
       ctx.project = {}
-      ctx.ProjectGetter.getProject = sinon
+      ctx.ProjectGetter.promises.getProject = sinon.stub().resolves(ctx.project)
+      ctx.ProjectGetter.promises.getProjectWithoutDocLines = sinon
+        .stub()
+        .resolves(ctx.project)
+      ctx.ProjectEntityUpdateHandler.promises.setRootDoc = sinon
         .stub()
-        .callsArgWith(2, null, ctx.project)
-      ctx.ProjectGetter.getProjectWithoutDocLines = sinon
+        .resolves()
+      ctx.ProjectEntityUpdateHandler.promises.unsetRootDoc = sinon
         .stub()
-        .callsArgWith(1, null, ctx.project)
-      ctx.ProjectEntityUpdateHandler.setRootDoc = sinon.stub().yields()
-      ctx.ProjectEntityUpdateHandler.unsetRootDoc = sinon.stub().yields()
-      ctx.ProjectRootDocManager.setRootDocAutomatically = sinon
+        .resolves()
+      ctx.ProjectRootDocManager.promises.setRootDocAutomatically = sinon
         .stub()
-        .callsArgWith(1, null)
+        .resolves(null)
     })
 
     describe('when the root doc is set', function () {
       describe('when the root doc is valid', function () {
-        beforeEach(function (ctx) {
+        beforeEach(async function (ctx) {
           ctx.project.rootDoc_id = ctx.docId2
-          ctx.ProjectEntityHandler.getDocPathFromProjectByDocId = sinon
+          ctx.ProjectEntityHandler.promises.getDocPathFromProjectByDocId = sinon
             .stub()
-            .callsArgWith(2, null, ctx.docPaths[ctx.docId2])
-          ctx.ProjectRootDocManager.ensureRootDocumentIsValid(
-            ctx.project_id,
-            ctx.callback
+            .resolves(ctx.docPaths[ctx.docId2])
+          await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid(
+            ctx.project_id
           )
         })
 
         it('should find the project without doc lines', function (ctx) {
-          ctx.ProjectGetter.getProjectWithoutDocLines
+          ctx.ProjectGetter.promises.getProjectWithoutDocLines
             .calledWith(ctx.project_id)
             .should.equal(true)
         })
 
         it('should not try to update the project rootDoc_id', function (ctx) {
-          ctx.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
+          ctx.ProjectRootDocManager.promises.setRootDocAutomatically.called.should.equal(
             false
           )
         })
-
-        it('should call the callback', function (ctx) {
-          ctx.callback.called.should.equal(true)
-        })
       })
 
       describe('when the root doc is not valid', function () {
-        beforeEach(function (ctx) {
+        beforeEach(async function (ctx) {
           ctx.project.rootDoc_id = new ObjectId()
-          ctx.ProjectEntityHandler.getDocPathFromProjectByDocId = sinon
+          ctx.ProjectEntityHandler.promises.getDocPathFromProjectByDocId = sinon
             .stub()
-            .callsArgWith(2, null, null)
-          ctx.ProjectRootDocManager.ensureRootDocumentIsValid(
-            ctx.project_id,
-            ctx.callback
+            .resolves(null)
+          await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid(
+            ctx.project_id
           )
         })
 
         it('should find the project without doc lines', function (ctx) {
-          ctx.ProjectGetter.getProjectWithoutDocLines
+          ctx.ProjectGetter.promises.getProjectWithoutDocLines
             .calledWith(ctx.project_id)
             .should.equal(true)
         })
 
         it('should unset the root doc', function (ctx) {
-          ctx.ProjectEntityUpdateHandler.unsetRootDoc
+          ctx.ProjectEntityUpdateHandler.promises.unsetRootDoc
             .calledWith(ctx.project_id)
             .should.equal(true)
         })
 
         it('should try to find a new rootDoc', function (ctx) {
-          ctx.ProjectRootDocManager.setRootDocAutomatically.called.should.equal(
+          ctx.ProjectRootDocManager.promises.setRootDocAutomatically.called.should.equal(
             true
           )
         })
-
-        it('should call the callback', function (ctx) {
-          ctx.callback.called.should.equal(true)
-        })
       })
     })
 
     describe('when the root doc is not set', function () {
-      beforeEach(function (ctx) {
-        ctx.ProjectRootDocManager.ensureRootDocumentIsValid(
-          ctx.project_id,
-          ctx.callback
+      beforeEach(async function (ctx) {
+        await ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid(
+          ctx.project_id
         )
       })
 
-      it('should find the project without doc lines', function (ctx) {
-        ctx.ProjectGetter.getProjectWithoutDocLines
+      it('should find the project without doc lines', async function (ctx) {
+        ctx.ProjectGetter.promises.getProjectWithoutDocLines
           .calledWith(ctx.project_id)
           .should.equal(true)
       })
 
       it('should update the project rootDoc_id', function (ctx) {
-        ctx.ProjectRootDocManager.setRootDocAutomatically
+        ctx.ProjectRootDocManager.promises.setRootDocAutomatically
           .calledWith(ctx.project_id)
           .should.equal(true)
       })
-
-      it('should call the callback', function (ctx) {
-        ctx.callback.called.should.equal(true)
-      })
     })
 
     describe('when the project does not exist', function () {
-      beforeEach(function (ctx) {
-        ctx.ProjectGetter.getProjectWithoutDocLines = sinon
+      beforeEach(async function (ctx) {
+        ctx.ProjectGetter.promises.getProjectWithoutDocLines = sinon
           .stub()
-          .callsArgWith(1, null, null)
-        ctx.ProjectRootDocManager.ensureRootDocumentIsValid(
-          ctx.project_id,
-          ctx.callback
-        )
+          .resolves(null)
       })
 
-      it('should call the callback with an error', function (ctx) {
-        ctx.callback
-          .calledWith(
-            sinon.match
-              .instanceOf(Error)
-              .and(sinon.match.has('message', 'project not found'))
+      it('should reject', async function (ctx) {
+        await expect(
+          ctx.ProjectRootDocManager.promises.ensureRootDocumentIsValid(
+            ctx.project_id
           )
-          .should.equal(true)
+        ).to.be.rejectedWith('project not found')
       })
     })
   })