first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,187 @@
const Client = require('./helpers/Client')
const ClsiApp = require('./helpers/ClsiApp')
const { expect } = require('chai')
describe('AllowedImageNames', function () {
beforeEach(function (done) {
this.project_id = Client.randomId()
this.request = {
options: {
imageName: undefined,
},
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\begin{document}
Hello world
\\end{document}\
`,
},
],
}
ClsiApp.ensureRunning(done)
})
describe('with a valid name', function () {
beforeEach(function (done) {
this.request.options.imageName = process.env.TEXLIVE_IMAGE
Client.compile(this.project_id, this.request, (error, res, body) => {
this.error = error
this.res = res
this.body = body
done(error)
})
})
it('should return success', function () {
expect(this.res.statusCode).to.equal(200)
})
it('should return a PDF', function () {
let pdf
try {
pdf = Client.getOutputFile(this.body, 'pdf')
} catch (e) {}
expect(pdf).to.exist
})
})
describe('with an invalid name', function () {
beforeEach(function (done) {
this.request.options.imageName = 'something/evil:1337'
Client.compile(this.project_id, this.request, (error, res, body) => {
this.error = error
this.res = res
this.body = body
done(error)
})
})
it('should return non success', function () {
expect(this.res.statusCode).to.not.equal(200)
})
it('should not return a PDF', function () {
let pdf
try {
pdf = Client.getOutputFile(this.body, 'pdf')
} catch (e) {}
expect(pdf).to.not.exist
})
})
describe('syncToCode', function () {
beforeEach(function (done) {
Client.compile(this.project_id, this.request, done)
})
it('should error out with an invalid imageName', function (done) {
Client.syncFromCodeWithImage(
this.project_id,
'main.tex',
3,
5,
'something/evil:1337',
(error, body) => {
expect(String(error)).to.include('statusCode=400')
expect(body).to.equal('invalid image')
done()
}
)
})
it('should produce a mapping a valid imageName', function (done) {
Client.syncFromCodeWithImage(
this.project_id,
'main.tex',
3,
5,
process.env.TEXLIVE_IMAGE,
(error, result) => {
expect(error).to.not.exist
expect(result).to.deep.equal({
pdf: [
{
page: 1,
h: 133.768356,
v: 134.764618,
height: 6.918498,
width: 343.71106,
},
],
})
done()
}
)
})
})
describe('syncToPdf', function () {
beforeEach(function (done) {
Client.compile(this.project_id, this.request, done)
})
it('should error out with an invalid imageName', function (done) {
Client.syncFromPdfWithImage(
this.project_id,
'main.tex',
100,
200,
'something/evil:1337',
(error, body) => {
expect(String(error)).to.include('statusCode=400')
expect(body).to.equal('invalid image')
done()
}
)
})
it('should produce a mapping a valid imageName', function (done) {
Client.syncFromPdfWithImage(
this.project_id,
1,
100,
200,
process.env.TEXLIVE_IMAGE,
(error, result) => {
expect(error).to.not.exist
expect(result).to.deep.equal({
code: [{ file: 'main.tex', line: 3, column: -1 }],
})
done()
}
)
})
})
describe('wordcount', function () {
beforeEach(function (done) {
Client.compile(this.project_id, this.request, done)
})
it('should error out with an invalid imageName', function (done) {
Client.wordcountWithImage(
this.project_id,
'main.tex',
'something/evil:1337',
(error, body) => {
expect(String(error)).to.include('statusCode=400')
expect(body).to.equal('invalid image')
done()
}
)
})
it('should produce a texcout a valid imageName', function (done) {
Client.wordcountWithImage(
this.project_id,
'main.tex',
process.env.TEXLIVE_IMAGE,
(error, result) => {
expect(error).to.not.exist
expect(result).to.exist
expect(result.texcount).to.exist
done()
}
)
})
})
})

View File

@@ -0,0 +1,124 @@
/* eslint-disable
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const Client = require('./helpers/Client')
const request = require('request')
const ClsiApp = require('./helpers/ClsiApp')
const { expect } = require('chai')
describe('Broken LaTeX file', function () {
before(function (done) {
this.broken_request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{articl % :(
\\begin{documen % :(
Broken
\\end{documen % :(\
`,
},
],
}
this.correct_request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\begin{document}
Hello world
\\end{document}\
`,
},
],
}
return ClsiApp.ensureRunning(done)
})
describe('on first run', function () {
before(function (done) {
this.project_id = Client.randomId()
return Client.compile(
this.project_id,
this.broken_request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
it('should return a failure status', function () {
return this.body.compile.status.should.equal('failure')
})
it('should return isInitialCompile flag', function () {
expect(this.body.compile.stats.isInitialCompile).to.equal(1)
})
it('should return output files', function () {
// NOTE: No output.pdf file.
this.body.compile.outputFiles
.map(f => f.path)
.should.deep.equal([
'output.aux',
'output.fdb_latexmk',
'output.fls',
'output.log',
'output.stderr',
'output.stdout',
])
})
})
return describe('on second run', function () {
before(function (done) {
this.project_id = Client.randomId()
return Client.compile(this.project_id, this.correct_request, () => {
return Client.compile(
this.project_id,
this.broken_request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
})
it('should return a failure status', function () {
return this.body.compile.status.should.equal('failure')
})
it('should not return isInitialCompile flag', function () {
expect(this.body.compile.stats.isInitialCompile).to.not.exist
})
it('should return output files', function () {
// NOTE: No output.pdf file.
this.body.compile.outputFiles
.map(f => f.path)
.should.deep.equal([
'output.aux',
'output.fdb_latexmk',
'output.fls',
'output.log',
'output.stderr',
'output.stdout',
])
})
})
})

View File

@@ -0,0 +1,72 @@
/* eslint-disable
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const Client = require('./helpers/Client')
const request = require('request')
const ClsiApp = require('./helpers/ClsiApp')
describe('Deleting Old Files', function () {
before(function (done) {
this.request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\begin{document}
Hello world
\\end{document}\
`,
},
],
}
return ClsiApp.ensureRunning(done)
})
return describe('on first run', function () {
before(function (done) {
this.project_id = Client.randomId()
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
it('should return a success status', function () {
return this.body.compile.status.should.equal('success')
})
return describe('after file has been deleted', function () {
before(function (done) {
this.request.resources = []
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
return it('should return a failure status', function () {
return this.body.compile.status.should.equal('failure')
})
})
})
})

View File

@@ -0,0 +1,285 @@
/* eslint-disable
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const Client = require('./helpers/Client')
const fetch = require('node-fetch')
const { pipeline } = require('node:stream')
const fs = require('node:fs')
const ChildProcess = require('node:child_process')
const ClsiApp = require('./helpers/ClsiApp')
const logger = require('@overleaf/logger')
const Path = require('node:path')
const fixturePath = path => {
if (path.slice(0, 3) === 'tmp') {
return '/tmp/clsi_acceptance_tests' + path.slice(3)
}
return Path.join(__dirname, '../fixtures/', path)
}
const process = require('node:process')
console.log(
process.pid,
process.ppid,
process.getuid(),
process.getgroups(),
'PID'
)
const MOCHA_LATEX_TIMEOUT = 60 * 1000
const convertToPng = function (pdfPath, pngPath, callback) {
if (callback == null) {
callback = function () {}
}
const command = `convert ${fixturePath(pdfPath)} ${fixturePath(pngPath)}`
console.log('COMMAND')
console.log(command)
const convert = ChildProcess.exec(command)
const stdout = ''
convert.stdout.on('data', chunk => console.log('STDOUT', chunk.toString()))
convert.stderr.on('data', chunk => console.log('STDERR', chunk.toString()))
return convert.on('exit', () => callback())
}
const compare = function (originalPath, generatedPath, callback) {
if (callback == null) {
callback = function () {}
}
const diffFile = `${fixturePath(generatedPath)}-diff.png`
const proc = ChildProcess.exec(
`compare -metric mae ${fixturePath(originalPath)} ${fixturePath(
generatedPath
)} ${diffFile}`
)
let stderr = ''
proc.stderr.on('data', chunk => (stderr += chunk))
return proc.on('exit', () => {
if (stderr.trim() === '0 (0)') {
// remove output diff if test matches expected image
fs.unlink(diffFile, err => {
if (err) {
throw err
}
})
return callback(null, true)
} else {
console.log('compare result', stderr)
return callback(null, false)
}
})
}
const checkPdfInfo = function (pdfPath, callback) {
if (callback == null) {
callback = function () {}
}
const proc = ChildProcess.exec(`pdfinfo ${fixturePath(pdfPath)}`)
let stdout = ''
proc.stdout.on('data', chunk => (stdout += chunk))
proc.stderr.on('data', chunk => console.log('STDERR', chunk.toString()))
return proc.on('exit', () => {
if (stdout.match(/Optimized:\s+yes/)) {
return callback(null, true)
} else {
return callback(null, false)
}
})
}
const compareMultiplePages = function (projectId, callback) {
if (callback == null) {
callback = function () {}
}
function compareNext(pageNo, callback) {
const path = `tmp/${projectId}-source-${pageNo}.png`
return fs.stat(fixturePath(path), (error, stat) => {
if (error != null) {
return callback()
} else {
return compare(
`tmp/${projectId}-source-${pageNo}.png`,
`tmp/${projectId}-generated-${pageNo}.png`,
(error, same) => {
if (error != null) {
throw error
}
same.should.equal(true)
return compareNext(pageNo + 1, callback)
}
)
}
})
}
return compareNext(0, callback)
}
const comparePdf = function (projectId, exampleDir, callback) {
if (callback == null) {
callback = function () {}
}
console.log('CONVERT')
console.log(`tmp/${projectId}.pdf`, `tmp/${projectId}-generated.png`)
return convertToPng(
`tmp/${projectId}.pdf`,
`tmp/${projectId}-generated.png`,
error => {
if (error != null) {
throw error
}
return convertToPng(
`examples/${exampleDir}/output.pdf`,
`tmp/${projectId}-source.png`,
error => {
if (error != null) {
throw error
}
return fs.stat(
fixturePath(`tmp/${projectId}-source-0.png`),
(error, stat) => {
if (error != null) {
return compare(
`tmp/${projectId}-source.png`,
`tmp/${projectId}-generated.png`,
(error, same) => {
if (error != null) {
throw error
}
same.should.equal(true)
return callback()
}
)
} else {
return compareMultiplePages(projectId, error => {
if (error != null) {
throw error
}
return callback()
})
}
}
)
}
)
}
)
}
const downloadAndComparePdf = function (projectId, exampleDir, url, callback) {
fetch(url)
.then(res => {
if (!res.ok) {
return callback(new Error('non success response: ' + res.statusText))
}
const dest = fs.createWriteStream(fixturePath(`tmp/${projectId}.pdf`))
pipeline(res.body, dest, err => {
if (err) return callback(err)
checkPdfInfo(`tmp/${projectId}.pdf`, (err, optimised) => {
if (err) return callback(err)
optimised.should.equal(true)
comparePdf(projectId, exampleDir, callback)
})
})
})
.catch(callback)
}
describe('Example Documents', function () {
Client.runFakeFilestoreService(fixturePath('examples'))
before(function (done) {
ClsiApp.ensureRunning(done)
})
before(function (done) {
fs.rm(fixturePath('tmp'), { force: true, recursive: true }, done)
})
before(function (done) {
fs.mkdir(fixturePath('tmp'), done)
})
after(function (done) {
fs.rm(fixturePath('tmp'), { force: true, recursive: true }, done)
})
return Array.from(fs.readdirSync(fixturePath('examples'))).map(exampleDir =>
(exampleDir =>
describe(exampleDir, function () {
before(function () {
return (this.project_id = Client.randomId() + '_' + exampleDir)
})
it('should generate the correct pdf', function (done) {
this.timeout(MOCHA_LATEX_TIMEOUT)
return Client.compileDirectory(
this.project_id,
fixturePath('examples'),
exampleDir,
(error, res, body) => {
if (
error ||
__guard__(
body != null ? body.compile : undefined,
x => x.status
) === 'failure'
) {
console.log('DEBUG: error', error, 'body', JSON.stringify(body))
return done(new Error('Compile failed'))
}
const pdf = Client.getOutputFile(body, 'pdf')
return downloadAndComparePdf(
this.project_id,
exampleDir,
pdf.url,
done
)
}
)
})
return it('should generate the correct pdf on the second run as well', function (done) {
this.timeout(MOCHA_LATEX_TIMEOUT)
return Client.compileDirectory(
this.project_id,
fixturePath('examples'),
exampleDir,
(error, res, body) => {
if (
error ||
__guard__(
body != null ? body.compile : undefined,
x => x.status
) === 'failure'
) {
console.log('DEBUG: error', error, 'body', JSON.stringify(body))
return done(new Error('Compile failed'))
}
const pdf = Client.getOutputFile(body, 'pdf')
return downloadAndComparePdf(
this.project_id,
exampleDir,
pdf.url,
done
)
}
)
})
}))(exampleDir)
)
})
function __guard__(value, transform) {
return typeof value !== 'undefined' && value !== null
? transform(value)
: undefined
}

View File

@@ -0,0 +1,91 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const Client = require('./helpers/Client')
const request = require('request')
const ClsiApp = require('./helpers/ClsiApp')
const Settings = require('@overleaf/settings')
describe('Simple LaTeX file', function () {
before(function (done) {
this.project_id = Client.randomId()
this.request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\begin{document}
Hello world
\\end{document}\
`,
},
],
options: {
metricsPath: 'clsi-perf',
metricsMethod: 'priority',
},
}
return ClsiApp.ensureRunning(() => {
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
})
it('should return the PDF', function () {
const pdf = Client.getOutputFile(this.body, 'pdf')
return pdf.type.should.equal('pdf')
})
it('should return the log', function () {
const log = Client.getOutputFile(this.body, 'log')
return log.type.should.equal('log')
})
it('should provide the pdf for download', function (done) {
const pdf = Client.getOutputFile(this.body, 'pdf')
return request.get(pdf.url, (error, res, body) => {
if (error) return done(error)
res.statusCode.should.equal(200)
return done()
})
})
it('should provide the log for download', function (done) {
const log = Client.getOutputFile(this.body, 'pdf')
return request.get(log.url, (error, res, body) => {
if (error) return done(error)
res.statusCode.should.equal(200)
return done()
})
})
it('should gather personalized metrics', function (done) {
request.get(`${Settings.apis.clsi.url}/metrics`, (err, res, body) => {
if (err) return done(err)
body
.split('\n')
.some(line => {
return (
line.startsWith('compile') &&
line.includes('path="clsi-perf"') &&
line.includes('method="priority"')
)
})
.should.equal(true)
done()
})
})
})

View File

@@ -0,0 +1,16 @@
const request = require('request')
const Settings = require('@overleaf/settings')
after(function (done) {
request(
{
url: `${Settings.apis.clsi.url}/metrics`,
},
(err, response, body) => {
if (err) return done(err)
console.error('-- metrics --')
console.error(body)
console.error('-- metrics --')
done()
}
)
})

View File

@@ -0,0 +1,188 @@
/* eslint-disable
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const Client = require('./helpers/Client')
const request = require('request')
const { expect } = require('chai')
const ClsiApp = require('./helpers/ClsiApp')
const crypto = require('node:crypto')
describe('Syncing', function () {
before(function (done) {
const content = `\
\\documentclass{article}
\\begin{document}
Hello world
\\end{document}\
`
this.request = {
resources: [
{
path: 'main.tex',
content,
},
],
}
this.project_id = Client.randomId()
return ClsiApp.ensureRunning(() => {
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
})
describe('from code to pdf', function () {
return it('should return the correct location', function (done) {
return Client.syncFromCode(
this.project_id,
'main.tex',
3,
5,
(error, pdfPositions) => {
if (error != null) {
throw error
}
expect(pdfPositions).to.deep.equal({
pdf: [
{
page: 1,
h: 133.768356,
v: 134.764618,
height: 6.918498,
width: 343.71106,
},
],
})
return done()
}
)
})
})
describe('from pdf to code', function () {
return it('should return the correct location', function (done) {
return Client.syncFromPdf(
this.project_id,
1,
100,
200,
(error, codePositions) => {
if (error != null) {
throw error
}
expect(codePositions).to.deep.equal({
code: [{ file: 'main.tex', line: 3, column: -1 }],
})
return done()
}
)
})
})
describe('when the project directory is not available', function () {
before(function () {
this.other_project_id = Client.randomId()
})
describe('from code to pdf', function () {
it('should return a 404 response', function (done) {
return Client.syncFromCode(
this.other_project_id,
'main.tex',
3,
5,
(error, body) => {
expect(String(error)).to.include('statusCode=404')
expect(body).to.equal('Not Found')
return done()
}
)
})
})
describe('from pdf to code', function () {
it('should return a 404 response', function (done) {
return Client.syncFromPdf(
this.other_project_id,
1,
100,
200,
(error, body) => {
expect(String(error)).to.include('statusCode=404')
expect(body).to.equal('Not Found')
return done()
}
)
})
})
})
describe('when the synctex file is not available', function () {
before(function (done) {
this.broken_project_id = Client.randomId()
const content = 'this is not valid tex' // not a valid tex file
this.request = {
resources: [
{
path: 'main.tex',
content,
},
],
}
Client.compile(
this.broken_project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
describe('from code to pdf', function () {
it('should return a 404 response', function (done) {
return Client.syncFromCode(
this.broken_project_id,
'main.tex',
3,
5,
(error, body) => {
expect(String(error)).to.include('statusCode=404')
expect(body).to.equal('Not Found')
return done()
}
)
})
})
describe('from pdf to code', function () {
it('should return a 404 response', function (done) {
return Client.syncFromPdf(
this.broken_project_id,
1,
100,
200,
(error, body) => {
expect(String(error)).to.include('statusCode=404')
expect(body).to.equal('Not Found')
return done()
}
)
})
})
})
})

View File

@@ -0,0 +1,66 @@
/* eslint-disable
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const Client = require('./helpers/Client')
const request = require('request')
const ClsiApp = require('./helpers/ClsiApp')
const { expect } = require('chai')
describe('Timed out compile', function () {
before(function (done) {
this.request = {
options: {
timeout: 10,
}, // seconds
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\begin{document}
\\def\\x{Hello!\\par\\x}
\\x
\\end{document}\
`,
},
],
}
this.project_id = Client.randomId()
return ClsiApp.ensureRunning(() => {
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
})
it('should return a timeout error', function () {
return this.body.compile.error.should.equal('container timed out')
})
it('should return a timedout status', function () {
return this.body.compile.status.should.equal('timedout')
})
it('should return isInitialCompile flag', function () {
expect(this.body.compile.stats.isInitialCompile).to.equal(1)
})
return it('should return the log output file name', function () {
const outputFilePaths = this.body.compile.outputFiles.map(x => x.path)
return outputFilePaths.should.include('output.log')
})
})

View File

@@ -0,0 +1,617 @@
/* eslint-disable
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const express = require('express')
const Path = require('node:path')
const Client = require('./helpers/Client')
const sinon = require('sinon')
const ClsiApp = require('./helpers/ClsiApp')
const request = require('request')
const Settings = require('@overleaf/settings')
const Server = {
run() {
const app = express()
const staticServer = express.static(Path.join(__dirname, '../fixtures/'))
const alreadyFailed = new Map()
app.get('/fail/:times/:id', (req, res) => {
this.getFile(req.url)
const soFar = alreadyFailed.get(req.params.id) || 0
const wanted = parseInt(req.params.times, 10)
if (soFar < wanted) {
alreadyFailed.set(req.params.id, soFar + 1)
res.status(503).end()
} else {
res.send('THE CONTENT')
}
})
app.get('/not-found', (req, res, next) => {
this.getFile(req.url)
res.status(404).end()
})
app.get('/project/:projectId/file/:fileId', (req, res, next) => {
this.getFile(req.url)
return res.send(`${req.params.projectId}:${req.params.fileId}`)
})
app.get('/bucket/:bucket/key/*', (req, res, next) => {
this.getFile(req.url)
return res.send(`${req.params.bucket}:${req.params[0]}`)
})
app.get('/:random_id/*', (req, res, next) => {
this.getFile(req.url)
req.url = `/${req.params[0]}`
return staticServer(req, res, next)
})
Client.startFakeFilestoreApp(app)
},
getFile() {},
randomId() {
return Math.random().toString(16).slice(2)
},
}
describe('Url Caching', function () {
Server.run()
describe('Retries', function () {
before(function (done) {
this.project_id = Client.randomId()
this.happyFile = `${Server.randomId()}/lion.png`
this.retryFileOnce = `fail/1/${Server.randomId()}`
this.retryFileTwice = `fail/2/${Server.randomId()}`
this.fatalFile = `fail/42/${Server.randomId()}`
this.request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\usepackage{graphicx}
\\begin{document}
\\includegraphics{lion.png}
\\end{document}\
`,
},
{
path: 'lion.png',
url: `http://filestore/${this.happyFile}`,
},
{
path: 'foo.tex',
url: `http://filestore/${this.retryFileOnce}`,
},
{
path: 'foo.tex',
url: `http://filestore/${this.retryFileTwice}`,
},
{
path: 'foo.tex',
url: `http://filestore/${this.fatalFile}`,
},
],
}
sinon.spy(Server, 'getFile')
ClsiApp.ensureRunning(() => {
Client.compile(this.project_id, this.request, (error, res, body) => {
this.error = error
this.res = res
this.body = body
done()
})
})
})
after(function () {
Server.getFile.restore()
})
function expectNFilestoreRequests(file, count) {
Server.getFile.args.filter(a => a[0] === file).should.have.length(count)
}
it('should download the happy file once', function () {
expectNFilestoreRequests(`/${this.happyFile}`, 1)
})
it('should retry the download of the unhappy files', function () {
expectNFilestoreRequests(`/${this.retryFileOnce}`, 2)
expectNFilestoreRequests(`/${this.retryFileTwice}`, 3)
expectNFilestoreRequests(`/${this.fatalFile}`, 3)
})
})
describe('Downloading an image for the first time', function () {
before(function (done) {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\usepackage{graphicx}
\\begin{document}
\\includegraphics{lion.png}
\\end{document}\
`,
},
{
path: 'lion.png',
url: `http://filestore/${this.file}`,
},
],
}
sinon.spy(Server, 'getFile')
return ClsiApp.ensureRunning(() => {
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
})
afterEach(function () {
return Server.getFile.restore()
})
return it('should download the image', function () {
return Server.getFile.calledWith(`/${this.file}`).should.equal(true)
})
})
describe('When an image is in the cache and the last modified date is unchanged', function () {
before(function (done) {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\usepackage{graphicx}
\\begin{document}
\\includegraphics{lion.png}
\\end{document}\
`,
},
(this.image_resource = {
path: 'lion.png',
url: `http://filestore/${this.file}`,
modified: Date.now(),
}),
],
}
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
sinon.spy(Server, 'getFile')
return Client.compile(
this.project_id,
this.request,
(error1, res1, body1) => {
this.error = error1
this.res = res1
this.body = body1
return done()
}
)
}
)
})
after(function () {
return Server.getFile.restore()
})
it('should not download the image again', function () {
return Server.getFile.called.should.equal(false)
})
it('should gather metrics', function (done) {
request.get(`${Settings.apis.clsi.url}/metrics`, (err, res, body) => {
if (err) return done(err)
body
.split('\n')
.some(line => {
return (
line.startsWith('url_source') && line.includes('path="unknown"')
)
})
.should.equal(true)
done()
})
})
})
describe('When an image is in the cache and the last modified date is advanced', function () {
before(function (done) {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\usepackage{graphicx}
\\begin{document}
\\includegraphics{lion.png}
\\end{document}\
`,
},
(this.image_resource = {
path: 'lion.png',
url: `http://filestore/${this.file}`,
modified: (this.last_modified = Date.now()),
}),
],
}
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
sinon.spy(Server, 'getFile')
this.image_resource.modified = new Date(this.last_modified + 3000)
return Client.compile(
this.project_id,
this.request,
(error1, res1, body1) => {
this.error = error1
this.res = res1
this.body = body1
return done()
}
)
}
)
})
afterEach(function () {
return Server.getFile.restore()
})
return it('should download the image again', function () {
return Server.getFile.called.should.equal(true)
})
})
describe('When an image is in the cache and the last modified date is further in the past', function () {
before(function (done) {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\usepackage{graphicx}
\\begin{document}
\\includegraphics{lion.png}
\\end{document}\
`,
},
(this.image_resource = {
path: 'lion.png',
url: `http://filestore/${this.file}`,
modified: (this.last_modified = Date.now()),
}),
],
}
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
sinon.spy(Server, 'getFile')
this.image_resource.modified = new Date(this.last_modified - 3000)
return Client.compile(
this.project_id,
this.request,
(error1, res1, body1) => {
this.error = error1
this.res = res1
this.body = body1
return done()
}
)
}
)
})
afterEach(function () {
return Server.getFile.restore()
})
return it('should download the other revision', function () {
return Server.getFile.called.should.equal(true)
})
})
describe('When an image is in the cache and the last modified date is not specified', function () {
before(function (done) {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\usepackage{graphicx}
\\begin{document}
\\includegraphics{lion.png}
\\end{document}\
`,
},
(this.image_resource = {
path: 'lion.png',
url: `http://filestore/${this.file}`,
modified: (this.last_modified = Date.now()),
}),
],
}
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
sinon.spy(Server, 'getFile')
delete this.image_resource.modified
return Client.compile(
this.project_id,
this.request,
(error1, res1, body1) => {
this.error = error1
this.res = res1
this.body = body1
return done()
}
)
}
)
})
afterEach(function () {
return Server.getFile.restore()
})
return it('should download the image again', function () {
return Server.getFile.called.should.equal(true)
})
})
describe('After clearing the cache', function () {
before(function (done) {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\usepackage{graphicx}
\\begin{document}
\\includegraphics{lion.png}
\\end{document}\
`,
},
(this.image_resource = {
path: 'lion.png',
url: `http://filestore/${this.file}`,
modified: (this.last_modified = Date.now()),
}),
],
}
return Client.compile(this.project_id, this.request, error => {
if (error != null) {
throw error
}
return Client.clearCache(this.project_id, (error, res, body) => {
if (error != null) {
throw error
}
sinon.spy(Server, 'getFile')
return Client.compile(
this.project_id,
this.request,
(error1, res1, body1) => {
this.error = error1
this.res = res1
this.body = body1
return done()
}
)
})
})
})
afterEach(function () {
return Server.getFile.restore()
})
return it('should download the image again', function () {
return Server.getFile.called.should.equal(true)
})
})
describe('fallbackURL', function () {
describe('when the primary resource is available', function () {
before(function (done) {
this.project_id = Client.randomId()
this.file = `/project/${Server.randomId()}/file/${Server.randomId()}`
this.fallback = `/bucket/project-blobs/key/ab/cd/${Server.randomId()}`
this.request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\usepackage{graphicx}
\\begin{document}
\\includegraphics{lion.png}
\\end{document}\
`,
},
{
path: 'lion.png',
url: `http://filestore${this.file}`,
fallbackURL: `http://filestore${this.fallback}`,
},
],
}
sinon.spy(Server, 'getFile')
return ClsiApp.ensureRunning(() => {
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
})
after(function () {
return Server.getFile.restore()
})
it('should download from the primary', function () {
Server.getFile.calledWith(this.file).should.equal(true)
})
it('should not download from the fallback', function () {
Server.getFile.calledWith(this.fallback).should.equal(false)
})
it('should gather metrics', function (done) {
request.get(`${Settings.apis.clsi.url}/metrics`, (err, res, body) => {
if (err) return done(err)
body
.split('\n')
.some(line => {
return (
line.startsWith('url_source') &&
line.includes('path="user-files"')
)
})
.should.equal(true)
done()
})
})
})
describe('when the primary resource is not available', function () {
before(function (done) {
this.project_id = Client.randomId()
this.file = `/project/${Server.randomId()}/file/${Server.randomId()}`
this.fallback = `/bucket/project-blobs/key/ab/cd/${Server.randomId()}`
this.request = {
resources: [
{
path: 'main.tex',
content: `\
\\documentclass{article}
\\usepackage{graphicx}
\\begin{document}
\\includegraphics{lion.png}
\\end{document}\
`,
},
{
path: 'lion.png',
url: `http://filestore/not-found`,
fallbackURL: `http://filestore${this.fallback}`,
},
],
}
sinon.spy(Server, 'getFile')
return ClsiApp.ensureRunning(() => {
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
})
after(function () {
return Server.getFile.restore()
})
it('should download from the fallback', function () {
Server.getFile.calledWith(`/not-found`).should.equal(true)
Server.getFile.calledWith(this.fallback).should.equal(true)
})
it('should gather metrics', function (done) {
request.get(`${Settings.apis.clsi.url}/metrics`, (err, res, body) => {
if (err) return done(err)
body
.split('\n')
.some(line => {
return (
line.startsWith('url_source') &&
line.includes('path="project-blobs"')
)
})
.should.equal(true)
done()
})
})
})
})
})

View File

@@ -0,0 +1,71 @@
/* eslint-disable
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const Client = require('./helpers/Client')
const request = require('request')
const { expect } = require('chai')
const path = require('node:path')
const fs = require('node:fs')
const ClsiApp = require('./helpers/ClsiApp')
describe('Syncing', function () {
before(function (done) {
this.request = {
resources: [
{
path: 'main.tex',
content: fs.readFileSync(
path.join(__dirname, '../fixtures/naugty_strings.txt'),
'utf-8'
),
},
],
}
this.project_id = Client.randomId()
return ClsiApp.ensureRunning(() => {
return Client.compile(
this.project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
})
})
return describe('wordcount file', function () {
return it('should return wordcount info', function (done) {
return Client.wordcount(this.project_id, 'main.tex', (error, result) => {
if (error != null) {
throw error
}
expect(result).to.deep.equal({
texcount: {
encode: 'utf8',
textWords: 2281,
headWords: 2,
outside: 0,
headers: 2,
elements: 0,
mathInline: 6,
mathDisplay: 0,
errors: 0,
messages: '',
},
})
return done()
})
})
})
})

View File

@@ -0,0 +1,248 @@
/* eslint-disable
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
let Client
const express = require('express')
const request = require('request')
const fs = require('node:fs')
const Settings = require('@overleaf/settings')
module.exports = Client = {
host: Settings.apis.clsi.url,
randomId() {
return Math.random().toString(16).slice(2)
},
compile(projectId, data, callback) {
if (callback == null) {
callback = function () {}
}
if (data) {
// Enable pdf caching unless disabled explicitly.
data.options = Object.assign({}, { enablePdfCaching: true }, data.options)
}
return request.post(
{
url: `${this.host}/project/${projectId}/compile`,
json: {
compile: data,
},
},
callback
)
},
clearCache(projectId, callback) {
if (callback == null) {
callback = function () {}
}
return request.del(`${this.host}/project/${projectId}`, callback)
},
getOutputFile(response, type) {
for (const file of Array.from(response.compile.outputFiles)) {
if (file.type === type && file.url.match(`output.${type}`)) {
return file
}
}
return null
},
runFakeFilestoreService(directory) {
const app = express()
app.use(express.static(directory))
this.startFakeFilestoreApp(app)
},
startFakeFilestoreApp(app) {
let server
before(function (done) {
server = app.listen(error => {
if (error) {
done(new Error('error starting server: ' + error.message))
} else {
const addr = server.address()
Settings.filestoreDomainOveride = `http://127.0.0.1:${addr.port}`
done()
}
})
})
after(function (done) {
server.close(done)
})
},
syncFromCode(projectId, file, line, column, callback) {
Client.syncFromCodeWithImage(projectId, file, line, column, '', callback)
},
syncFromCodeWithImage(projectId, file, line, column, imageName, callback) {
if (callback == null) {
callback = function () {}
}
return request.get(
{
url: `${this.host}/project/${projectId}/sync/code`,
qs: {
imageName,
file,
line,
column,
},
json: true,
},
(error, response, body) => {
if (error != null) {
return callback(error)
}
if (response.statusCode !== 200) {
return callback(new Error(`statusCode=${response.statusCode}`), body)
}
return callback(null, body)
}
)
},
syncFromPdf(projectId, page, h, v, callback) {
Client.syncFromPdfWithImage(projectId, page, h, v, '', callback)
},
syncFromPdfWithImage(projectId, page, h, v, imageName, callback) {
if (callback == null) {
callback = function () {}
}
return request.get(
{
url: `${this.host}/project/${projectId}/sync/pdf`,
qs: {
imageName,
page,
h,
v,
},
json: true,
},
(error, response, body) => {
if (error != null) {
return callback(error)
}
if (response.statusCode !== 200) {
return callback(new Error(`statusCode=${response.statusCode}`), body)
}
return callback(null, body)
}
)
},
compileDirectory(projectId, baseDirectory, directory, callback) {
if (callback == null) {
callback = function () {}
}
const resources = []
let entities = fs.readdirSync(`${baseDirectory}/${directory}`)
let rootResourcePath = 'main.tex'
while (entities.length > 0) {
const entity = entities.pop()
const stat = fs.statSync(`${baseDirectory}/${directory}/${entity}`)
if (stat.isDirectory()) {
entities = entities.concat(
fs
.readdirSync(`${baseDirectory}/${directory}/${entity}`)
.map(subEntity => {
if (subEntity === 'main.tex') {
rootResourcePath = `${entity}/${subEntity}`
}
return `${entity}/${subEntity}`
})
)
} else if (stat.isFile() && entity !== 'output.pdf') {
const extension = entity.split('.').pop()
if (
[
'tex',
'bib',
'cls',
'sty',
'pdf_tex',
'Rtex',
'ist',
'md',
'Rmd',
'Rnw',
].indexOf(extension) > -1
) {
resources.push({
path: entity,
content: fs
.readFileSync(`${baseDirectory}/${directory}/${entity}`)
.toString(),
})
} else if (
['eps', 'ttf', 'png', 'jpg', 'pdf', 'jpeg'].indexOf(extension) > -1
) {
resources.push({
path: entity,
url: `http://filestore/${directory}/${entity}`,
modified: stat.mtime,
})
}
}
}
return fs.readFile(
`${baseDirectory}/${directory}/options.json`,
(error, body) => {
const req = {
resources,
rootResourcePath,
}
if (error == null) {
body = JSON.parse(body)
req.options = body
}
return this.compile(projectId, req, callback)
}
)
},
wordcount(projectId, file, callback) {
const image = undefined
Client.wordcountWithImage(projectId, file, image, callback)
},
wordcountWithImage(projectId, file, image, callback) {
if (callback == null) {
callback = function () {}
}
return request.get(
{
url: `${this.host}/project/${projectId}/wordcount`,
qs: {
image,
file,
},
},
(error, response, body) => {
if (error != null) {
return callback(error)
}
if (response.statusCode !== 200) {
return callback(new Error(`statusCode=${response.statusCode}`), body)
}
return callback(null, JSON.parse(body))
}
)
},
}

View File

@@ -0,0 +1,50 @@
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS101: Remove unnecessary use of Array.from
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__
* DS205: Consider reworking code to avoid use of IIFEs
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const app = require('../../../../app')
const Settings = require('@overleaf/settings')
module.exports = {
running: false,
initing: false,
callbacks: [],
ensureRunning(callback) {
if (callback == null) {
callback = function () {}
}
if (this.running) {
return callback()
} else if (this.initing) {
return this.callbacks.push(callback)
} else {
this.initing = true
this.callbacks.push(callback)
return app.listen(
Settings.internal.clsi.port,
Settings.internal.clsi.host,
error => {
if (error != null) {
throw error
}
this.running = true
return (() => {
const result = []
for (callback of Array.from(this.callbacks)) {
result.push(callback())
}
return result
})()
}
)
}
},
}