[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
This commit is contained in:
Antoine Clausse
2026-01-08 09:05:12 +00:00
committed by Copybot
co-authored by Brian Gough
parent 3d352b35cb
commit 16b83b128b
2 changed files with 359 additions and 481 deletions
@@ -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()
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 (!firstFileInRootFolder && !file.includes('/')) {
firstFileInRootFolder = { 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
}
return { path: doc?.path, content: doc?.content }
}
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
}
}
// 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 await ProjectEntityUpdateHandler.promises.setRootDoc(
projectId,
rootDocId
)
}
}
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)
}
/**
* @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
}
await ProjectEntityUpdateHandler.promises.unsetRootDoc(projectId)
}
return await ProjectRootDocManager.promises.setRootDocAutomatically(projectId)
}
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),
}
})
})
},
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)
}
// if no doc was found, use the first file in the root folder as the main doc
if (!doc && firstFileInRootFolder) {
doc = firstFileInRootFolder
}
callback(null, doc?.path, doc?.content)
}
)
}
),
err => callback(err)
)
)
return files.sort(_rootDocSort).map(file => file.path)
}
// coffeescript's implicit-return mechanism returns filePathsPromise from this method, which confuses mocha
return null
},
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)
}
setRootDocFromName(projectId, rootDocName, callback) {
if (callback == null) {
callback = function () {}
}
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()
}
}
)
},
ensureRootDocumentIsSet(projectId, callback) {
if (callback == null) {
callback = function () {}
}
return ProjectGetter.getProject(
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
)
}
}
)
},
/**
* @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
)
}
}
)
},
_sortFileList(listToSort, rootDirectory, callback) {
if (callback == null) {
callback = function () {}
}
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)
}
return callback(
null,
_.map(
files.sort(ProjectRootDocManager._rootDocSort),
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)
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,
},
}
ProjectRootDocManager.promises = promisifyAll(ProjectRootDocManager, {
without: ['_rootDocSort', 'setRootDocAutomaticallyInBackground'],
multiResult: {
findRootDocFileFromDirectory: ['path', 'content'],
},
})
export default ProjectRootDocManager
@@ -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()
.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'
@@ -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()
.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,
'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()
.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,
'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
ctx.ProjectGetter.promises.getProject = sinon.stub().resolves(ctx.project)
ctx.ProjectRootDocManager.promises.setRootDocAutomatically = sinon
.stub()
.callsArgWith(2, null, ctx.project)
ctx.ProjectRootDocManager.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()
.callsArgWith(2, null, ctx.project)
ctx.ProjectGetter.getProjectWithoutDocLines = sinon
.resolves(ctx.project)
ctx.ProjectEntityUpdateHandler.promises.setRootDoc = 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.ProjectEntityUpdateHandler.promises.unsetRootDoc = sinon
.stub()
.callsArgWith(1, null)
.resolves()
ctx.ProjectRootDocManager.promises.setRootDocAutomatically = sinon
.stub()
.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')
})
})
})