13 Commits

Author SHA1 Message Date
Antoine Clausse
72c02bbd98 [e2e] Prevent slashes in generated project names (#28835)
* Prevent slashes in generated project names by using base64url encoding (replaces `+` and `/` by `-` and `_`)

* Keep base64 (Missing baseUrl in compilerOptions) and replace the slash manually

GitOrigin-RevId: 62217873de8d1e00e72927a5195338ad92c09769
2025-10-03 08:06:56 +00:00
Domagoj Kriskovic
632a563ed2 convert some helper functions in ProjectHistoryClient
GitOrigin-RevId: a73207c4dfbf13cd96456e024adfa6a046002e00
2025-10-03 08:06:31 +00:00
Domagoj Kriskovic
5944d20340 promisify DiscardingUpdatesTests
GitOrigin-RevId: ea64c162bc05d7f30cf95cc397991d7f448c26e2
2025-10-03 08:06:26 +00:00
Domagoj Kriskovic
5e03da3b58 promisify DiffTests
GitOrigin-RevId: 688f0f5f52917483912ee69779381d1ea2469ad8
2025-10-03 08:06:21 +00:00
Domagoj Kriskovic
c1645ebe2c promisify RetryTests
GitOrigin-RevId: 8e748a63bd523087d023be5573e8e23ef3bda3f2
2025-10-03 08:06:16 +00:00
Domagoj Kriskovic
3840eaa0d8 promisify DeleteProjectTests
GitOrigin-RevId: a86e6962253bbf4adf5fc9748e7accb9084b31f9
2025-10-03 08:06:11 +00:00
Domagoj Kriskovic
a9d805ef9b delete unused helper
GitOrigin-RevId: ff7d96159e8c71b2a5ebed4041ee44f5cf45ceb1
2025-10-03 08:06:06 +00:00
Domagoj Kriskovic
854536fcc9 use async/await HealthCheckTests, promisify helpers
GitOrigin-RevId: 1016296695eb1c09d74d6f50c765438b83df1024
2025-10-03 08:06:01 +00:00
Kristina
b0b7822d8d [web] redirect offsite payment failures back to checkout (#28778)
GitOrigin-RevId: 528cc1fa916ac9ec7b2e12d4224f25798253b5a9
2025-10-02 08:06:35 +00:00
Kristina
e2e6a52b97 [web] rm unused callback parameter from ProjectDetailsHandler (#28804)
GitOrigin-RevId: 73cbf3a0ff9fcb6da60e6e6f9a6e3ae5196c79c9
2025-10-02 08:06:30 +00:00
Jakob Ackermann
c3c04acfea [web] add junit integration for Jenkins (#28788)
* [web] add junit integration for Jenkins

* [web] integrate junit test report for writefull into Jenkins

* [web] put all the junit test results into namespaces

GitOrigin-RevId: ba1ff07b5ea7bcfa97bb4d6bf7fa9e5291ab7b0f
2025-10-02 08:06:25 +00:00
Miguel Serrano
e8bc186ca0 Merge pull request #28752 from overleaf/msm-clsi-acceptance-async-await
[clsi] async/await migration in acceptance tests

GitOrigin-RevId: d614fabb6d568dc5c955603fb923fb40b871a703
2025-10-02 08:06:04 +00:00
Domagoj Kriskovic
c22e44438e Support for deleting and editing chat messages (#28204)
* Initial server-side delete of chat message plus dropdown

* Update chat pane after deleting message

* Chat message dropdown styling

* Add confirmation dialog for deleting a message

* Refactor chat message grouping to allow deletion of individual messages

* Delete other user's deleted message from chat pane

* Implement message editing

* Styling

* Make the dropdown appear overlap with the button slightly so that the menu stays visible when the user moves their cursor into the menu when the menu is positioned above the button

* Submit edit with Enter key

* Add edited indicator to edited chat messages

* Add animation to chat message deletion

* Tidying, edit chat message textarea improvements

* Add types to message-list-utils

* update dependencies

* edit/delete for ide-redesign

* fix type errors in tests

* filter deleted messages from group

* promisify ChatController

* fix tests and translations

* add new tests

* chat-context tests

* fix message-list-appender tests

* add new tests for message-list-utils

* Update services/web/test/frontend/features/chat/context/chat-context.test.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* preserve original content when canceling edits

* update delete message translation

* hide dropdown only if not already shown

* remove delete animation

* fix lint error

* fix chat.yaml

* hide under feature flag

---------

Co-authored-by: Tim Down <158919+timdown@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
GitOrigin-RevId: 12521886a1a59ccd564851df19e5d46c70d328f5
2025-10-02 08:05:58 +00:00
68 changed files with 3110 additions and 2653 deletions

20
package-lock.json generated
View File

@@ -37047,6 +37047,23 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mocha-multi-reporters": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/mocha-multi-reporters/-/mocha-multi-reporters-1.5.1.tgz",
"integrity": "sha512-Yb4QJOaGLIcmB0VY7Wif5AjvLMUFAdV57D2TWEva1Y0kU/3LjKpeRVmlMIfuO1SVbauve459kgtIizADqxMWPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.1.1",
"lodash": "^4.17.15"
},
"engines": {
"node": ">=6.0.0"
},
"peerDependencies": {
"mocha": ">=3.1.2"
}
},
"node_modules/mocha/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -52717,6 +52734,7 @@
"css-loader": "^6.8.1",
"css-minimizer-webpack-plugin": "^5.0.1",
"cypress": "13.13.2",
"cypress-multi-reporters": "^2.0.5",
"cypress-plugin-tab": "^1.0.5",
"d3": "^3.5.16",
"daterangepicker": "2.1.27",
@@ -52758,6 +52776,8 @@
"mini-css-extract-plugin": "^2.7.6",
"mocha": "^11.1.0",
"mocha-each": "^2.0.1",
"mocha-junit-reporter": "^2.2.1",
"mocha-multi-reporters": "^1.5.1",
"mock-fs": "^5.1.2",
"nock": "^13.5.6",
"nvd3": "^1.8.6",

View File

@@ -141,6 +141,7 @@ export function getSpamSafeProjectName() {
// Move from hex/16 to base64/64 possible characters per char in string
const name = Buffer.from(uuid().replaceAll('-', ''), 'hex')
.toString('base64')
.replace('/', '_')
.slice(0, 10)
const nDigits = (name.match(/\d/g) || []).length
if (nDigits < 6) return name

View File

@@ -78,6 +78,10 @@ export async function editMessage(context) {
return await callMessageHttpController(context, _editMessage)
}
export async function editGlobalMessage(context) {
return await callMessageHttpController(context, _editGlobalMessage)
}
export async function deleteMessage(context) {
return await callMessageHttpController(context, _deleteMessage)
}
@@ -86,6 +90,10 @@ export async function deleteUserMessage(context) {
return await callMessageHttpController(context, _deleteUserMessage)
}
export async function deleteGlobalMessage(context) {
return await callMessageHttpController(context, _deleteGlobalMessage)
}
export async function getResolvedThreadIds(context) {
return await callMessageHttpController(context, _getResolvedThreadIds)
}
@@ -242,6 +250,28 @@ const _editMessage = async (req, res) => {
res.status(204)
}
const _editGlobalMessage = async (req, res) => {
const { content, userId } = req.body
const { projectId, messageId } = req.params
logger.debug({ projectId, messageId, content }, 'editing global message')
const room = await ThreadManager.findOrCreateThread(
projectId,
ThreadManager.GLOBAL_THREAD
)
const found = await MessageManager.updateMessage(
room._id,
messageId,
userId,
content,
Date.now()
)
if (!found) {
res.status(404)
return
}
res.status(204)
}
const _deleteMessage = async (req, res) => {
const { projectId, threadId, messageId } = req.params
logger.debug({ projectId, threadId, messageId }, 'deleting message')
@@ -257,6 +287,16 @@ const _deleteUserMessage = async (req, res) => {
res.status(204)
}
const _deleteGlobalMessage = async (req, res) => {
const { projectId, messageId } = req.params
const room = await ThreadManager.findOrCreateThread(
projectId,
ThreadManager.GLOBAL_THREAD
)
await MessageManager.deleteMessage(room._id, messageId)
res.status(204)
}
const _getResolvedThreadIds = async (req, res) => {
const { projectId } = req.params
const resolvedThreadIds = await ThreadManager.getResolvedThreadIds(projectId)
@@ -307,6 +347,7 @@ async function _sendMessage(userId, projectId, content, clientThreadId, res) {
)
message = MessageFormatter.formatMessageForClientSide(message)
message.room_id = projectId
res.status(201).setBody(message)
}

View File

@@ -82,6 +82,13 @@ paths:
description: Message not found
operationId: getGlobalMessage
description: Get a single global message by message ID for the project with Project ID provided
delete:
summary: Delete global message
operationId: deleteGlobalMessage
responses:
'204':
description: No Content
description: 'Delete global message'
'/project/{projectId}/thread/{threadId}/messages':
parameters:
- schema:
@@ -179,6 +186,46 @@ paths:
- user_id: Id of the user (optional)
description: |
Update message with Message ID provided from the Thread ID and Project ID provided
'/project/{projectId}/messages/{messageId}/edit':
parameters:
- schema:
type: string
name: projectId
in: path
required: true
- schema:
type: string
name: messageId
in: path
required: true
post:
summary: Edit global message
operationId: editGlobalMessage
responses:
'204':
description: No Content
'404':
description: Not Found
requestBody:
content:
application/json:
schema:
type: object
properties:
content:
type: string
user_id:
type: string
readOnly: true
required:
- content
examples: {}
description: |-
JSON object with :
- content: Content of the message to edit
- user_id: Id of the user (optional)
description: |
Update global message with Message ID provided from the Project ID provided
'/project/{projectId}/thread/{threadId}/messages/{messageId}':
parameters:
- schema:

View File

@@ -3,7 +3,7 @@ const ClsiApp = require('./helpers/ClsiApp')
const { expect } = require('chai')
describe('AllowedImageNames', function () {
beforeEach(function (done) {
beforeEach(async function () {
this.project_id = Client.randomId()
this.request = {
options: {
@@ -21,22 +21,21 @@ Hello world
},
],
}
ClsiApp.ensureRunning(done)
await ClsiApp.ensureRunning()
})
describe('with a valid name', function () {
beforeEach(function (done) {
beforeEach(async function () {
this.request.options.imageName = process.env.TEXLIVE_IMAGE
Client.compile(this.project_id, this.request, (error, res, body) => {
try {
this.body = await Client.compile(this.project_id, this.request)
} catch (error) {
this.error = error
this.res = res
this.body = body
done(error)
})
}
})
it('should return success', function () {
expect(this.res.statusCode).to.equal(200)
expect(this.error).not.to.exist
})
it('should return a PDF', function () {
@@ -49,17 +48,16 @@ Hello world
})
describe('with an invalid name', function () {
beforeEach(function (done) {
beforeEach(async function () {
this.request.options.imageName = 'something/evil:1337'
Client.compile(this.project_id, this.request, (error, res, body) => {
try {
this.body = await Client.compile(this.project_id, this.request)
} catch (error) {
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)
expect(this.error.response.status).to.equal(500)
})
it('should not return a PDF', function () {
@@ -72,118 +70,109 @@ Hello world
})
describe('syncToCode', function () {
beforeEach(function (done) {
Client.compile(this.project_id, this.request, done)
beforeEach(async function () {
await Client.compile(this.project_id, this.request)
})
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 error out with an invalid imageName', async function () {
const rejects = () =>
expect(
Client.syncFromCodeWithImage(
this.project_id,
'main.tex',
3,
5,
'something/evil:1337'
)
).to.eventually.be.rejected
await rejects().and.have.property('body', 'invalid image')
await rejects().and.have.property('info').to.contain({ status: 400 })
})
it('should produce a mapping a valid imageName', function (done) {
Client.syncFromCodeWithImage(
it('should produce a mapping a valid imageName', async function () {
const result = await 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,
},
],
downloadedFromCache: false,
})
done()
}
process.env.TEXLIVE_IMAGE
)
expect(result).to.deep.equal({
pdf: [
{
page: 1,
h: 133.768356,
v: 134.764618,
height: 6.918498,
width: 343.71106,
},
],
downloadedFromCache: false,
})
})
})
describe('syncToPdf', function () {
beforeEach(function (done) {
Client.compile(this.project_id, this.request, done)
beforeEach(async function () {
await Client.compile(this.project_id, this.request)
})
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 error out with an invalid imageName', async function () {
const rejects = () =>
expect(
Client.syncFromPdfWithImage(
this.project_id,
'main.tex',
100,
200,
'something/evil:1337'
)
).to.eventually.be.rejected
await rejects().and.have.property('body', 'invalid image')
await rejects().and.have.property('info').to.contain({ status: 400 })
})
it('should produce a mapping a valid imageName', function (done) {
Client.syncFromPdfWithImage(
it('should produce a mapping a valid imageName', async function () {
const result = await 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 }],
downloadedFromCache: false,
})
done()
}
process.env.TEXLIVE_IMAGE
)
expect(result).to.deep.equal({
code: [{ file: 'main.tex', line: 3, column: -1 }],
downloadedFromCache: false,
})
})
})
describe('wordcount', function () {
beforeEach(function (done) {
Client.compile(this.project_id, this.request, done)
beforeEach(async function () {
await Client.compile(this.project_id, this.request)
})
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 error out with an invalid imageName', async function () {
const rejects = () =>
expect(
Client.wordcountWithImage(
this.project_id,
'main.tex',
'something/evil:1337'
)
).to.eventually.be.rejected
await rejects().and.have.property('body', 'invalid image')
await rejects().and.have.property('info').to.contain({ status: 400 })
})
it('should produce a texcout a valid imageName', function (done) {
Client.wordcountWithImage(
it('should produce a texcout a valid imageName', async function () {
const result = await 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()
}
process.env.TEXLIVE_IMAGE
)
expect(result).to.exist
expect(result.texcount).to.exist
})
})
})

View File

@@ -1,20 +1,9 @@
/* 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) {
before(async function () {
this.broken_request = {
resources: [
{
@@ -41,26 +30,17 @@ Hello world
},
],
}
return ClsiApp.ensureRunning(done)
await ClsiApp.ensureRunning()
})
describe('on first run', function () {
before(function (done) {
before(async function () {
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()
}
)
this.body = await Client.compile(this.project_id, this.broken_request)
})
it('should return a failure status', function () {
return this.body.compile.status.should.equal('failure')
this.body.compile.status.should.equal('failure')
})
it('should return isInitialCompile flag', function () {
@@ -82,25 +62,15 @@ Hello world
})
})
return describe('on second run', function () {
before(function (done) {
describe('on second run', function () {
before(async function () {
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()
}
)
})
await Client.compile(this.project_id, this.correct_request)
this.body = await Client.compile(this.project_id, this.broken_request)
})
it('should return a failure status', function () {
return this.body.compile.status.should.equal('failure')
this.body.compile.status.should.equal('failure')
})
it('should not return isInitialCompile flag', function () {

View File

@@ -1,19 +1,8 @@
/* 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) {
before(async function () {
this.request = {
resources: [
{
@@ -27,45 +16,27 @@ Hello world
},
],
}
return ClsiApp.ensureRunning(done)
await ClsiApp.ensureRunning()
})
return describe('on first run', function () {
before(function (done) {
describe('on first run', function () {
before(async function () {
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()
}
)
this.body = await Client.compile(this.project_id, this.request)
})
it('should return a success status', function () {
return this.body.compile.status.should.equal('success')
this.body.compile.status.should.equal('success')
})
return describe('after file has been deleted', function () {
before(function (done) {
describe('after file has been deleted', function () {
before(async function () {
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()
}
)
this.body = await Client.compile(this.project_id, this.request)
})
return it('should return a failure status', function () {
return this.body.compile.status.should.equal('failure')
it('should return a failure status', function () {
this.body.compile.status.should.equal('failure')
})
})
})

View File

@@ -1,24 +1,11 @@
/* 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 Stream = require('node:stream')
const fs = require('node:fs')
const fsPromises = require('node:fs/promises')
const ChildProcess = require('node:child_process')
const { promisify } = require('node:util')
const ClsiApp = require('./helpers/ClsiApp')
const logger = require('@overleaf/logger')
const Path = require('node:path')
const fixturePath = path => {
if (path.slice(0, 3) === 'tmp') {
@@ -27,6 +14,7 @@ const fixturePath = path => {
return Path.join(__dirname, '../fixtures/', path)
}
const process = require('node:process')
const pipeline = promisify(Stream.pipeline)
console.log(
process.pid,
process.ppid,
@@ -37,249 +25,167 @@ console.log(
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 convertToPng = function (pdfPath, pngPath) {
return new Promise((resolve, reject) => {
const command = `convert ${fixturePath(pdfPath)} ${fixturePath(pngPath)}`
console.log('COMMAND')
console.log(command)
const convert = ChildProcess.exec(command)
convert.stdout.on('data', chunk => console.log('STDOUT', chunk.toString()))
convert.stderr.on('data', chunk => console.log('STDERR', chunk.toString()))
convert.on('exit', () => resolve())
convert.on('error', error => reject(error))
})
}
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)
const compare = function (originalPath, generatedPath) {
return new Promise((resolve, reject) => {
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))
proc.on('exit', () => {
if (stderr.trim() === '0 (0)') {
// remove output diff if test matches expected image
fs.unlink(diffFile, err => {
if (err) {
reject(err)
}
)
})
resolve(true)
} else {
console.log('compare result', stderr)
resolve(false)
}
})
}
return compareNext(0, callback)
})
}
const comparePdf = function (projectId, exampleDir, callback) {
if (callback == null) {
callback = function () {}
const checkPdfInfo = function (pdfPath) {
return new Promise((resolve, reject) => {
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()))
proc.on('exit', () => {
if (stdout.match(/Optimized:\s+yes/)) {
resolve(true)
} else {
resolve(false)
}
})
proc.on('error', error => reject(error))
})
}
const compareMultiplePages = async function (projectId) {
async function compareNext(pageNo) {
const path = `tmp/${projectId}-source-${pageNo}.png`
try {
await fsPromises.stat(fixturePath(path))
} catch (error) {
return
}
const same = await compare(
`tmp/${projectId}-source-${pageNo}.png`,
`tmp/${projectId}-generated-${pageNo}.png`
)
same.should.equal(true)
await compareNext(pageNo + 1)
}
await compareNext(0)
}
const comparePdf = async function (projectId, exampleDir) {
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()
})
}
}
)
}
)
}
await convertToPng(`tmp/${projectId}.pdf`, `tmp/${projectId}-generated.png`)
await convertToPng(
`examples/${exampleDir}/output.pdf`,
`tmp/${projectId}-source.png`
)
try {
await fsPromises.stat(fixturePath(`tmp/${projectId}-source-0.png`))
await compareMultiplePages(projectId)
} catch (error) {
const same = await compare(
`tmp/${projectId}-source.png`,
`tmp/${projectId}-generated.png`
)
same.should.equal(true)
}
}
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)
const downloadAndComparePdf = async function (projectId, exampleDir, url) {
const res = await fetch(url)
if (!res.ok) {
throw new Error('non success response: ' + res.statusText)
}
const dest = fs.createWriteStream(fixturePath(`tmp/${projectId}.pdf`))
await pipeline(res.body, dest)
const optimised = await checkPdfInfo(`tmp/${projectId}.pdf`)
optimised.should.equal(true)
await comparePdf(projectId, exampleDir)
}
describe('Example Documents', function () {
Client.runFakeFilestoreService(fixturePath('examples'))
before(function (done) {
ClsiApp.ensureRunning(done)
before(async function () {
await ClsiApp.ensureRunning()
})
before(function (done) {
fs.rm(fixturePath('tmp'), { force: true, recursive: true }, done)
before(async function () {
await fsPromises.rm(fixturePath('tmp'), { force: true, recursive: true })
})
before(function (done) {
fs.mkdir(fixturePath('tmp'), done)
before(async function () {
await fsPromises.mkdir(fixturePath('tmp'))
})
after(function (done) {
fs.rm(fixturePath('tmp'), { force: true, recursive: true }, done)
after(async function () {
await fsPromises.rm(fixturePath('tmp'), { force: true, recursive: true })
})
return Array.from(fs.readdirSync(fixturePath('examples'))).map(exampleDir =>
return fs.readdirSync(fixturePath('examples')).map(exampleDir =>
(exampleDir =>
describe(exampleDir, function () {
before(function () {
return (this.project_id = Client.randomId() + '_' + exampleDir)
this.project_id = Client.randomId() + '_' + exampleDir
})
it('should generate the correct pdf', function (done) {
it('should generate the correct pdf', async function () {
this.timeout(MOCHA_LATEX_TIMEOUT)
return Client.compileDirectory(
const body = await 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
)
if (body?.compile?.status === 'failure') {
throw new Error('Compile failed')
}
const pdf = Client.getOutputFile(body, 'pdf')
await downloadAndComparePdf(this.project_id, exampleDir, pdf.url)
})
return it('should generate the correct pdf on the second run as well', function (done) {
it('should generate the correct pdf on the second run as well', async function () {
this.timeout(MOCHA_LATEX_TIMEOUT)
return Client.compileDirectory(
const body = await 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
)
if (body?.compile?.status === 'failure') {
throw new Error('Compile failed')
}
const pdf = Client.getOutputFile(body, 'pdf')
await downloadAndComparePdf(this.project_id, exampleDir, pdf.url)
})
}))(exampleDir)
)
})
function __guard__(value, transform) {
return typeof value !== 'undefined' && value !== null
? transform(value)
: undefined
}

View File

@@ -1,17 +1,10 @@
// 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 { fetchString, fetchNothing } = require('@overleaf/fetch-utils')
const ClsiApp = require('./helpers/ClsiApp')
const Settings = require('@overleaf/settings')
describe('Simple LaTeX file', function () {
before(function (done) {
before(async function () {
this.project_id = Client.randomId()
this.request = {
resources: [
@@ -30,62 +23,48 @@ Hello world
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()
}
)
})
await ClsiApp.ensureRunning()
try {
this.body = await Client.compile(this.project_id, this.request)
} catch (error) {
this.error = error
}
})
it('should return the PDF', function () {
const pdf = Client.getOutputFile(this.body, 'pdf')
return pdf.type.should.equal('pdf')
pdf.type.should.equal('pdf')
})
it('should return the log', function () {
const log = Client.getOutputFile(this.body, 'log')
return log.type.should.equal('log')
log.type.should.equal('log')
})
it('should provide the pdf for download', function (done) {
it('should provide the pdf for download', async function () {
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()
})
const response = await fetchNothing(pdf.url)
response.status.should.equal(200)
})
it('should provide the log for download', function (done) {
it('should provide the log for download', async function () {
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()
})
const response = await fetchNothing(log.url)
response.status.should.equal(200)
})
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()
})
it('should gather personalized metrics', async function () {
const body = await fetchString(`${Settings.apis.clsi.url}/metrics`)
body
.split('\n')
.some(line => {
return (
line.startsWith('compile') &&
line.includes('path="clsi-perf"') &&
line.includes('method="priority"')
)
})
.should.equal(true)
})
})

View File

@@ -1,16 +1,8 @@
const request = require('request')
const { fetchString } = require('@overleaf/fetch-utils')
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()
}
)
after(async function () {
const metrics = await fetchString(`${Settings.apis.clsi.url}/metrics`)
console.error('-- metrics --')
console.error(metrics)
console.error('-- metrics --')
})

View File

@@ -1,9 +1,12 @@
const { promisify } = require('node:util')
const Client = require('./helpers/Client')
const ClsiApp = require('./helpers/ClsiApp')
const { expect } = require('chai')
const sleep = promisify(setTimeout)
describe('Stop compile', function () {
before(function (done) {
before(async function () {
this.request = {
options: {
timeout: 100,
@@ -22,25 +25,35 @@ describe('Stop compile', function () {
],
}
this.project_id = Client.randomId()
ClsiApp.ensureRunning(() => {
// start the compile in the background
Client.compile(this.project_id, this.request, (error, res, body) => {
this.compileResult = { error, res, body }
await ClsiApp.ensureRunning()
// start the compile in the background
Client.compile(this.project_id, this.request)
.then(body => {
this.compileResult = { body }
})
// wait for 1 second before stopping the compile
setTimeout(() => {
Client.stopCompile(this.project_id, (error, res, body) => {
this.stopResult = { error, res, body }
setTimeout(done, 1000) // allow time for the compile request to terminate
})
}, 1000)
})
.catch(error => {
this.compileResult = { error }
})
// wait for 1 second before stopping the compile
await sleep(1000)
try {
const res = await Client.stopCompile(this.project_id)
this.stopResult = { res }
} catch (error) {
this.stopResult = { error }
}
// allow time for the compile request to terminate
await sleep(1000)
})
it('should force a compile response with an error status', function () {
expect(this.stopResult.error).to.be.null
expect(this.stopResult.res.statusCode).to.equal(204)
expect(this.compileResult.res.statusCode).to.equal(200)
expect(this.stopResult.error).not.to.exist
expect(this.stopResult.res.status).to.equal(204)
expect(this.compileResult.error).not.to.exist
expect(this.compileResult.body.compile.status).to.equal('terminated')
expect(this.compileResult.body.compile.error).to.equal('terminated')
})

View File

@@ -1,22 +1,9 @@
/* 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) {
before(async function () {
const content = `\
\\documentclass{article}
\\begin{document}
@@ -32,67 +19,45 @@ Hello world
],
}
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()
}
)
})
await ClsiApp.ensureRunning()
this.body = await Client.compile(this.project_id, this.request)
})
describe('from code to pdf', function () {
return it('should return the correct location', function (done) {
return Client.syncFromCode(
it('should return the correct location', async function () {
const pdfPositions = await 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,
},
],
downloadedFromCache: false,
})
return done()
}
5
)
expect(pdfPositions).to.deep.equal({
pdf: [
{
page: 1,
h: 133.768356,
v: 134.764618,
height: 6.918498,
width: 343.71106,
},
],
downloadedFromCache: false,
})
})
})
describe('from pdf to code', function () {
return it('should return the correct location', function (done) {
return Client.syncFromPdf(
it('should return the correct location', async function () {
const codePositions = await 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 }],
downloadedFromCache: false,
})
return done()
}
200
)
expect(codePositions).to.deep.equal({
code: [{ file: 'main.tex', line: 3, column: -1 }],
downloadedFromCache: false,
})
})
})
@@ -101,39 +66,29 @@ Hello world
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()
}
)
it('should return a 404 response', async function () {
const rejects = () =>
expect(Client.syncFromCode(this.other_project_id, 'main.tex', 3, 5))
.to.eventually.be.rejected
await rejects().and.have.property('info').to.contain({ status: 404 })
await rejects().and.have.property('body', 'Not Found')
})
})
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()
}
)
it('should return a 404 response', async function () {
const rejects = () =>
expect(Client.syncFromPdf(this.other_project_id, 1, 100, 200)).to
.eventually.be.rejected
await rejects().and.have.property('info').to.contain({ status: 404 })
await rejects().and.have.property('body', 'Not Found')
})
})
})
describe('when the synctex file is not available', function () {
before(function (done) {
before(async function () {
this.broken_project_id = Client.randomId()
const content = 'this is not valid tex' // not a valid tex file
this.request = {
@@ -144,46 +99,27 @@ Hello world
},
],
}
Client.compile(
this.broken_project_id,
this.request,
(error, res, body) => {
this.error = error
this.res = res
this.body = body
return done()
}
)
this.body = await Client.compile(this.broken_project_id, this.request)
})
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()
}
)
it('should return a 404 response', async function () {
const rejects = () =>
expect(Client.syncFromCode(this.broken_project_id, 'main.tex', 3, 5))
.to.eventually.be.rejected
await rejects().and.have.property('info').to.contain({ status: 404 })
await rejects().and.have.property('body', 'Not Found')
})
})
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()
}
)
it('should return a 404 response', async function () {
const rejects = () =>
expect(Client.syncFromPdf(this.broken_project_id, 1, 100, 200)).to
.eventually.be.rejected
await rejects().and.have.property('info').to.contain({ status: 404 })
await rejects().and.have.property('body', 'Not Found')
})
})
})

View File

@@ -1,20 +1,9 @@
/* 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) {
before(async function () {
this.request = {
options: {
timeout: 10,
@@ -33,34 +22,24 @@ describe('Timed out compile', function () {
],
}
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()
}
)
})
await ClsiApp.ensureRunning()
this.body = await Client.compile(this.project_id, this.request)
})
it('should return a timeout error', function () {
return this.body.compile.error.should.equal('container timed out')
this.body.compile.error.should.equal('container timed out')
})
it('should return a timedout status', function () {
return this.body.compile.status.should.equal('timedout')
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 () {
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')
outputFilePaths.should.include('output.log')
})
})

View File

@@ -1,20 +1,9 @@
/* 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 { fetchString } = require('@overleaf/fetch-utils')
const Settings = require('@overleaf/settings')
const Server = {
@@ -44,18 +33,18 @@ const Server = {
app.get('/project/:projectId/file/:fileId', (req, res, next) => {
this.getFile(req.url)
return res.send(`${req.params.projectId}:${req.params.fileId}`)
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]}`)
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)
staticServer(req, res, next)
})
Client.startFakeFilestoreApp(app)
@@ -72,7 +61,7 @@ describe('Url Caching', function () {
Server.run()
describe('Retries', function () {
before(function (done) {
before(async function () {
this.project_id = Client.randomId()
this.happyFile = `${Server.randomId()}/lion.png`
this.retryFileOnce = `fail/1/${Server.randomId()}`
@@ -110,14 +99,8 @@ describe('Url Caching', function () {
}
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()
})
})
await ClsiApp.ensureRunning()
this.body = await Client.compile(this.project_id, this.request)
})
after(function () {
@@ -139,7 +122,7 @@ describe('Url Caching', function () {
})
describe('Downloading an image for the first time', function () {
before(function (done) {
before(async function () {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
@@ -162,31 +145,21 @@ describe('Url Caching', function () {
}
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()
}
)
})
await ClsiApp.ensureRunning()
this.body = await Client.compile(this.project_id, this.request)
})
afterEach(function () {
return Server.getFile.restore()
Server.getFile.restore()
})
return it('should download the image', function () {
return Server.getFile.calledWith(`/${this.file}`).should.equal(true)
it('should download the image', function () {
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) {
before(async function () {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
@@ -209,54 +182,34 @@ describe('Url Caching', function () {
],
}
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()
}
)
}
)
await Client.compile(this.project_id, this.request)
sinon.spy(Server, 'getFile')
await Client.compile(this.project_id, this.request)
})
after(function () {
return Server.getFile.restore()
Server.getFile.restore()
})
it('should not download the image again', function () {
return Server.getFile.called.should.equal(false)
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()
})
it('should gather metrics', async function () {
const body = await fetchString(`${Settings.apis.clsi.url}/metrics`)
body
.split('\n')
.some(line => {
return (
line.startsWith('url_source') && line.includes('path="unknown"')
)
})
.should.equal(true)
})
})
describe('When an image is in the cache and the last modified date is advanced', function () {
before(function (done) {
before(async function () {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
@@ -279,40 +232,25 @@ describe('Url Caching', function () {
],
}
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()
}
)
}
)
await Client.compile(this.project_id, this.request)
sinon.spy(Server, 'getFile')
this.image_resource.modified = new Date(this.last_modified + 3000)
await Client.compile(this.project_id, this.request)
})
afterEach(function () {
return Server.getFile.restore()
Server.getFile.restore()
})
return it('should download the image again', function () {
return Server.getFile.called.should.equal(true)
it('should download the image again', function () {
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) {
before(async function () {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
@@ -335,40 +273,25 @@ describe('Url Caching', function () {
],
}
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()
}
)
}
)
await Client.compile(this.project_id, this.request)
sinon.spy(Server, 'getFile')
this.image_resource.modified = new Date(this.last_modified - 3000)
await Client.compile(this.project_id, this.request)
})
afterEach(function () {
return Server.getFile.restore()
Server.getFile.restore()
})
return it('should download the other revision', function () {
return Server.getFile.called.should.equal(true)
it('should download the other revision', function () {
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) {
before(async function () {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
@@ -391,40 +314,25 @@ describe('Url Caching', function () {
],
}
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()
}
)
}
)
await Client.compile(this.project_id, this.request)
sinon.spy(Server, 'getFile')
delete this.image_resource.modified
await Client.compile(this.project_id, this.request)
})
afterEach(function () {
return Server.getFile.restore()
Server.getFile.restore()
})
return it('should download the image again', function () {
return Server.getFile.called.should.equal(true)
it('should download the image again', function () {
Server.getFile.called.should.equal(true)
})
})
describe('After clearing the cache', function () {
before(function (done) {
before(async function () {
this.project_id = Client.randomId()
this.file = `${Server.randomId()}/lion.png`
this.request = {
@@ -447,41 +355,26 @@ describe('Url Caching', function () {
],
}
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()
}
)
})
})
await Client.compile(this.project_id, this.request)
await Client.clearCache(this.project_id)
sinon.spy(Server, 'getFile')
await Client.compile(this.project_id, this.request)
})
afterEach(function () {
return Server.getFile.restore()
Server.getFile.restore()
})
return it('should download the image again', function () {
return Server.getFile.called.should.equal(true)
it('should download the image again', function () {
Server.getFile.called.should.equal(true)
})
})
describe('fallbackURL', function () {
describe('when the primary resource is available', function () {
before(function (done) {
before(async function () {
this.project_id = Client.randomId()
this.file = `/project/${Server.randomId()}/file/${Server.randomId()}`
this.fallback = `/bucket/project-blobs/key/ab/cd/${Server.randomId()}`
@@ -506,22 +399,12 @@ describe('Url Caching', function () {
}
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()
}
)
})
await ClsiApp.ensureRunning()
await Client.compile(this.project_id, this.request)
})
after(function () {
return Server.getFile.restore()
Server.getFile.restore()
})
it('should download from the primary', function () {
@@ -531,25 +414,22 @@ describe('Url Caching', 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()
})
it('should gather metrics', async function () {
const body = await fetchString(`${Settings.apis.clsi.url}/metrics`)
body
.split('\n')
.some(line => {
return (
line.startsWith('url_source') &&
line.includes('path="user-files"')
)
})
.should.equal(true)
})
})
describe('when the primary resource is not available', function () {
before(function (done) {
before(async function () {
this.project_id = Client.randomId()
this.file = `/project/${Server.randomId()}/file/${Server.randomId()}`
this.fallback = `/bucket/project-blobs/key/ab/cd/${Server.randomId()}`
@@ -574,22 +454,12 @@ describe('Url Caching', function () {
}
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()
}
)
})
await ClsiApp.ensureRunning()
await Client.compile(this.project_id, this.request)
})
after(function () {
return Server.getFile.restore()
Server.getFile.restore()
})
it('should download from the fallback', function () {
@@ -597,20 +467,17 @@ describe('Url Caching', function () {
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()
})
it('should gather metrics', async function () {
const body = await fetchString(`${Settings.apis.clsi.url}/metrics`)
body
.split('\n')
.some(line => {
return (
line.startsWith('url_source') &&
line.includes('path="project-blobs"')
)
})
.should.equal(true)
})
})
})

View File

@@ -1,23 +1,11 @@
/* 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) {
before(async function () {
this.request = {
resources: [
{
@@ -30,41 +18,26 @@ describe('Syncing', function () {
],
}
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()
}
)
})
await ClsiApp.ensureRunning()
this.body = await Client.compile(this.project_id, this.request)
})
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()
describe('wordcount file', function () {
it('should return wordcount info', async function () {
const result = await Client.wordcount(this.project_id, 'main.tex')
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: '',
},
})
})
})

View File

@@ -1,258 +1,195 @@
/* 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 { fetchJson, fetchNothing } = require('@overleaf/fetch-utils')
const fs = require('node:fs')
const fsPromises = require('node:fs/promises')
const Settings = require('@overleaf/settings')
module.exports = Client = {
host: Settings.apis.clsi.url,
const 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
)
},
stopCompile(projectId, callback) {
if (callback == null) {
callback = function () {}
}
return request.post(
{ url: `${this.host}/project/${projectId}/compile/stop` },
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))
}
)
},
function randomId() {
return Math.random().toString(16).slice(2)
}
function compile(projectId, data) {
if (data) {
// Enable pdf caching unless disabled explicitly.
data.options = Object.assign({}, { enablePdfCaching: true }, data.options)
}
return fetchJson(`${host}/project/${projectId}/compile`, {
method: 'POST',
json: {
compile: data,
},
})
}
async function stopCompile(projectId) {
return await fetchNothing(`${host}/project/${projectId}/compile/stop`, {
method: 'POST',
})
}
async function clearCache(projectId) {
await fetchNothing(`${host}/project/${projectId}`, {
method: 'DELETE',
})
}
function getOutputFile(response, type) {
for (const file of response.compile.outputFiles) {
if (file.type === type && file.url.match(`output.${type}`)) {
return file
}
}
return null
}
function runFakeFilestoreService(directory) {
const app = express()
app.use(express.static(directory))
this.startFakeFilestoreApp(app)
}
function 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)
})
}
function syncFromCode(projectId, file, line, column) {
return syncFromCodeWithImage(projectId, file, line, column, '')
}
async function syncFromCodeWithImage(projectId, file, line, column, imageName) {
const url = new URL(`${host}/project/${projectId}/sync/code`)
url.searchParams.append('imageName', imageName)
url.searchParams.append('file', file)
url.searchParams.append('line', line)
url.searchParams.append('column', column)
return await fetchJson(url)
}
function syncFromPdf(projectId, page, h, v) {
return syncFromPdfWithImage(projectId, page, h, v, '')
}
function syncFromPdfWithImage(projectId, page, h, v, imageName) {
const url = new URL(`${host}/project/${projectId}/sync/pdf`)
url.searchParams.append('imageName', imageName)
url.searchParams.append('page', page)
url.searchParams.append('h', h)
url.searchParams.append('v', v)
return fetchJson(url)
}
function wordcount(projectId, file) {
const image = undefined
return wordcountWithImage(projectId, file, image)
}
async function wordcountWithImage(projectId, file, image) {
const url = new URL(`${host}/project/${projectId}/wordcount`)
if (image) {
url.searchParams.append('image', image)
}
url.searchParams.append('file', file)
return await fetchJson(url)
}
async function compileDirectory(projectId, baseDirectory, directory) {
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,
})
}
}
}
const req = {
resources,
rootResourcePath,
}
try {
const options = await fsPromises.readFile(
`${baseDirectory}/${directory}/options.json`
)
req.options = JSON.parse(options)
} catch (error) {
// noop
}
return await compile(projectId, req)
}
module.exports = {
randomId,
compile,
stopCompile,
clearCache,
getOutputFile,
runFakeFilestoreService,
startFakeFilestoreApp,
syncFromCode,
syncFromCodeWithImage,
syncFromPdf,
syncFromPdfWithImage,
compileDirectory,
wordcount,
wordcountWithImage,
}

View File

@@ -1,50 +1,31 @@
// 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
})()
function startApp() {
return new Promise((resolve, reject) => {
app.listen(
Settings.internal.clsi.port,
Settings.internal.clsi.host,
error => {
if (error) {
reject(error)
} else {
resolve()
}
)
}
},
}
)
})
}
let appStartedPromise
async function ensureRunning() {
if (!appStartedPromise) {
appStartedPromise = startApp()
}
await appStartedPromise
}
module.exports = {
ensureRunning,
}

View File

@@ -10,7 +10,7 @@ const MockWeb = () => nock('http://127.0.0.1:3000')
const fixture = path => new URL(`../fixtures/${path}`, import.meta.url)
describe('Deleting project', function () {
beforeEach(function (done) {
beforeEach(async function () {
this.projectId = new ObjectId().toString()
this.historyId = new ObjectId().toString()
MockWeb()
@@ -23,61 +23,40 @@ describe('Deleting project', function () {
.get(`/api/projects/${this.historyId}/latest/history`)
.replyWithFile(200, fixture('chunks/0-3.json'))
MockHistoryStore().delete(`/api/projects/${this.historyId}`).reply(204)
ProjectHistoryApp.ensureRunning(done)
await ProjectHistoryApp.promises.ensureRunning()
})
describe('when the project has no pending updates', function (done) {
it('successfully deletes the project', function (done) {
ProjectHistoryClient.deleteProject(this.projectId, done)
describe('when the project has no pending updates', function () {
it('successfully deletes the project', async function () {
await ProjectHistoryClient.deleteProject(this.projectId)
})
})
describe('when the project has pending updates', function (done) {
beforeEach(function (done) {
ProjectHistoryClient.pushRawUpdate(
describe('when the project has pending updates', function () {
beforeEach(async function () {
await ProjectHistoryClient.promises.pushRawUpdate(this.projectId, {
pathname: '/main.tex',
docLines: 'hello',
doc: this.docId,
meta: { userId: this.userId, ts: new Date() },
})
await ProjectHistoryClient.promises.setFirstOpTimestamp(
this.projectId,
{
pathname: '/main.tex',
docLines: 'hello',
doc: this.docId,
meta: { userId: this.userId, ts: new Date() },
},
err => {
if (err) {
return done(err)
}
ProjectHistoryClient.setFirstOpTimestamp(
this.projectId,
Date.now(),
err => {
if (err) {
return done(err)
}
ProjectHistoryClient.deleteProject(this.projectId, done)
}
)
}
Date.now()
)
await ProjectHistoryClient.deleteProject(this.projectId)
})
it('clears pending updates', function (done) {
ProjectHistoryClient.getDump(this.projectId, (err, dump) => {
if (err) {
return done(err)
}
expect(dump.updates).to.deep.equal([])
done()
})
it('clears pending updates', async function () {
const dump = await ProjectHistoryClient.getDump(this.projectId)
expect(dump.updates).to.deep.equal([])
})
it('clears the first op timestamp', function (done) {
ProjectHistoryClient.getFirstOpTimestamp(this.projectId, (err, ts) => {
if (err) {
return done(err)
}
expect(ts).to.be.null
done()
})
it('clears the first op timestamp', async function () {
const ts = await ProjectHistoryClient.promises.getFirstOpTimestamp(
this.projectId
)
expect(ts).to.be.null
})
})
})

View File

@@ -1,8 +1,11 @@
import { expect } from 'chai'
import request from 'request'
import crypto from 'node:crypto'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import {
fetchJsonWithResponse,
RequestFailedError,
} from '@overleaf/fetch-utils'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
import * as ProjectHistoryApp from './helpers/ProjectHistoryApp.js'
const { ObjectId } = mongodb
@@ -20,39 +23,30 @@ function createMockBlob(historyId, content) {
}
describe('Diffs', function () {
beforeEach(function (done) {
ProjectHistoryApp.ensureRunning(error => {
if (error) {
throw error
}
beforeEach(async function () {
await ProjectHistoryApp.promises.ensureRunning()
this.historyId = new ObjectId().toString()
this.projectId = new ObjectId().toString()
this.historyId = new ObjectId().toString()
this.projectId = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: this.historyId,
})
MockWeb()
.get(`/project/${this.projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: this.historyId } },
})
ProjectHistoryClient.initializeProject(this.historyId, error => {
if (error) {
return done(error)
}
done()
})
MockHistoryStore().post('/api/projects').reply(200, {
projectId: this.historyId,
})
MockWeb()
.get(`/project/${this.projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: { history: { id: this.historyId } },
})
await ProjectHistoryClient.promises.initializeProject(this.historyId)
})
afterEach(function () {
nock.cleanAll()
})
it('should return a diff of the updates to a doc from a single chunk', function (done) {
it('should return a diff of the updates to a doc from a single chunk', async function () {
this.blob = 'one two three five'
this.sha = createMockBlob(this.historyId, this.blob)
this.v2AuthorId = '123456789'
@@ -107,58 +101,52 @@ describe('Diffs', function () {
authors: [31],
})
ProjectHistoryClient.getDiff(
const diff = await ProjectHistoryClient.getDiff(
this.projectId,
'foo.tex',
3,
6,
(error, diff) => {
if (error) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
u: 'one ',
},
{
d: 'two ',
meta: {
users: [31],
start_ts: 1512383362905,
end_ts: 1512383362905,
},
},
{
u: 'three',
},
{
i: ' four',
meta: {
users: [31],
start_ts: 1512383357786,
end_ts: 1512383357786,
},
},
{
u: ' five',
},
{
i: ' six',
meta: {
users: [this.v2AuthorId],
start_ts: 1512383366120,
end_ts: 1512383366120,
},
},
],
})
done()
}
6
)
expect(diff).to.deep.equal({
diff: [
{
u: 'one ',
},
{
d: 'two ',
meta: {
users: [31],
start_ts: 1512383362905,
end_ts: 1512383362905,
},
},
{
u: 'three',
},
{
i: ' four',
meta: {
users: [31],
start_ts: 1512383357786,
end_ts: 1512383357786,
},
},
{
u: ' five',
},
{
i: ' six',
meta: {
users: [this.v2AuthorId],
start_ts: 1512383366120,
end_ts: 1512383366120,
},
},
],
})
})
it('should return a diff of the updates to a doc across multiple chunks', function (done) {
it('should return a diff of the updates to a doc across multiple chunks', async function () {
MockHistoryStore()
.get(`/api/projects/${this.historyId}/versions/5/history`)
.reply(200, {
@@ -240,47 +228,41 @@ describe('Diffs', function () {
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
ProjectHistoryClient.getDiff(
const diff = await ProjectHistoryClient.getDiff(
this.projectId,
'foo.tex',
4,
6,
(error, diff) => {
if (error) {
throw error
}
expect(diff).to.deep.equal({
diff: [
{
u: 'one ',
},
{
d: 'two ',
meta: {
users: [31],
start_ts: 1512383362905,
end_ts: 1512383362905,
},
},
{
u: 'three four five',
},
{
i: ' six',
meta: {
users: [31],
start_ts: 1512383366120,
end_ts: 1512383366120,
},
},
],
})
done()
}
6
)
expect(diff).to.deep.equal({
diff: [
{
u: 'one ',
},
{
d: 'two ',
meta: {
users: [31],
start_ts: 1512383362905,
end_ts: 1512383362905,
},
},
{
u: 'three four five',
},
{
i: ' six',
meta: {
users: [31],
start_ts: 1512383366120,
end_ts: 1512383366120,
},
},
],
})
})
it('should return a 404 when there are no changes for the file in the range', function (done) {
it('should return a 404 when there are no changes for the file in the range', async function () {
this.blob = 'one two three five'
this.sha = createMockBlob(this.historyId, this.blob)
MockHistoryStore()
@@ -314,27 +296,18 @@ describe('Diffs', function () {
authors: [31],
})
request.get(
{
url: `http://127.0.0.1:3054/project/${this.projectId}/diff`,
qs: {
pathname: 'not_here.tex',
from: 3,
to: 6,
},
json: true,
},
(error, res, body) => {
if (error) {
throw error
}
expect(res.statusCode).to.equal(404)
done()
}
)
try {
await fetchJsonWithResponse(
`http://127.0.0.1:3054/project/${this.projectId}/diff?pathname=not_here.tex&from=3&to=6`
)
expect.fail('Expected a 404 error')
} catch (error) {
expect(error).to.be.instanceOf(RequestFailedError)
expect(error.response.status).to.equal(404)
}
})
it('should return a binary flag with a diff of a binary file', function (done) {
it('should return a binary flag with a diff of a binary file', async function () {
this.blob = 'one two three five'
this.sha = createMockBlob(this.historyId, this.blob)
this.binaryBlob = Buffer.from([1, 2, 3, 4])
@@ -394,22 +367,16 @@ describe('Diffs', function () {
authors: [{ id: 31, email: 'james.allen@overleaf.com', name: 'James' }],
})
ProjectHistoryClient.getDiff(
const diff = await ProjectHistoryClient.getDiff(
this.projectId,
'binary.tex',
3,
6,
(error, diff) => {
if (error) {
throw error
}
expect(diff).to.deep.equal({
diff: {
binary: true,
},
})
done()
}
6
)
expect(diff).to.deep.equal({
diff: {
binary: true,
},
})
})
})

View File

@@ -1,20 +1,3 @@
/* eslint-disable
no-undef,
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
*/
import async from 'async'
import sinon from 'sinon'
import { expect } from 'chai'
import Settings from '@overleaf/settings'
import assert from 'node:assert'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
@@ -25,49 +8,31 @@ const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
describe('DiscardingUpdates', function () {
beforeEach(function (done) {
beforeEach(async function () {
this.timestamp = new Date()
return ProjectHistoryApp.ensureRunning(error => {
if (error != null) {
throw error
}
this.user_id = new ObjectId().toString()
this.project_id = new ObjectId().toString()
this.doc_id = new ObjectId().toString()
await ProjectHistoryApp.promises.ensureRunning()
this.user_id = new ObjectId().toString()
this.project_id = new ObjectId().toString()
this.doc_id = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: 0,
})
MockWeb()
.get(`/project/${this.project_id}/details`)
.reply(200, { name: 'Test Project' })
return ProjectHistoryClient.initializeProject(this.project_id, done)
MockHistoryStore().post('/api/projects').reply(200, {
projectId: 0,
})
MockWeb()
.get(`/project/${this.project_id}/details`)
.reply(200, { name: 'Test Project' })
await ProjectHistoryClient.promises.initializeProject(this.project_id)
})
return it('should discard updates', function (done) {
return async.series(
[
cb => {
const update = {
pathname: '/main.tex',
docLines: 'a\nb',
doc: this.doc_id,
meta: { user_id: this.user_id, ts: new Date() },
}
return ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb)
},
cb => {
return ProjectHistoryClient.flushProject(this.project_id, cb)
},
],
error => {
if (error != null) {
throw error
}
return done()
}
)
it('should discard updates', async function () {
const update = {
pathname: '/main.tex',
docLines: 'a\nb',
doc: this.doc_id,
meta: { user_id: this.user_id, ts: new Date() },
}
await ProjectHistoryClient.promises.pushRawUpdate(this.project_id, update)
await ProjectHistoryClient.promises.flushProject(this.project_id)
})
})

View File

@@ -1,17 +1,6 @@
/* eslint-disable
no-undef,
*/
// 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
*/
import { expect } from 'chai'
import settings from '@overleaf/settings'
import request from 'request'
import { fetchNothing } from '@overleaf/fetch-utils'
import mongodb from 'mongodb-legacy'
import nock from 'nock'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
@@ -22,55 +11,43 @@ const MockHistoryStore = () => nock('http://127.0.0.1:3100')
const MockWeb = () => nock('http://127.0.0.1:3000')
describe('Health Check', function () {
beforeEach(function (done) {
beforeEach(async function () {
const projectId = new ObjectId()
const historyId = new ObjectId().toString()
settings.history.healthCheck = { project_id: projectId }
return ProjectHistoryApp.ensureRunning(error => {
if (error != null) {
throw error
}
MockHistoryStore().post('/api/projects').reply(200, {
projectId: historyId,
})
MockHistoryStore()
.get(`/api/projects/${historyId}/latest/history`)
.reply(200, {
chunk: {
startVersion: 0,
history: {
snapshot: {},
changes: [],
},
},
})
MockWeb()
.get(`/project/${projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: {
history: {
id: historyId,
},
},
})
return ProjectHistoryClient.initializeProject(historyId, done)
await ProjectHistoryApp.promises.ensureRunning()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: historyId,
})
MockHistoryStore()
.get(`/api/projects/${historyId}/latest/history`)
.reply(200, {
chunk: {
startVersion: 0,
history: {
snapshot: {},
changes: [],
},
},
})
MockWeb()
.get(`/project/${projectId}/details`)
.reply(200, {
name: 'Test Project',
overleaf: {
history: {
id: historyId,
},
},
})
await ProjectHistoryClient.promises.initializeProject(historyId)
})
return it('should respond to the health check', function (done) {
return request.get(
{
url: 'http://127.0.0.1:3054/health_check',
},
(error, res, body) => {
if (error != null) {
return callback(error)
}
expect(res.statusCode).to.equal(200)
return done()
}
)
it('should respond to the health check', async function () {
const response = await fetchNothing('http://127.0.0.1:3054/health_check')
expect(response.status).to.equal(200)
})
})

View File

@@ -1,7 +1,6 @@
import async from 'async'
import nock from 'nock'
import { expect } from 'chai'
import request from 'request'
import { fetchStringWithResponse } from '@overleaf/fetch-utils'
import assert from 'node:assert'
import mongodb from 'mongodb-legacy'
import * as ProjectHistoryClient from './helpers/ProjectHistoryClient.js'
@@ -16,42 +15,39 @@ const MockCallback = () => nock('http://127.0.0.1')
describe('Retrying failed projects', function () {
const historyId = new ObjectId().toString()
beforeEach(function (done) {
beforeEach(async function () {
this.timestamp = new Date()
ProjectHistoryApp.ensureRunning(error => {
if (error) {
throw error
}
this.project_id = new ObjectId().toString()
this.doc_id = new ObjectId().toString()
this.file_id = new ObjectId().toString()
await ProjectHistoryApp.promises.ensureRunning()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: historyId,
})
MockWeb()
.get(`/project/${this.project_id}/details`)
.reply(200, {
name: 'Test Project',
overleaf: {
history: {
id: historyId,
},
},
})
MockHistoryStore()
.get(`/api/projects/${historyId}/latest/history`)
.reply(200, {
chunk: {
startVersion: 0,
history: {
changes: [],
},
},
})
ProjectHistoryClient.initializeProject(historyId, done)
this.project_id = new ObjectId().toString()
this.doc_id = new ObjectId().toString()
this.file_id = new ObjectId().toString()
MockHistoryStore().post('/api/projects').reply(200, {
projectId: historyId,
})
MockWeb()
.get(`/project/${this.project_id}/details`)
.reply(200, {
name: 'Test Project',
overleaf: {
history: {
id: historyId,
},
},
})
MockHistoryStore()
.get(`/api/projects/${historyId}/latest/history`)
.reply(200, {
chunk: {
startVersion: 0,
history: {
changes: [],
},
},
})
await ProjectHistoryClient.promises.initializeProject(historyId)
})
afterEach(function () {
@@ -60,7 +56,7 @@ describe('Retrying failed projects', function () {
describe('retrying project history', function () {
describe('when there is a soft failure', function () {
beforeEach(function (done) {
beforeEach(async function () {
this.flushCall = MockHistoryStore()
.put(
`/api/projects/${historyId}/blobs/0a207c060e61f3b88eaee0a8cd0696f46fb155eb`
@@ -74,82 +70,69 @@ describe('Retrying failed projects', function () {
doc: this.doc_id,
meta: { user_id: this.user_id, ts: new Date() },
}
async.series(
[
cb =>
ProjectHistoryClient.pushRawUpdate(this.project_id, update, cb),
cb =>
ProjectHistoryClient.setFailure(
{
project_id: this.project_id,
attempts: 1,
error: 'soft-error',
},
cb
),
],
done
await ProjectHistoryClient.promises.pushRawUpdate(
this.project_id,
update
)
await ProjectHistoryClient.setFailure({
project_id: this.project_id,
attempts: 1,
error: 'soft-error',
})
})
it('flushes the project history queue', function (done) {
request.post(
it('flushes the project history queue', async function () {
const { response } = await fetchStringWithResponse(
'http://127.0.0.1:3054/retry/failures?failureType=soft&limit=1&timeout=10000',
{
url: 'http://127.0.0.1:3054/retry/failures?failureType=soft&limit=1&timeout=10000',
},
(error, res, body) => {
if (error) {
return done(error)
}
expect(res.statusCode).to.equal(200)
assert(
this.flushCall.isDone(),
'made calls to history service to store updates'
)
done()
method: 'POST',
}
)
expect(response.status).to.equal(200)
assert(
this.flushCall.isDone(),
'made calls to history service to store updates'
)
})
it('retries in the background when requested', function (done) {
it('retries in the background when requested', async function () {
this.callback = MockCallback()
.matchHeader('Authorization', '123')
.get('/ping')
.reply(200)
request.post(
const { body, response } = await fetchStringWithResponse(
'http://127.0.0.1:3054/retry/failures?failureType=soft&limit=1&timeout=10000&callbackUrl=http%3A%2F%2F127.0.0.1%2Fping',
{
url: 'http://127.0.0.1:3054/retry/failures?failureType=soft&limit=1&timeout=10000&callbackUrl=http%3A%2F%2F127.0.0.1%2Fping',
method: 'POST',
headers: {
'X-CALLBACK-Authorization': '123',
},
},
(error, res, body) => {
if (error) {
return done(error)
}
expect(res.statusCode).to.equal(200)
expect(body).to.equal(
'{"retryStatus":"running retryFailures in background"}'
)
assert(
!this.flushCall.isDone(),
'did not make calls to history service to store updates in the foreground'
)
setTimeout(() => {
assert(
this.flushCall.isDone(),
'made calls to history service to store updates in the background'
)
assert(this.callback.isDone(), 'hit the callback url')
done()
}, 100)
}
)
expect(response.status).to.equal(200)
expect(body).to.equal(
'{"retryStatus":"running retryFailures in background"}'
)
assert(
!this.flushCall.isDone(),
'did not make calls to history service to store updates in the foreground'
)
await new Promise(resolve => setTimeout(resolve, 100))
assert(
this.flushCall.isDone(),
'made calls to history service to store updates in the background'
)
assert(this.callback.isDone(), 'hit the callback url')
})
})
describe('when there is a hard failure', function () {
beforeEach(function (done) {
beforeEach(async function () {
MockWeb()
.get(`/project/${this.project_id}/details`)
.reply(200, {
@@ -160,34 +143,27 @@ describe('Retrying failed projects', function () {
},
},
})
ProjectHistoryClient.setFailure(
{
project_id: this.project_id,
attempts: 100,
error: 'hard-error',
},
done
)
await ProjectHistoryClient.setFailure({
project_id: this.project_id,
attempts: 100,
error: 'hard-error',
})
})
it('calls web to resync the project', function (done) {
it('calls web to resync the project', async function () {
const resyncCall = MockWeb()
.post(`/project/${this.project_id}/history/resync`)
.reply(200)
request.post(
const { response } = await fetchStringWithResponse(
'http://127.0.0.1:3054/retry/failures?failureType=hard&limit=1&timeout=10000',
{
url: 'http://127.0.0.1:3054/retry/failures?failureType=hard&limit=1&timeout=10000',
},
(error, res, body) => {
if (error) {
return done(error)
}
expect(res.statusCode).to.equal(200)
assert(resyncCall.isDone(), 'made a call to web to resync project')
done()
method: 'POST',
}
)
expect(response.status).to.equal(200)
assert(resyncCall.isDone(), 'made a call to web to resync project')
})
})
})

View File

@@ -1,41 +0,0 @@
/* 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
*/
import { expect } from 'chai'
import request from 'request'
import Settings from '@overleaf/settings'
export function getLatestContent(olProjectId, callback) {
if (callback == null) {
callback = function () {}
}
return request.get(
{
url: `${Settings.overleaf.history.host}/projects/${olProjectId}/latest/content`,
auth: {
user: Settings.overleaf.history.user,
pass: Settings.overleaf.history.pass,
sendImmediately: true,
},
},
(error, res, body) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
callback(
new Error(
`history store a non-success status code: ${res.statusCode}`
)
)
}
return callback(error, JSON.parse(body))
}
)
}

View File

@@ -10,6 +10,7 @@
*/
import { app } from '../../../../app/js/server.js'
import { mongoClient } from '../../../../app/js/mongodb.js'
import { promisify } from '@overleaf/promise-utils'
let running = false
let initing = false
@@ -43,3 +44,7 @@ export function ensureRunning(callback) {
})
})
}
export const promises = {
ensureRunning: promisify(ensureRunning),
}

View File

@@ -3,6 +3,8 @@ import request from 'request'
import Settings from '@overleaf/settings'
import RedisWrapper from '@overleaf/redis-wrapper'
import { db } from '../../../../app/js/mongodb.js'
import { promisify } from '@overleaf/promise-utils'
import { fetchJsonWithResponse, fetchNothing } from '@overleaf/fetch-utils'
const rclient = RedisWrapper.createClient(Settings.redis.project_history)
const Keys = Settings.redis.project_history.key_schema
@@ -68,25 +70,15 @@ export function getSummarizedUpdates(projectId, query, callback) {
)
}
export function getDiff(projectId, pathname, from, to, callback) {
request.get(
{
url: `http://127.0.0.1:3054/project/${projectId}/diff`,
qs: {
pathname,
from,
to,
},
json: true,
},
(error, res, body) => {
if (error) {
return callback(error)
}
expect(res.statusCode).to.equal(200)
callback(error, body)
}
)
export async function getDiff(projectId, pathname, from, to) {
const url = new URL(`http://127.0.0.1:3054/project/${projectId}/diff`)
url.searchParams.set('pathname', pathname)
url.searchParams.set('from', from)
url.searchParams.set('to', to)
const { response, json } = await fetchJsonWithResponse(url.toString())
expect(response.status).to.equal(200)
return json
}
export function getFileTreeDiff(projectId, from, to, callback) {
@@ -299,16 +291,9 @@ export function deleteLabel(projectId, labelId, callback) {
)
}
export function setFailure(failureEntry, callback) {
db.projectHistoryFailures.deleteOne(
{ project_id: { $exists: true } },
(err, result) => {
if (err) {
return callback(err)
}
db.projectHistoryFailures.insertOne(failureEntry, callback)
}
)
export async function setFailure(failureEntry) {
await db.projectHistoryFailures.deleteOne({ project_id: { $exists: true } })
return await db.projectHistoryFailures.insertOne(failureEntry)
}
export function getFailure(projectId, callback) {
@@ -330,25 +315,26 @@ export function transferLabelOwnership(fromUser, toUser, callback) {
)
}
export function getDump(projectId, callback) {
request.get(
`http://127.0.0.1:3054/project/${projectId}/dump`,
(err, res, body) => {
if (err) {
return callback(err)
}
expect(res.statusCode).to.equal(200)
callback(null, JSON.parse(body))
}
export async function getDump(projectId) {
const { response, json } = await fetchJsonWithResponse(
`http://127.0.0.1:3054/project/${projectId}/dump`
)
expect(response.status).to.equal(200)
return json
}
export function deleteProject(projectId, callback) {
request.delete(`http://127.0.0.1:3054/project/${projectId}`, (err, res) => {
if (err) {
return callback(err)
}
expect(res.statusCode).to.equal(204)
callback()
})
export async function deleteProject(projectId) {
const response = await fetchNothing(
`http://127.0.0.1:3054/project/${projectId}`,
{ method: 'DELETE' }
)
expect(response.status).to.equal(204)
}
export const promises = {
initializeProject: promisify(initializeProject),
pushRawUpdate: promisify(pushRawUpdate),
setFirstOpTimestamp: promisify(setFirstOpTimestamp),
getFirstOpTimestamp: promisify(getFirstOpTimestamp),
flushProject: promisify(flushProject),
}

8
services/web/.mocharc.js Normal file
View File

@@ -0,0 +1,8 @@
let reporterOptions = {}
if (process.env.CI && process.env.MOCHA_ROOT_SUITE_NAME) {
reporterOptions = {
reporter: '/overleaf/node_modules/mocha-multi-reporters',
'reporter-options': ['configFile=./test/mocha-multi-reporters.js'],
}
}
module.exports = reporterOptions

View File

@@ -1,17 +1,3 @@
// Initialize variables to signal that a given stage finished.
// We use them to build a graph of interconnected steps/dependencies.
// - Incoming edges use "waitUntil" and reference the given variables of dependencies.
// - Outgoing edges set the given variable to true.
def action_test_frontend_ct_build = false
def action_build_deps = false
def action_copy_external_pages = false
def action_build_dev = false
def action_test_acceptance_app_server_pro = false
def action_build_webpack = false
def action_test_acceptance_app_saas = false
def action_build_pug = false
def action_build_production = false
pipeline {
agent {
node {
@@ -24,8 +10,6 @@ pipeline {
options {
// Print timestamp next to each log line.
timestamps()
// Abort build after hitting first failure.
parallelsAlwaysFailFast()
retry(3)
timeout(time: 15, unit: 'MINUTES')
}
@@ -45,34 +29,8 @@ pipeline {
CDN_PROD = "gs://mgcp-1117973-ol-prod-web-assets-1"
}
stages {
// Retries will use the same pipeline instance. Reset the vars.
stage('Reset vars') {
steps {
script {
action_test_frontend_ct_build = false
action_build_deps = false
action_copy_external_pages = false
action_build_dev = false
action_test_acceptance_app_server_pro = false
action_build_webpack = false
action_test_acceptance_app_saas = false
action_build_pug = false
action_build_production = false
}
}
}
stage ('Build, Test and Push') {
stage('Stage 1') {
parallel {
stage('Copy external pages') {
steps {
dir('services/web') {
sh 'bin/copy_external_pages'
script {
action_copy_external_pages = true
}
}
}
}
stage('Prefetch Tests Images') {
steps {
dir('services/web') {
@@ -80,50 +38,29 @@ pipeline {
}
}
}
stage('Build Deps') {
stage('Build') {
steps {
dir('services/web') {
sh 'bin/copy_external_pages'
sh 'make build_deps'
}
script {
action_build_deps = true
sh 'make build_dev'
sh 'make build_test_frontend_ct'
}
}
}
}
}
stage('Stage 2') {
parallel {
stage('Push Deps') {
steps {
script {
waitUntil {
return action_build_deps
}
}
dir('services/web') {
sh 'docker push ${AR_URL}/${IMAGE_NAME}:${BRANCH_NAME}-deps'
}
}
}
stage('Build Dev') {
stage('Format') {
steps {
script {
waitUntil {
return action_build_deps && action_copy_external_pages
}
}
dir('services/web') {
sh 'make build_dev'
}
script {
action_build_dev = true
}
}
}
stage ('Format') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh 'make format_in_docker'
}
@@ -131,15 +68,16 @@ pipeline {
}
stage('Lint') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh 'make lint_in_docker'
}
}
post {
always {
recordIssues checksAnnotationScope: 'ALL', enabledForFailure: true, failOnError: true, id: 'web-eslint', name: 'Web eslint', qualityGates: [[integerThreshold: 1, threshold: 1.0, type: 'TOTAL']], sourceCodeRetention: 'LAST_BUILD', tools: [esLint(pattern: 'services/web/data/reports/eslint.json')]
recordIssues checksAnnotationScope: 'ALL', enabledForFailure: true, failOnError: true, id: 'web-stylelint', name: 'Web stylelint', qualityGates: [[integerThreshold: 1, threshold: 1.0, type: 'TOTAL']], sourceCodeRetention: 'LAST_BUILD', tools: [styleLint(pattern: 'services/web/data/reports/stylelint.json')]
}
}
}
stage('Shellcheck') {
steps {
@@ -150,26 +88,13 @@ pipeline {
}
stage('Acceptance SaaS') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh 'make test_acceptance_app_saas'
}
script {
action_test_acceptance_app_saas = true
}
}
}
stage('Acceptance Server CE') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_acceptance_app_server_ce"
}
@@ -177,26 +102,13 @@ pipeline {
}
stage('Acceptance Server Pro') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_acceptance_app_server_pro"
}
script {
action_test_acceptance_app_server_pro = true
}
}
}
stage('test_acceptance_modules_merged_saas_1') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_acceptance_modules_merged_saas_1"
}
@@ -204,11 +116,6 @@ pipeline {
}
stage('test_acceptance_modules_merged_saas_2') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_acceptance_modules_merged_saas_2"
}
@@ -216,11 +123,6 @@ pipeline {
}
stage('test_acceptance_modules_merged_saas_3') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_acceptance_modules_merged_saas_3"
}
@@ -228,11 +130,6 @@ pipeline {
}
stage('test_acceptance_modules_merged_saas_4') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_acceptance_modules_merged_saas_4"
}
@@ -240,11 +137,6 @@ pipeline {
}
stage('test_acceptance_modules_merged_server_ce') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_acceptance_modules_merged_server_ce"
}
@@ -252,11 +144,6 @@ pipeline {
}
stage('test_acceptance_modules_merged_server_pro') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_acceptance_modules_merged_server_pro"
}
@@ -264,11 +151,6 @@ pipeline {
}
stage('test_frontend') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_frontend"
}
@@ -276,41 +158,16 @@ pipeline {
}
stage('test_writefull') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_writefull"
}
}
}
stage('test_frontend_ct_build') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make build_test_frontend_ct"
}
script {
action_test_frontend_ct_build = true
}
}
}
stage('test_frontend_ct_core_other') {
environment {
CYPRESS_INTERNAL_BROWSER_CONNECT_TIMEOUT = '120000'
}
steps {
script {
waitUntil {
return action_test_frontend_ct_build
}
}
dir('services/web') {
sh "make test_frontend_ct_core_other"
}
@@ -321,11 +178,6 @@ pipeline {
CYPRESS_INTERNAL_BROWSER_CONNECT_TIMEOUT = '120000'
}
steps {
script {
waitUntil {
return action_test_frontend_ct_build
}
}
dir('services/web') {
sh "make test_frontend_ct_core_features"
}
@@ -336,11 +188,6 @@ pipeline {
CYPRESS_INTERNAL_BROWSER_CONNECT_TIMEOUT = '120000'
}
steps {
script {
waitUntil {
return action_test_frontend_ct_build
}
}
dir('services/web') {
sh "make test_frontend_ct_modules"
}
@@ -348,11 +195,6 @@ pipeline {
}
stage('test_frontend_ct_editor_visual') {
steps {
script {
waitUntil {
return action_test_frontend_ct_build
}
}
dir('services/web') {
sh "make test_frontend_ct_editor_visual"
}
@@ -360,11 +202,6 @@ pipeline {
}
stage('test_frontend_ct_editor_other') {
steps {
script {
waitUntil {
return action_test_frontend_ct_build
}
}
dir('services/web') {
sh "make test_frontend_ct_editor_other"
}
@@ -372,11 +209,6 @@ pipeline {
}
stage('Test Unit ESM') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_unit_esm"
}
@@ -384,90 +216,60 @@ pipeline {
}
stage('Test Unit Mocha') {
steps {
script {
waitUntil {
return action_build_dev
}
}
dir('services/web') {
sh "make test_unit_mocha"
}
}
}
stage('Build Webpack') {
steps {
script {
waitUntil {
return action_build_dev && action_test_acceptance_app_server_pro
stage('Build webpack + production + cdn upload + sentry upload') {
stages {
stage('Wait a bit to give tests all the CPU capacity') {
steps {
sh 'sleep 120'
}
}
dir('services/web') {
sh 'make build_webpack'
}
script {
action_build_webpack = true
}
}
}
stage('Build Pug') {
steps {
script {
waitUntil {
return action_build_dev && action_test_acceptance_app_server_pro
stage('Build Webpack') {
steps {
dir('services/web') {
sh 'make build_webpack'
}
}
}
dir('services/web') {
sh 'make build_pug'
}
script {
action_build_pug = true
}
}
}
stage('CDN Upload Image') {
steps {
script {
waitUntil {
return action_build_webpack
stage('Build Pug') {
steps {
dir('services/web') {
sh 'make build_pug'
}
}
}
dir('services/web') {
sh 'make tar'
sh 'bin/cdn_upload'
}
}
}
stage('Build Production') {
steps {
script {
waitUntil {
return action_build_webpack && action_build_pug
stage('CDN Upload Image') {
steps {
dir('services/web') {
sh 'make tar'
sh 'bin/cdn_upload'
}
}
}
dir('services/web') {
sh 'make build'
}
script {
action_build_production = true
}
}
}
stage('Sentry Upload') {
steps {
script {
waitUntil {
return action_build_webpack && action_build_production
stage('Build Production') {
steps {
dir('services/web') {
sh 'make build'
}
}
}
dir('services/web') {
sh 'gcloud secrets versions access latest --secret=web-sentryclirc > .sentryclirc'
sh 'make sentry_upload'
}
}
post {
cleanup {
dir('services/web') {
sh 'rm -f .sentryclirc'
stage('Sentry Upload') {
steps {
dir('services/web') {
sh 'gcloud secrets versions access latest --secret=web-sentryclirc > .sentryclirc'
sh 'make sentry_upload'
}
}
post {
cleanup {
dir('services/web') {
sh 'rm -f .sentryclirc'
}
}
}
}
}
@@ -483,6 +285,9 @@ pipeline {
}
}
post {
always {
junit checksName: 'Web test results', testResults: 'services/web/data/reports/junit-*.xml'
}
// Ensure tear down of test containers, then run general Jenkins VM cleanup.
cleanup {
dir('services/web') {

View File

@@ -6,6 +6,7 @@ export COMMIT_SHA ?= $(shell git rev-parse HEAD)
PROJECT_NAME = web
BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]')
PWD = $(shell pwd)
export MONOREPO ?= $(shell cd ../../ && pwd)
export OVERLEAF_CONFIG ?= /overleaf/services/web/test/acceptance/config/settings.test.saas.js
export BASE_CONFIG ?= ${OVERLEAF_CONFIG}
@@ -60,6 +61,7 @@ SHARD_PROJECT_NAMES = \
CLEAN_SHARDS=$(addprefix clean/,$(SHARD_PROJECT_NAMES))
clean: $(CLEAN_SHARDS)
-docker run --rm --volume /dev/shm:/dev/shm --user root $(IMAGE_CI) rm -rf /dev/shm/overleaf
-docker rmi --force $(IMAGE_CI) $(IMAGE_CI)-dev $(IMAGE_CI)-pug $(IMAGE_CI)-webpack $(IMAGE_REPO_FINAL)
-docker compose down --remove-orphans --rmi local --timeout 0 --volumes
-git clean -dfX data/
@@ -98,10 +100,12 @@ test_unit_app: export COMPOSE_PROJECT_NAME=unit_test_$(BUILD_DIR_NAME)
test_unit_app: mongo_migrations_for_tests
$(DOCKER_COMPOSE) run --name unit_test_$(BUILD_DIR_NAME) --rm test_unit
test_unit_mocha: export MOCHA_ROOT_SUITE_NAME = Mocha unit tests
test_unit_mocha: export COMPOSE_PROJECT_NAME=unit_test_mocha_$(BUILD_DIR_NAME)
test_unit_mocha: mongo_migrations_for_tests
$(DOCKER_COMPOSE) run --rm test_unit npm run test:unit:mocha
test_unit_esm: export MOCHA_ROOT_SUITE_NAME = ESM unit tests
test_unit_esm: export COMPOSE_PROJECT_NAME=unit_test_esm_$(BUILD_DIR_NAME)
test_unit_esm: mongo_migrations_for_tests
$(DOCKER_COMPOSE) run --rm test_unit npm run test:unit:esm
@@ -148,6 +152,7 @@ mongo_migrations_for_tests:
# Frontend tests
#
test_frontend: export MOCHA_ROOT_SUITE_NAME = JSDOM frontend tests
test_frontend:
COMPOSE_PROJECT_NAME=frontend_test_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) run --rm test_frontend
COMPOSE_PROJECT_NAME=frontend_test_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) down -v -t 0
@@ -169,13 +174,14 @@ TEST_FRONTEND_CT_VARIANTS = \
# Writefull tests
#
test_writefull: export MOCHA_ROOT_SUITE_NAME = Writefull tests
test_writefull:
COMPOSE_PROJECT_NAME=writefull_test_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) run --rm test_writefull
COMPOSE_PROJECT_NAME=writefull_test_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) down -v -t 0
# Note: The below cypress targets are for CI only
build_test_frontend_ct:
docker run --rm --volume /dev/shm:/dev/shm --user root $(IMAGE_CI) bash -ec 'tar -cC / overleaf | tar -xC /dev/shm'
docker run --rm --volume /dev/shm:/dev/shm --user root $(IMAGE_CI) bash -ec 'for path in /overleaf/services/web/cypress/results /overleaf/services/web/node_modules/.cache; do mkdir -p $$path; chown -R node:node $$path; done && tar -cC / overleaf | tar -xC /dev/shm'
test_frontend_ct_core_other: export CYPRESS_RESULTS=./cypress/results/core
test_frontend_ct_core_other: export CYPRESS_SPEC_PATTERN=./test/frontend/**/*.spec.{js,jsx,ts,tsx}
@@ -214,10 +220,13 @@ TEST_ACCEPTANCE_APP := \
test_acceptance_app_server_pro \
test_acceptance_app: $(TEST_ACCEPTANCE_APP)
test_acceptance_app_saas: export MOCHA_ROOT_SUITE_NAME = SaaS app acceptance tests
test_acceptance_app_saas: export COMPOSE_PROJECT_NAME=acceptance_test_saas_$(BUILD_DIR_NAME)
test_acceptance_app_saas: export OVERLEAF_CONFIG=$(CFG_SAAS)
test_acceptance_app_server_ce: export MOCHA_ROOT_SUITE_NAME = Server CE app acceptance tests
test_acceptance_app_server_ce: export COMPOSE_PROJECT_NAME=acceptance_test_server_ce_$(BUILD_DIR_NAME)
test_acceptance_app_server_ce: export OVERLEAF_CONFIG=$(CFG_SERVER_CE)
test_acceptance_app_server_pro: export MOCHA_ROOT_SUITE_NAME = Server Pro app acceptance tests
test_acceptance_app_server_pro: export COMPOSE_PROJECT_NAME=acceptance_test_server_pro_$(BUILD_DIR_NAME)
test_acceptance_app_server_pro: export OVERLEAF_CONFIG=$(CFG_SERVER_PRO)
@@ -349,14 +358,17 @@ $(TEST_ACCEPTANCE_MODULES_MERGED_INNER_SPLIT):
)
# See docs for test_acceptance_server_ce how this works.
test_acceptance_modules_merged_saas: export MOCHA_ROOT_SUITE_NAME = SaaS modules acceptance tests
test_acceptance_modules_merged_saas: export COMPOSE_PROJECT_NAME = \
acceptance_test_modules_merged_saas_$(BUILD_DIR_NAME)
test_acceptance_modules_merged_saas: export BASE_CONFIG = $(CFG_SAAS)
test_acceptance_modules_merged_server_ce: export MOCHA_ROOT_SUITE_NAME = Server CE modules acceptance tests
test_acceptance_modules_merged_server_ce: export COMPOSE_PROJECT_NAME = \
acceptance_test_modules_merged_server_ce_$(BUILD_DIR_NAME)
test_acceptance_modules_merged_server_ce: export BASE_CONFIG = $(CFG_SERVER_CE)
test_acceptance_modules_merged_server_pro: export MOCHA_ROOT_SUITE_NAME = Server Pro modules acceptance tests
test_acceptance_modules_merged_server_pro: export COMPOSE_PROJECT_NAME = \
acceptance_test_modules_merged_server_pro_$(BUILD_DIR_NAME)
test_acceptance_modules_merged_server_pro: export BASE_CONFIG = $(CFG_SERVER_PRO)
@@ -388,6 +400,7 @@ test_acceptance_modules_merged_saas_3: export COMPOSE_PROJECT_NAME = \
test_acceptance_modules_merged_saas_4: export COMPOSE_PROJECT_NAME = \
acceptance_test_modules_merged_saas_4_$(BUILD_DIR_NAME)
$(TEST_ACCEPTANCE_MODULES_MERGED_SPLIT_SAAS): export BASE_CONFIG = $(CFG_SAAS)
$(TEST_ACCEPTANCE_MODULES_MERGED_SPLIT_SAAS): export MOCHA_ROOT_SUITE_NAME = SaaS modules acceptance tests
$(TEST_ACCEPTANCE_MODULES_MERGED_SPLIT_SAAS): test_acceptance_modules_merged_saas_%:
$(DOCKER_COMPOSE) run --rm test_acceptance make test_acceptance_modules_merged_inner_$*
@@ -408,7 +421,7 @@ ci:
#
ORG_PATH = /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
RUN_LINT_FORMAT ?= \
docker run --rm ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER)
docker run --rm --env BRANCH_NAME --env CI --env COMMIT_SHA --env MONOREPO --volume ${PWD}/data/reports:/overleaf/services/web/data/reports ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER)
NODE_MODULES_PATH := ${PATH}:${PWD}/node_modules/.bin:/overleaf/services/web/node_modules/.bin
WITH_NODE_MODULES_PATH = \
@@ -425,11 +438,21 @@ $(WITH_NODE_MODULES_PATH): export PATH=$(NODE_MODULES_PATH)
lint: lint_eslint
lint_eslint:
ifeq ($(CI),true)
-npm run lint -- --format json --output-file data/reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' data/reports/eslint.json
else
npm run lint
endif
lint: lint_stylelint
lint_stylelint:
ifeq ($(CI),true)
-npm run lint:styles -- --formatter json --output-file data/reports/stylelint.json
sed -i 's_"source":"/overleaf_"source":"$(MONOREPO)_g' data/reports/stylelint.json
else
npm run lint:styles
endif
lint: lint_pug
lint_pug:
@@ -589,7 +612,6 @@ SENTRY_IMAGE=getsentry/sentry-cli:2.16.1
sentry_prefetch:
docker pull $(SENTRY_IMAGE)
sentry_upload: MONOREPO=$(shell cd ../../ && pwd)
sentry_upload:
docker run --rm \
--volume $(MONOREPO):$(MONOREPO) --workdir $(PWD) \

View File

@@ -91,6 +91,16 @@ async function editMessage(projectId, threadId, messageId, userId, content) {
)
}
async function editGlobalMessage(projectId, messageId, userId, content) {
await fetchNothing(
chatApiUrl(`/project/${projectId}/messages/${messageId}/edit`),
{
method: 'POST',
json: { content, userId },
}
)
}
async function deleteMessage(projectId, threadId, messageId) {
await fetchNothing(
chatApiUrl(
@@ -109,6 +119,13 @@ async function deleteUserMessage(projectId, threadId, userId, messageId) {
)
}
async function deleteGlobalMessage(projectId, messageId) {
await fetchNothing(
chatApiUrl(`/project/${projectId}/messages/${messageId}`),
{ method: 'DELETE' }
)
}
async function getResolvedThreadIds(projectId) {
const body = await fetchJson(
chatApiUrl(`/project/${projectId}/resolved-thread-ids`)
@@ -154,8 +171,10 @@ module.exports = {
reopenThread: callbackify(reopenThread),
deleteThread: callbackify(deleteThread),
editMessage: callbackify(editMessage),
editGlobalMessage: callbackify(editGlobalMessage),
deleteMessage: callbackify(deleteMessage),
deleteUserMessage: callbackify(deleteUserMessage),
deleteGlobalMessage: callbackify(deleteGlobalMessage),
getResolvedThreadIds: callbackify(getResolvedThreadIds),
duplicateCommentThreads: callbackify(duplicateCommentThreads),
generateThreadData: callbackify(generateThreadData),
@@ -171,8 +190,10 @@ module.exports = {
reopenThread,
deleteThread,
editMessage,
editGlobalMessage,
deleteMessage,
deleteUserMessage,
deleteGlobalMessage,
getResolvedThreadIds,
duplicateCommentThreads,
generateThreadData,

View File

@@ -48,7 +48,48 @@ async function getMessages(req, res) {
res.json(messages)
}
async function deleteMessage(req, res) {
const { project_id: projectId, message_id: messageId } = req.params
const userId = SessionManager.getLoggedInUserId(req.session)
if (userId == null) {
throw new Error('no logged-in user')
}
await ChatApiHandler.promises.deleteGlobalMessage(projectId, messageId)
EditorRealTimeController.emitToRoom(projectId, 'delete-global-message', {
messageId,
userId,
})
res.sendStatus(204)
}
async function editMessage(req, res, next) {
const { project_id: projectId, message_id: messageId } = req.params
const { content } = req.body
const userId = SessionManager.getLoggedInUserId(req.session)
if (userId == null) {
throw new Error('no logged-in user')
}
await ChatApiHandler.promises.editGlobalMessage(
projectId,
messageId,
userId,
content
)
EditorRealTimeController.emitToRoom(projectId, 'edit-global-message', {
messageId,
userId,
content,
})
res.sendStatus(204)
}
export default {
sendMessage: expressify(sendMessage),
getMessages: expressify(getMessages),
deleteMessage: expressify(deleteMessage),
editMessage: expressify(editMessage),
}

View File

@@ -401,6 +401,7 @@ const _ProjectController = {
'client-side-references',
'editor-redesign-new-users',
'writefull-frontend-migration',
'chat-edit-delete',
].filter(Boolean)
const getUserValues = async userId =>

View File

@@ -233,7 +233,7 @@ async function clearTokens(projectId) {
).exec()
}
async function _generateTokens(project, callback) {
async function _generateTokens(project) {
if (!project.tokens) {
project.tokens = {}
}

View File

@@ -979,6 +979,20 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
RateLimiterMiddleware.rateLimit(rateLimiters.sendChatMessage),
ChatController.sendMessage
)
webRouter.delete(
'/project/:project_id/messages/:message_id',
AuthorizationMiddleware.blockRestrictedUserFromProject,
AuthorizationMiddleware.ensureUserCanReadProject,
PermissionsController.requirePermission('chat'),
ChatController.deleteMessage
)
webRouter.post(
'/project/:project_id/messages/:message_id/edit',
AuthorizationMiddleware.blockRestrictedUserFromProject,
AuthorizationMiddleware.ensureUserCanReadProject,
PermissionsController.requirePermission('chat'),
ChatController.editMessage
)
}
webRouter.post(

View File

@@ -7,4 +7,3 @@ web
--esmock-loader=False
--node-version=22.18.0
--public-repo=False
--script-version=4.7.0

View File

@@ -1,11 +1,21 @@
import { defineConfig } from 'cypress'
import { webpackConfig } from './cypress/support/webpack.cypress'
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: '/overleaf/node_modules/cypress-multi-reporters',
reporterOptions: {
configFile: 'cypress/cypress-multi-reporters.json',
},
}
}
export default defineConfig({
fixturesFolder: 'cypress/fixtures',
video: process.env.CYPRESS_VIDEO === 'true',
screenshotsFolder: 'cypress/results',
videosFolder: 'cypress/results',
screenshotsFolder: process.env.CYPRESS_RESULTS || 'cypress/results',
videosFolder: process.env.CYPRESS_RESULTS || 'cypress/results',
viewportHeight: 800,
viewportWidth: 800,
component: {
@@ -25,4 +35,5 @@ export default defineConfig({
retries: {
runMode: 3,
},
...reporterOptions,
})

View File

@@ -0,0 +1,10 @@
{
"reporterEnabled": "spec, mocha-junit-reporter",
"mochaJunitReporterReporterOptions": {
"mochaFile": "data/reports/junit-cypress-[hash].xml",
"includePending": true,
"jenkinsMode": true,
"jenkinsClassnamePrefix": "Web Cypress tests",
"rootSuiteTitle": "Web Cypress tests"
}
}

0
services/web/data/reports/.gitignore vendored Normal file
View File

View File

@@ -13,11 +13,14 @@ services:
user: node
volumes:
- ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it
- ./data/reports:/overleaf/services/web/data/reports
entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 --
command: npm run test:unit:app
working_dir: /overleaf/services/web
env_file: docker-compose.common.env
environment:
CI:
MOCHA_ROOT_SUITE_NAME:
BASE_CONFIG:
OVERLEAF_CONFIG:
NODE_ENV: test
@@ -38,6 +41,8 @@ services:
working_dir: /overleaf/services/web
env_file: docker-compose.common.env
environment:
CI:
MOCHA_ROOT_SUITE_NAME:
BASE_CONFIG:
OVERLEAF_CONFIG:
REDIS_HOST: redis_test
@@ -45,6 +50,7 @@ services:
- 'www.overleaf.test:127.0.0.1'
volumes:
- ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it
- ./data/reports:/overleaf/services/web/data/reports
entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 --
command: npm run test:acceptance:app
user: root
@@ -61,6 +67,8 @@ services:
context: ../..
dockerfile: services/web/Dockerfile
image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER
volumes:
- ./data/reports:/overleaf/services/web/data/reports
logging:
driver: local
user: node
@@ -68,18 +76,23 @@ services:
command: npm run test:frontend
environment:
NODE_OPTIONS: "--unhandled-rejections=strict"
CI:
MOCHA_ROOT_SUITE_NAME:
test_frontend_ct:
image: cypress/included:13.13.2
logging:
driver: local
working_dir: /overleaf/services/web
user: "${DOCKER_USER:-1000:1000}"
environment:
CYPRESS_SPEC_PATTERN: ${CYPRESS_SPEC_PATTERN:-}
CYPRESS_EXCLUDE_SPEC_PATTERN: ${CYPRESS_EXCLUDE_SPEC_PATTERN:-}
CI:
CYPRESS_RESULTS:
CYPRESS_SPEC_PATTERN:
CYPRESS_EXCLUDE_SPEC_PATTERN:
volumes:
- ${CYPRESS_RESULTS:-./cypress/results}:/overleaf/services/web/cypress/results/
- /dev/shm/overleaf:/overleaf
- ./data/reports:/overleaf/services/web/data/reports
entrypoint: npm
command:
- "run"
@@ -95,7 +108,11 @@ services:
user: node
working_dir: /overleaf/services/web
command: npm run test:writefull
volumes:
- ./data/reports:/overleaf/services/web/data/reports
environment:
CI:
MOCHA_ROOT_SUITE_NAME:
NODE_OPTIONS: "--unhandled-rejections=strict"
tar:

View File

@@ -15,6 +15,8 @@ services:
environment:
BASE_CONFIG:
OVERLEAF_CONFIG:
CI:
MOCHA_ROOT_SUITE_NAME:
MOCHA_GREP: ${MOCHA_GREP}
LOG_LEVEL: ${LOG_LEVEL:-}
NODE_ENV: test
@@ -40,6 +42,8 @@ services:
environment:
BASE_CONFIG:
OVERLEAF_CONFIG:
CI:
MOCHA_ROOT_SUITE_NAME:
MOCHA_GREP: ${MOCHA_GREP}
LOG_LEVEL: ${LOG_LEVEL:-}
MONGO_SERVER_SELECTION_TIMEOUT: 600000
@@ -68,6 +72,8 @@ services:
- ../../libraries:/overleaf/libraries
working_dir: /overleaf/services/web
environment:
CI:
MOCHA_ROOT_SUITE_NAME:
MOCHA_GREP: ${MOCHA_GREP}
NODE_OPTIONS: "--unhandled-rejections=strict"
VERBOSE_LOGGING:
@@ -79,8 +85,11 @@ services:
volumes:
- ../../:/overleaf
working_dir: /overleaf/services/web
user: "${DOCKER_USER:-1000:1000}"
environment:
VERBOSE_LOGGING:
CI:
MOCHA_ROOT_SUITE_NAME:
CYPRESS_SPEC_PATTERN: ${CYPRESS_SPEC_PATTERN:-}
CYPRESS_EXCLUDE_SPEC_PATTERN: ${CYPRESS_EXCLUDE_SPEC_PATTERN:-}
entrypoint: npm
@@ -99,6 +108,8 @@ services:
- ../../libraries:/overleaf/libraries
working_dir: /overleaf/services/web
environment:
CI:
MOCHA_ROOT_SUITE_NAME:
NODE_OPTIONS: "--unhandled-rejections=strict"
VERBOSE_LOGGING:
command: npm run --silent test:writefull

View File

@@ -397,6 +397,8 @@
"delete_comment_thread": "",
"delete_comment_thread_message": "",
"delete_figure": "",
"delete_message": "",
"delete_message_confirmation": "",
"delete_projects": "",
"delete_row_or_column": "",
"delete_sso_config": "",
@@ -503,6 +505,7 @@
"edit_tag": "",
"edit_tag_name": "",
"edit_your_custom_dictionary": "",
"edited": "",
"editing": "",
"editing_captions": "",
"editing_tools": "",

View File

@@ -43,10 +43,7 @@ const ChatPane = React.memo(function ChatPane() {
const shouldDisplayPlaceholder = status !== 'pending' && messages.length === 0
const messageContentCount = messages.reduce(
(acc, { contents }) => acc + contents.length,
0
)
const messageContentCount = messages.length
// Keep the chat pane in the DOM to avoid resetting the form input and re-rendering MathJax content.
const [chatOpenedOnce, setChatOpenedOnce] = useState(chatIsOpen)

View File

@@ -0,0 +1,40 @@
import {
Message as MessageType,
useChatContext,
} from '@/features/chat/context/chat-context'
import classNames from 'classnames'
import MessageDropdown from '@/features/chat/components/message-dropdown'
import MessageContent from '@/features/chat/components/message-content'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export function MessageAndDropdown({
message,
fromSelf,
}: {
message: MessageType
fromSelf: boolean
}) {
const { idOfMessageBeingEdited } = useChatContext()
const hasChatEditDelete = useFeatureFlag('chat-edit-delete')
const editing = idOfMessageBeingEdited === message.id
return (
<div
className={classNames('message-and-dropdown', {
'pending-message': message.pending,
})}
>
{hasChatEditDelete && fromSelf && !message.pending && !editing ? (
<MessageDropdown message={message} />
) : null}
<div className="message-content">
<MessageContent
content={message.content}
messageId={message.id}
edited={message.edited}
/>
</div>
</div>
)
}

View File

@@ -1,12 +1,26 @@
import { useRef, useEffect, type FC } from 'react'
import { useRef, useEffect, type FC, useCallback, useState } from 'react'
import Linkify from 'react-linkify'
import useIsMounted from '../../../shared/hooks/use-is-mounted'
import { loadMathJax } from '../../mathjax/load-mathjax'
import { debugConsole } from '@/utils/debugging'
import { Message, useChatContext } from '@/features/chat/context/chat-context'
import OLButton from '@/shared/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
const MessageContent: FC<{ content: string }> = ({ content }) => {
const MessageContent: FC<{
content: Message['content']
messageId: Message['id']
edited: Message['edited']
}> = ({ content, messageId, edited }) => {
const { t } = useTranslation()
const root = useRef<HTMLDivElement | null>(null)
const mounted = useIsMounted()
const { idOfMessageBeingEdited, cancelMessageEdit, editMessage } =
useChatContext()
const [editedContent, setEditedContent] = useState(content)
const editing = idOfMessageBeingEdited === messageId
useEffect(() => {
if (root.current) {
@@ -33,9 +47,70 @@ const MessageContent: FC<{ content: string }> = ({ content }) => {
}
}, [content, mounted])
return (
const completeEdit = useCallback(() => {
editMessage(messageId, editedContent)
}, [editMessage, editedContent, messageId])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
completeEdit()
} else if (e.key === 'Escape') {
e.preventDefault()
cancelMessageEdit()
setEditedContent(content)
}
},
[cancelMessageEdit, completeEdit, content]
)
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditedContent(e.target.value)
},
[]
)
const handleAutoFocus = useCallback(
(textarea: HTMLTextAreaElement) => textarea.select(),
[]
)
return editing ? (
<>
<AutoExpandingTextArea
value={editedContent}
style={{ width: '100%' }}
onKeyDown={handleKeyDown}
onChange={handleChange}
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
onAutoFocus={handleAutoFocus}
/>
<br />
<OLButton
size="sm"
variant="secondary"
onClick={() => {
cancelMessageEdit()
setEditedContent(content)
}}
>
{t('cancel')}
</OLButton>
<OLButton size="sm" variant="secondary" onClick={() => completeEdit()}>
{t('save')}
</OLButton>
</>
) : (
<p ref={root} translate="no">
<Linkify>{content}</Linkify>
{edited ? (
<>
{' '}
<span className="message-edited">({t('edited')})</span>
</>
) : null}
</p>
)
}

View File

@@ -0,0 +1,59 @@
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import DropdownListItem from '@/shared/components/dropdown/dropdown-list-item'
import { Message, useChatContext } from '@/features/chat/context/chat-context'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { useCallback } from 'react'
export default function MessageDropdown({ message }: { message: Message }) {
const { t } = useTranslation()
const { deleteMessage, startedEditingMessage } = useChatContext()
const { showGenericConfirmModal } = useModalsContext()
const deleteButtonHandler = useCallback(() => {
showGenericConfirmModal({
title: t('delete_message'),
message: t('delete_message_confirmation'),
onConfirm: () => {
deleteMessage(message.id)
},
})
}, [deleteMessage, message.id, showGenericConfirmModal, t])
const editButtonHandler = useCallback(() => {
startedEditingMessage(message.id)
}, [message.id, startedEditingMessage])
return (
<Dropdown align="end" className="message-dropdown float-end">
<DropdownToggle bsPrefix="message-dropdown-menu-btn">
<MaterialIcon type="more_vert" accessibilityLabel={t('actions')} />
</DropdownToggle>
<DropdownMenu
className="message-dropdown-menu"
// Make the dropdown appear overlap with the button slightly so that the
// menu stays visible when the user moves their cursor into the menu
// when the menu is positioned above the button
popperConfig={{
modifiers: [{ name: 'offset', options: { offset: [0, -3] } }],
}}
>
<DropdownListItem>
<DropdownItem as="button" onClick={editButtonHandler}>
{t('edit')}
</DropdownItem>
<DropdownItem as="button" onClick={deleteButtonHandler}>
{t('delete')}
</DropdownItem>
</DropdownListItem>
</DropdownMenu>
</Dropdown>
)
}

View File

@@ -0,0 +1,62 @@
import { getHueForUserId } from '@/shared/utils/colors'
import type { Message as MessageType } from '@/features/chat/context/chat-context'
import { User } from '../../../../../types/user'
import classNames from 'classnames'
import { MessageAndDropdown } from '@/features/chat/components/message-and-dropdown'
import { useTranslation } from 'react-i18next'
export interface MessageGroupProps {
messages: MessageType[]
user?: User
fromSelf: boolean
}
function hue(user?: User) {
return user ? getHueForUserId(user.id) : 0
}
function getMessageStyle(user?: User) {
return {
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
backgroundColor: `hsl(${hue(user)}, 85%, 40%`,
}
}
function getArrowStyle(user?: User) {
return {
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
}
}
function MessageGroup({ messages, user, fromSelf }: MessageGroupProps) {
const { t } = useTranslation()
return (
<div
className={classNames('message-wrapper', {
'own-message-wrapper': fromSelf,
})}
>
{!fromSelf && (
<div className="name" translate="no">
<span>
{user ? user.first_name || user.email : t('deleted_user')}
</span>
</div>
)}
<div className="message" style={getMessageStyle(user)}>
{!fromSelf && <div className="arrow" style={getArrowStyle(user)} />}
{messages.map(message => (
<MessageAndDropdown
key={message.id}
message={message}
fromSelf={fromSelf}
/>
))}
</div>
</div>
)
}
export default MessageGroup

View File

@@ -1,10 +1,12 @@
import moment from 'moment'
import Message from './message'
import type { Message as MessageType } from '@/features/chat/context/chat-context'
import MessageRedesign from '@/features/ide-redesign/components/chat/message'
import { useUserContext } from '@/shared/context/user-context'
import { User } from '../../../../../types/user'
import MessageGroup from '@/features/chat/components/message-group'
import MessageGroupRedesign from '@/features/ide-redesign/components/chat/message-group'
const FIVE_MINUTES = 5 * 60 * 1000
const TIMESTAMP_GROUP_SIZE = FIVE_MINUTES
function formatTimestamp(date: moment.MomentInput) {
if (!date) {
@@ -20,13 +22,56 @@ interface MessageListProps {
newDesign?: boolean
}
type MessageGroupType = {
messages: MessageType[]
id: string
user?: User
}
// Group messages by the same author that were sent within 5 minutes of each
// other
function groupMessages(messages: MessageType[]) {
const groups: MessageGroupType[] = []
let currentGroup: MessageGroupType | null = null
let previousMessage: MessageType | null = null
for (const message of messages) {
if (message.deleted) {
continue
}
if (
currentGroup &&
previousMessage &&
!message.pending &&
message.user &&
message.user.id &&
message.user.id === previousMessage.user?.id &&
message.timestamp - previousMessage.timestamp < TIMESTAMP_GROUP_SIZE
) {
currentGroup.messages.push(message)
} else {
currentGroup = {
messages: [message],
id: String(message.timestamp),
user: message.user,
}
groups.push(currentGroup)
}
previousMessage = message
}
return groups
}
function MessageList({
messages,
resetUnreadMessages,
newDesign,
}: MessageListProps) {
const user = useUserContext()
const MessageComponent = newDesign ? MessageRedesign : Message
const MessageGroupComponent = newDesign ? MessageGroupRedesign : MessageGroup
function shouldRenderDate(messageIndex: number) {
if (messageIndex === 0) {
return true
@@ -41,6 +86,8 @@ function MessageList({
}
}
const messageGroups = groupMessages(messages)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<ul
@@ -48,25 +95,25 @@ function MessageList({
onClick={resetUnreadMessages}
onKeyDown={resetUnreadMessages}
>
{messages.map((message, index) => (
// new messages are added to the beginning of the list, so we use a reversed index
<li key={message.id} className="message">
{messageGroups.map((group, index) => (
<li key={group.id} className="message">
{shouldRenderDate(index) && (
<div className="date">
<time
dateTime={
message.timestamp
? moment(message.timestamp).format()
group.messages[0].timestamp
? moment(group.messages[0].timestamp).format()
: undefined
}
>
{formatTimestamp(message.timestamp)}
{formatTimestamp(group.messages[0].timestamp)}
</time>
</div>
)}
<MessageComponent
message={message}
fromSelf={message.user ? message.user.id === user.id : false}
<MessageGroupComponent
messages={group.messages}
user={group.user}
fromSelf={user ? group.user?.id === user.id : false}
/>
</li>
))}

View File

@@ -1,56 +0,0 @@
import { getHueForUserId } from '@/shared/utils/colors'
import MessageContent from './message-content'
import type { Message as MessageType } from '@/features/chat/context/chat-context'
import { User } from '../../../../../types/user'
import { useTranslation } from 'react-i18next'
export interface MessageProps {
message: MessageType
fromSelf: boolean
}
function hue(user?: User) {
return user ? getHueForUserId(user.id) : 0
}
function getMessageStyle(user?: User) {
return {
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
backgroundColor: `hsl(${hue(user)}, 85%, 40%`,
}
}
function getArrowStyle(user?: User) {
return {
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
}
}
function Message({ message, fromSelf }: MessageProps) {
const { t } = useTranslation()
return (
<div className="message-wrapper">
{!fromSelf && (
<div className="name" translate="no">
<span>
{message.user
? message.user.first_name || message.user.email
: t('deleted_user')}
</span>
</div>
)}
<div className="message" style={getMessageStyle(message.user)}>
{!fromSelf && (
<div className="arrow" style={getArrowStyle(message.user)} />
)}
<div className="message-content">
{message.contents.map((content, index) => (
<MessageContent key={index} content={content} />
))}
</div>
</div>
</div>
)
}
export default Message

View File

@@ -11,8 +11,18 @@ import {
import clientIdGenerator from '@/utils/client-id'
import { useUserContext } from '../../../shared/context/user-context'
import { useProjectContext } from '../../../shared/context/project-context'
import { getJSON, postJSON } from '../../../infrastructure/fetch-json'
import { appendMessage, prependMessages } from '../utils/message-list-appender'
import {
deleteJSON,
getJSON,
postJSON,
} from '../../../infrastructure/fetch-json'
import {
appendMessage,
confirmMessage,
deleteMessage,
editMessage,
prependMessages,
} from '../utils/message-list-utils'
import useBrowserWindow from '../../../shared/hooks/use-browser-window'
import { useLayoutContext } from '../../../shared/context/layout-context'
import { useIdeContext } from '@/shared/context/ide-context'
@@ -27,12 +37,16 @@ const PAGE_SIZE = 50
export type Message = {
id: string
timestamp: number
contents: string[]
content: string
user?: User
edited?: boolean
deleted?: boolean
pending?: boolean
}
export type ServerMessageEntry = Omit<Message, 'contents'> & {
export type ServerMessageEntry = Message & {
content: string
edited_at?: number
}
type State = {
@@ -44,6 +58,7 @@ type State = {
unreadMessageCount: number
error?: Error | null
uniqueMessageIds: string[]
idOfMessageBeingEdited: Message['id'] | null
}
type Action =
@@ -66,9 +81,29 @@ type Action =
type: 'RECEIVE_MESSAGE'
message: ServerMessageEntry
}
| {
type: 'RECEIVE_OWN_MESSAGE'
message: any
}
| {
type: 'MARK_MESSAGES_AS_READ'
}
| {
type: 'DELETE_MESSAGE'
messageId: Message['id']
}
| {
type: 'START_EDITING_MESSAGE'
messageId: Message['id']
}
| {
type: 'CANCEL_MESSAGE_EDIT'
}
| {
type: 'RECEIVE_MESSAGE_EDIT'
messageId: Message['id']
content: string
}
| {
type: 'CLEAR'
}
@@ -125,6 +160,7 @@ function chatReducer(state: State, action: Action): State {
user: action.user,
content: action.content,
timestamp: Date.now(),
pending: true,
},
state.uniqueMessageIds
),
@@ -141,6 +177,36 @@ function chatReducer(state: State, action: Action): State {
unreadMessageCount: state.unreadMessageCount + 1,
}
case 'RECEIVE_OWN_MESSAGE':
return {
...state,
...confirmMessage(action.message, state.messages),
}
case 'DELETE_MESSAGE':
return {
...state,
...deleteMessage(action.messageId, state.messages),
}
case 'START_EDITING_MESSAGE':
return {
...state,
idOfMessageBeingEdited: action.messageId,
}
case 'CANCEL_MESSAGE_EDIT':
return {
...state,
idOfMessageBeingEdited: null,
}
case 'RECEIVE_MESSAGE_EDIT':
return {
...state,
...editMessage(action.messageId, action.content, state.messages),
}
case 'MARK_MESSAGES_AS_READ':
return {
...state,
@@ -171,6 +237,7 @@ const initialState: State = {
unreadMessageCount: 0,
error: null,
uniqueMessageIds: [],
idOfMessageBeingEdited: null,
}
export const ChatContext = createContext<
@@ -180,10 +247,15 @@ export const ChatContext = createContext<
initialMessagesLoaded: boolean
atEnd: boolean
unreadMessageCount: number
idOfMessageBeingEdited: State['idOfMessageBeingEdited']
loadInitialMessages: () => void
loadMoreMessages: () => void
sendMessage: (message: any) => void
markMessagesAsRead: () => void
deleteMessage: (messageId: Message['id']) => void
startedEditingMessage: (messageId: Message['id']) => void
cancelMessageEdit: () => void
editMessage: (messageId: Message['id'], content: string) => void
reset: () => void
error?: Error | null
}
@@ -314,6 +386,32 @@ export const ChatProvider: FC<React.PropsWithChildren> = ({ children }) => {
[chatEnabled, projectId, user]
)
const startedEditingMessage = useCallback(
(messageId: Message['id']) => {
if (!chatEnabled) {
debugConsole.warn(`chat is disabled, won't send message`)
return
}
dispatch({
type: 'START_EDITING_MESSAGE',
messageId,
})
},
[chatEnabled]
)
const cancelMessageEdit = useCallback(() => {
if (!chatEnabled) {
debugConsole.warn(`chat is disabled, won't cancel message edit`)
return
}
dispatch({
type: 'CANCEL_MESSAGE_EDIT',
})
}, [chatEnabled])
const markMessagesAsRead = useCallback(() => {
if (!chatEnabled) {
debugConsole.warn(`chat is disabled, won't mark messages as read`)
@@ -322,26 +420,114 @@ export const ChatProvider: FC<React.PropsWithChildren> = ({ children }) => {
dispatch({ type: 'MARK_MESSAGES_AS_READ' })
}, [chatEnabled])
// Handling receiving messages over the socket
const deleteMessage = useCallback(
(messageId: Message['id']) => {
if (!chatEnabled) {
debugConsole.warn(`chat is disabled, won't delete message`)
return
}
if (!messageId) return
dispatch({
type: 'DELETE_MESSAGE',
messageId,
})
deleteJSON(`/project/${projectId}/messages/${messageId}`).catch(error => {
dispatch({
type: 'ERROR',
error,
})
})
},
[chatEnabled, projectId]
)
const editMessage = useCallback(
(messageId: Message['id'], content: string) => {
if (!chatEnabled) {
debugConsole.warn(`chat is disabled, won't edit message`)
return
}
if (!messageId || !content) return
dispatch({
type: 'RECEIVE_MESSAGE_EDIT',
messageId,
content,
})
dispatch({
type: 'CANCEL_MESSAGE_EDIT',
})
postJSON(`/project/${projectId}/messages/${messageId}/edit`, {
body: { content },
}).catch(error => {
dispatch({
type: 'ERROR',
error,
})
})
},
[chatEnabled, projectId]
)
// Handling receiving and deleting messages over the socket
const { socket } = useIdeContext()
useEffect(() => {
if (!chatEnabled || !socket) return
function receivedMessage(message: any) {
// If the message is from the current client id, then we are receiving the sent message back from the socket.
// If the message is from the current client id, then we are receiving the
// sent message back from the socket. In this case, we want to update the
// message in our local state with the ID of the message on the server.
// Ignore it to prevent double message.
if (message.clientId === clientId.current) return
if (message.clientId === clientId.current) {
dispatch({ type: 'RECEIVE_OWN_MESSAGE', message })
} else {
dispatch({ type: 'RECEIVE_MESSAGE', message })
}
}
dispatch({ type: 'RECEIVE_MESSAGE', message })
function deletedMessage(message: {
messageId: Message['id']
userId: User['id']
}) {
if (message.userId === user.id) return
dispatch({
type: 'DELETE_MESSAGE',
messageId: message.messageId,
})
}
function editedMessage(message: {
messageId: Message['id']
userId: User['id']
content: string
}) {
if (message.userId === user.id) return
dispatch({
type: 'RECEIVE_MESSAGE_EDIT',
messageId: message.messageId,
content: message.content,
})
}
socket.on('new-chat-message', receivedMessage)
socket.on('delete-global-message', deletedMessage)
socket.on('edit-global-message', editedMessage)
return () => {
if (!socket) return
socket.removeListener('new-chat-message', receivedMessage)
socket.removeListener('delete-global-message', deletedMessage)
socket.removeListener('edit-global-message', editedMessage)
}
}, [chatEnabled, socket])
}, [chatEnabled, socket, user.id])
// Handle unread messages
useEffect(() => {
@@ -370,17 +556,26 @@ export const ChatProvider: FC<React.PropsWithChildren> = ({ children }) => {
initialMessagesLoaded: state.initialMessagesLoaded,
atEnd: state.atEnd,
unreadMessageCount: state.unreadMessageCount,
idOfMessageBeingEdited: state.idOfMessageBeingEdited,
loadInitialMessages,
loadMoreMessages,
reset,
sendMessage,
markMessagesAsRead,
deleteMessage,
startedEditingMessage,
cancelMessageEdit,
editMessage,
error: state.error,
}),
[
loadInitialMessages,
loadMoreMessages,
markMessagesAsRead,
deleteMessage,
startedEditingMessage,
cancelMessageEdit,
editMessage,
reset,
sendMessage,
state.atEnd,
@@ -389,6 +584,7 @@ export const ChatProvider: FC<React.PropsWithChildren> = ({ children }) => {
state.messages,
state.status,
state.unreadMessageCount,
state.idOfMessageBeingEdited,
]
)

View File

@@ -1,91 +0,0 @@
import { Message, ServerMessageEntry } from '../context/chat-context'
const TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 // 5 minutes
export function appendMessage(
messageList: Message[],
message: ServerMessageEntry,
uniqueMessageIds: string[]
) {
if (uniqueMessageIds.includes(message.id)) {
return { messages: messageList, uniqueMessageIds }
}
uniqueMessageIds = uniqueMessageIds.slice(0)
uniqueMessageIds.push(message.id)
const lastMessage = messageList[messageList.length - 1]
const shouldGroup =
lastMessage &&
message &&
message.user &&
lastMessage.user &&
message.user.id &&
message.user.id === lastMessage.user.id &&
message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
if (shouldGroup) {
messageList = messageList.slice(0, messageList.length - 1).concat({
...lastMessage,
// the `id` is updated to the latest received content when a new
// message is appended or prepended
id: message.id,
timestamp: message.timestamp,
contents: lastMessage.contents.concat(message.content),
})
} else {
messageList = messageList.slice(0).concat({
id: message.id,
user: message.user,
timestamp: message.timestamp,
contents: [message.content],
})
}
return { messages: messageList, uniqueMessageIds }
}
export function prependMessages(
messageList: Message[],
messages: ServerMessageEntry[],
uniqueMessageIds: string[]
) {
const listCopy = messageList.slice(0)
uniqueMessageIds = uniqueMessageIds.slice(0)
messages
.slice(0)
.reverse()
.forEach(message => {
if (uniqueMessageIds.includes(message.id)) {
return
}
uniqueMessageIds.push(message.id)
const firstMessage = listCopy[0]
const shouldGroup =
firstMessage &&
message &&
firstMessage.user &&
message.user &&
message.user.id === firstMessage.user.id &&
firstMessage.timestamp - message.timestamp < TIMESTAMP_GROUP_SIZE
if (shouldGroup) {
firstMessage.id = message.id
firstMessage.timestamp = message.timestamp
firstMessage.contents = [message.content].concat(firstMessage.contents)
} else {
listCopy.unshift({
id: message.id,
user: message.user,
timestamp: message.timestamp,
contents: [message.content],
})
}
})
return { messages: listCopy, uniqueMessageIds }
}

View File

@@ -0,0 +1,134 @@
import { Message, ServerMessageEntry } from '../context/chat-context'
export function appendMessage(
messageList: Message[],
message: ServerMessageEntry,
uniqueMessageIds: string[]
) {
if (uniqueMessageIds.includes(message.id)) {
return { messages: messageList, uniqueMessageIds }
}
uniqueMessageIds = uniqueMessageIds.slice(0)
uniqueMessageIds.push(message.id)
messageList = messageList.slice(0).concat({
id: message.id,
user: message.user,
timestamp: message.timestamp,
content: message.content,
pending: message.pending,
edited: Boolean(message.edited_at),
})
return { messages: messageList, uniqueMessageIds }
}
export function prependMessages(
messageList: Message[],
messages: ServerMessageEntry[],
uniqueMessageIds: string[]
) {
const listCopy = messageList.slice(0)
uniqueMessageIds = uniqueMessageIds.slice(0)
messages
.slice(0)
.reverse()
.forEach(message => {
if (uniqueMessageIds.includes(message.id)) {
return
}
uniqueMessageIds.push(message.id)
listCopy.unshift({
id: message.id,
user: message.user,
timestamp: message.timestamp,
content: message.content,
edited: Boolean(message.edited_at),
})
})
return { messages: listCopy, uniqueMessageIds }
}
export function confirmMessage(
updatedMessage: Message,
messageList: Message[]
) {
// Find our message and change its ID from the temporary one we generated
// on creation to the ID assigned to it by the server. This is so that the
// message can be deleted later, for which we need the server ID.
const ownMessageIndex = messageList.findIndex(
message => message.pending && message.content === updatedMessage.content
)
if (ownMessageIndex === -1) {
throw new Error("Couldn't find own message in local state")
}
const messageWithOldId = messageList[ownMessageIndex]
const newMessageList = [...messageList]
newMessageList.splice(ownMessageIndex, 1, {
...messageWithOldId,
pending: false,
id: updatedMessage.id,
user: updatedMessage.user,
timestamp: updatedMessage.timestamp,
content: updatedMessage.content,
})
return {
messages: newMessageList,
uniqueMessageIds: Array.from(
new Set(newMessageList.map(message => message.id))
),
}
}
export function deleteMessage(messageId: string, messageList: Message[]) {
const messageIndex = messageList.findIndex(
message => message.id === messageId
)
if (messageIndex === -1) {
throw new Error(`Message with id ${messageId} not found in message list`)
}
const newMessageList = [...messageList]
const message = newMessageList[messageIndex]
newMessageList.splice(messageIndex, 1, {
...message,
deleted: true,
})
return {
messages: newMessageList,
}
}
export function editMessage(
messageId: string,
content: string,
messageList: Message[]
) {
const messageIndex = messageList.findIndex(
message => message.id === messageId
)
if (messageIndex === -1) {
throw new Error(`Message with id ${messageId} not found in message list`)
}
const newMessageList = [...messageList]
const message = newMessageList[messageIndex]
newMessageList.splice(messageIndex, 1, {
...message,
content,
edited: true,
})
return {
messages: newMessageList,
}
}

View File

@@ -48,11 +48,6 @@ export const ChatPane = () => {
const shouldDisplayPlaceholder = status !== 'pending' && messages.length === 0
const messageContentCount = messages.reduce(
(acc, { contents }) => acc + contents.length,
0
)
if (error) {
// let user try recover from fetch errors
if (error instanceof FetchError) {
@@ -75,7 +70,7 @@ export const ChatPane = () => {
className="messages"
fetchData={loadMoreMessages}
isLoading={status === 'pending'}
itemCount={messageContentCount}
itemCount={messages.length}
>
<div className={classNames({ 'h-100': shouldDisplayPlaceholder })}>
<h2 className="visually-hidden">{t('chat')}</h2>

View File

@@ -0,0 +1,92 @@
import type { Message } from '@/features/chat/context/chat-context'
import { User } from '../../../../../../types/user'
import {
getBackgroundColorForUserId,
hslStringToLuminance,
} from '@/shared/utils/colors'
import MessageContent from '@/features/chat/components/message-content'
import classNames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
import MessageDropdown from '@/features/chat/components/message-dropdown'
import { useFeatureFlag } from '@/shared/context/split-test-context'
function getAvatarStyle(user?: User) {
if (!user?.id) {
// Deleted user
return {
backgroundColor: 'var(--bg-light-disabled)',
borderColor: 'var(--bg-light-disabled)',
color: 'var(--content-disabled)',
}
}
const backgroundColor = getBackgroundColorForUserId(user.id)
return {
borderColor: backgroundColor,
backgroundColor,
color:
hslStringToLuminance(backgroundColor) < 0.5
? 'var(--content-primary-dark)'
: 'var(--content-primary)',
}
}
export function MessageAndDropdown({
message,
fromSelf,
isLast,
isFirst,
}: {
message: Message
fromSelf: boolean
isLast: boolean
isFirst: boolean
}) {
const hasChatEditDelete = useFeatureFlag('chat-edit-delete')
return (
<div className="message-row">
<>
{!fromSelf && isLast ? (
<div className="message-avatar">
<div className="avatar" style={getAvatarStyle(message.user)}>
{message.user?.id && message.user.email ? (
message.user.first_name?.charAt(0) ||
message.user.email.charAt(0)
) : (
<MaterialIcon
type="delete"
className="message-avatar-deleted-user-icon"
/>
)}
</div>
</div>
) : (
<div className="message-avatar-placeholder" />
)}
<div
className={classNames('message-container', {
'message-from-self': fromSelf,
'first-row-in-message': isFirst,
'last-row-in-message': isLast,
'pending-message': message.pending,
})}
>
<div>
{hasChatEditDelete && fromSelf ? (
<MessageDropdown message={message} />
) : null}
</div>
<div className="message-content">
<MessageContent
content={message.content}
messageId={message.id}
edited={message.edited}
/>
</div>
</div>
</>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { MessageGroupProps } from '@/features/chat/components/message-group'
import { MessageAndDropdown } from './message-and-dropdown'
import { useTranslation } from 'react-i18next'
function MessageGroup({ messages, user, fromSelf }: MessageGroupProps) {
const { t } = useTranslation()
return (
<div className="chat-message-redesign">
<div>
<div className="message-row">
<div className="message-avatar-placeholder" />
{!fromSelf && (
<div className="message-author">
<span>
{user?.id && user.email
? user.first_name || user.email
: t('deleted_user')}
</span>
</div>
)}
</div>
</div>
{messages.map((message, index) => {
const nonDeletedMessages = messages.filter(m => !m.deleted)
const nonDeletedIndex = nonDeletedMessages.findIndex(
m => m.id === message.id
)
return (
<MessageAndDropdown
key={index}
message={message}
fromSelf={fromSelf}
isLast={nonDeletedIndex === nonDeletedMessages.length - 1}
isFirst={nonDeletedIndex === 0}
/>
)
})}
</div>
)
}
export default MessageGroup

View File

@@ -1,87 +0,0 @@
import { MessageProps } from '@/features/chat/components/message'
import { User } from '../../../../../../types/user'
import {
getBackgroundColorForUserId,
hslStringToLuminance,
} from '@/shared/utils/colors'
import MessageContent from '@/features/chat/components/message-content'
import classNames from 'classnames'
import MaterialIcon from '@/shared/components/material-icon'
import { t } from 'i18next'
function getAvatarStyle(user?: User) {
if (!user?.id) {
// Deleted user
return {
backgroundColor: 'var(--bg-light-disabled)',
borderColor: 'var(--bg-light-disabled)',
color: 'var(--content-disabled)',
}
}
const backgroundColor = getBackgroundColorForUserId(user.id)
return {
borderColor: backgroundColor,
backgroundColor,
color:
hslStringToLuminance(backgroundColor) < 0.5
? 'var(--content-primary-dark)'
: 'var(--content-primary)',
}
}
function Message({ message, fromSelf }: MessageProps) {
return (
<div className="chat-message-redesign">
<div className="message-row">
<div className="message-avatar-placeholder" />
{!fromSelf && (
<div className="message-author">
<span>
{message.user?.id && message.user.email
? message.user.first_name || message.user.email
: t('deleted_user')}
</span>
</div>
)}
</div>
{message.contents.map((content, index) => (
<div key={index} className="message-row">
<>
{!fromSelf && index === message.contents.length - 1 ? (
<div className="message-avatar">
<div className="avatar" style={getAvatarStyle(message.user)}>
{message.user?.id && message.user.email ? (
message.user.first_name?.charAt(0) ||
message.user.email.charAt(0)
) : (
<MaterialIcon
type="delete"
className="message-avatar-deleted-user-icon"
/>
)}
</div>
</div>
) : (
<div className="message-avatar-placeholder" />
)}
<div
className={classNames('message-container', {
'message-from-self': fromSelf,
'first-row-in-message': index === 0,
'last-row-in-message': index === message.contents.length - 1,
})}
>
<div className="message-content">
<MessageContent content={content} />
</div>
</div>
</>
</div>
))}
</div>
)
}
export default Message

View File

@@ -105,7 +105,7 @@ function AutoExpandingTextArea({
}
}, [onResize])
// Maintain a copy onAutoFocus in a ref for use in the autofocus effect
// Maintain a copy of onAutoFocus in a ref for use in the autofocus effect
// below so that the effect doesn't run when onAutoFocus changes
const onAutoFocusRef = useRef(onAutoFocus)
useEffect(() => {

View File

@@ -1,5 +1,6 @@
import type { ElementType, ReactNode, PropsWithChildren } from 'react'
import type { ButtonProps } from '@/shared/components/types/button-props'
import type { DropdownMenuProps as BS5DropdownMenuProps } from 'react-bootstrap'
type SplitButtonVariants = Extract<
ButtonProps['variant'],
@@ -71,6 +72,7 @@ export type DropdownMenuProps = PropsWithChildren<{
flip?: boolean
id?: string
renderOnMount?: boolean
popperConfig?: BS5DropdownMenuProps['popperConfig']
}>
export type DropdownDividerProps = PropsWithChildren<{

View File

@@ -289,6 +289,7 @@ export interface Meta {
domainCapture?: boolean
}
'ol-subscriptionId': string
'ol-subscriptionPaymentErrorCode': string | null
'ol-suggestedLanguage': SuggestedLanguage | undefined
'ol-survey': Survey | undefined
'ol-symbolPaletteAvailable': boolean

View File

@@ -109,6 +109,18 @@
border-radius: var(--border-radius-base);
position: relative;
.message-and-dropdown {
clear: both;
&.pending-message {
opacity: 0.6;
}
}
.message-dropdown:not(.show) {
visibility: hidden;
}
.message-content {
padding: var(--spacing-03) var(--spacing-05);
overflow-x: auto;
@@ -118,6 +130,12 @@
a {
color: var(--white);
}
.message-edited {
@include body-xs;
font-weight: normal;
}
}
.arrow {
@@ -134,6 +152,28 @@
border-bottom-color: transparent !important;
border-width: 10px;
}
.message-dropdown-menu {
min-width: var(--bs-dropdown-min-width);
}
.message-dropdown-menu-btn {
@include reset-button;
@include action-button;
color: var(--white);
padding: 0;
width: 30px;
height: 30px;
}
}
&.own-message-wrapper .message-and-dropdown:hover {
background-color: rgb($neutral-90, 0.08);
.message-dropdown {
visibility: visible;
}
}
p {
@@ -273,6 +313,30 @@
.message-container {
display: flex;
justify-content: flex-start;
.message-dropdown:not(.show) {
visibility: hidden;
}
&.pending-message {
opacity: 0.6;
}
&:hover {
.message-dropdown {
visibility: visible;
}
}
.message-dropdown-menu-btn {
@include reset-button;
@include action-button;
color: var(--content-primary-themed);
padding: 0;
width: 30px;
height: 30px;
}
}
.message-author {
@@ -292,6 +356,12 @@
p {
margin: 0;
}
.message-edited {
@include body-xs;
font-weight: normal;
}
}
.message-container.message-from-self {

View File

@@ -520,6 +520,8 @@
"delete_comment_thread": "Delete comment thread",
"delete_comment_thread_message": "This will delete the whole comment thread. You cannot undo this action.",
"delete_figure": "Delete figure",
"delete_message": "Delete message",
"delete_message_confirmation": "Are you sure you want to delete this message? This cant be undone.",
"delete_projects": "Delete Projects",
"delete_row_or_column": "Delete row or column",
"delete_sso_config": "Delete SSO configuration",

View File

@@ -304,6 +304,7 @@
"css-loader": "^6.8.1",
"css-minimizer-webpack-plugin": "^5.0.1",
"cypress": "13.13.2",
"cypress-multi-reporters": "^2.0.5",
"cypress-plugin-tab": "^1.0.5",
"d3": "^3.5.16",
"daterangepicker": "2.1.27",
@@ -345,6 +346,8 @@
"mini-css-extract-plugin": "^2.7.6",
"mocha": "^11.1.0",
"mocha-each": "^2.0.1",
"mocha-junit-reporter": "^2.2.1",
"mocha-multi-reporters": "^1.5.1",
"mock-fs": "^5.1.2",
"nock": "^13.5.6",
"nvd3": "^1.8.6",

View File

@@ -0,0 +1,250 @@
import { expect } from 'chai'
import { render, screen } from '@testing-library/react'
import MessageGroup from '../../../../../frontend/js/features/chat/components/message-group'
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
import { User, UserId } from '@ol-types/user'
import {
ChatContext,
Message as MessageType,
} from '@/features/chat/context/chat-context'
import { UserProvider } from '@/shared/context/user-context'
import { ModalsContextProvider } from '@/features/ide-react/context/modals-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
describe('<MessageGroup />', function () {
function ChatProviders({
children,
idOfMessageBeingEdited = null,
}: {
children: React.ReactNode
idOfMessageBeingEdited?: string | null
}) {
const mockContextValue = {
idOfMessageBeingEdited,
cancelMessageEdit: () => {},
editMessage: () => {},
}
return (
<UserProvider>
<ModalsContextProvider>
<SplitTestProvider>
<ChatContext.Provider value={mockContextValue as any}>
{children}
</ChatContext.Provider>
</SplitTestProvider>
</ModalsContextProvider>
</UserProvider>
)
}
const currentUser: User = {
id: 'fake_user' as UserId,
first_name: 'fake_user_first_name',
email: 'fake@example.com',
}
beforeEach(function () {
window.metaAttributesCache.set('ol-user', currentUser)
stubMathJax()
})
afterEach(function () {
tearDownMathJaxStubs()
})
it('renders a basic message', function () {
const message: MessageType = {
content: 'a message',
user: currentUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
render(
<ChatProviders>
<MessageGroup messages={[message]} fromSelf />
</ChatProviders>
)
screen.getByText('a message')
})
it('renders a message with multiple contents', function () {
const messages: MessageType[] = [
{
content: 'a message',
user: currentUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
},
{
content: 'another message',
user: currentUser,
id: 'msg_2',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
},
]
render(
<ChatProviders>
<MessageGroup messages={messages} fromSelf />
</ChatProviders>
)
screen.getByText('a message')
screen.getByText('another message')
})
it('renders HTML links within messages', function () {
const message: MessageType = {
content:
'a message with a <a href="https://overleaf.com">link to Overleaf</a>',
user: currentUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
render(
<ChatProviders>
<MessageGroup messages={[message]} fromSelf />
</ChatProviders>
)
screen.getByRole('link', { name: 'https://overleaf.com' })
})
it('renders edited message with "(edited)" indicator', function () {
const editedMessage: MessageType = {
content: 'this message has been edited',
user: currentUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
edited: true,
}
render(
<ChatProviders>
<MessageGroup messages={[editedMessage]} fromSelf />
</ChatProviders>
)
screen.getByText('this message has been edited')
screen.getByText('(edited)')
})
it('does not render "(edited)" indicator for non-edited message', function () {
const message: MessageType = {
content: 'this message was not edited',
user: currentUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
edited: false,
}
render(
<ChatProviders>
<MessageGroup messages={[message]} fromSelf />
</ChatProviders>
)
screen.getByText('this message was not edited')
expect(screen.queryByText('(edited)')).to.not.exist
})
it('renders message being edited with textarea and action buttons', function () {
const messageBeingEdited: MessageType = {
content: 'original message content',
user: currentUser,
id: 'msg_being_edited',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
render(
<ChatProviders idOfMessageBeingEdited="msg_being_edited">
<MessageGroup messages={[messageBeingEdited]} fromSelf />
</ChatProviders>
)
const textarea = screen.getByDisplayValue('original message content')
expect(textarea.tagName.toLowerCase()).to.equal('textarea')
screen.getByRole('button', { name: 'Cancel' })
screen.getByRole('button', { name: 'Save' })
const paragraphs = screen.queryAllByText('original message content', {
selector: 'p',
})
expect(paragraphs).to.have.length(0)
})
describe('when the message is from the user themselves', function () {
const message: MessageType = {
content: 'a message',
user: currentUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
it('does not render the user name nor the email', function () {
render(
<ChatProviders>
<MessageGroup messages={[message]} fromSelf />
</ChatProviders>
)
expect(screen.queryByText(currentUser.first_name!)).to.not.exist
expect(screen.queryByText(currentUser.email)).to.not.exist
})
})
describe('when the message is from other user', function () {
const otherUser: User = {
id: 'other_user' as UserId,
first_name: 'other_user_first_name',
email: 'other@example.com',
}
const message: MessageType = {
content: 'a message',
user: otherUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
it('should render the other user name', function () {
render(
<ChatProviders>
<MessageGroup
messages={[message]}
user={otherUser}
fromSelf={false}
/>
</ChatProviders>
)
screen.getByText(otherUser.first_name!)
})
it('should render the other user email when their name is not available', function () {
const msg: MessageType = {
content: message.content,
user: {
id: otherUser.id,
email: 'other@example.com',
},
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
render(
<ChatProviders>
<MessageGroup messages={[msg]} user={msg.user} fromSelf={false} />
</ChatProviders>
)
expect(screen.queryByText(otherUser.first_name!)).to.not.exist
screen.getByText(msg.user!.email)
})
})
})

View File

@@ -1,14 +1,34 @@
import sinon from 'sinon'
import { expect } from 'chai'
import { screen, render, fireEvent } from '@testing-library/react'
import React from 'react'
import MessageList from '../../../../../frontend/js/features/chat/components/message-list'
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
import { UserProvider } from '@/shared/context/user-context'
import { User, UserId } from '@ol-types/user'
import { Message } from '@/features/chat/context/chat-context'
import { Message, ChatContext } from '@/features/chat/context/chat-context'
import { ModalsContextProvider } from '@/features/ide-react/context/modals-context'
import { SplitTestProvider } from '@/shared/context/split-test-context'
describe('<MessageList />', function () {
function ChatProviders({ children }: { children: React.ReactNode }) {
const mockContextValue = {
idOfMessageBeingEdited: null,
}
return (
<UserProvider>
<ModalsContextProvider>
<SplitTestProvider>
<ChatContext.Provider value={mockContextValue as any}>
{children}
</ChatContext.Provider>
</SplitTestProvider>
</ModalsContextProvider>
</UserProvider>
)
}
const currentUser: User = {
id: 'fake_user' as UserId,
first_name: 'fake_user_first_name',
@@ -19,13 +39,13 @@ describe('<MessageList />', function () {
return [
{
id: '1',
contents: ['a message'],
content: 'a message',
user: currentUser,
timestamp: new Date().getTime(),
},
{
id: '2',
contents: ['another message'],
content: 'another message',
user: currentUser,
timestamp: new Date().getTime(),
},
@@ -52,12 +72,12 @@ describe('<MessageList />', function () {
it('renders multiple messages', function () {
render(
<UserProvider>
<ChatProviders>
<MessageList
messages={createMessages()}
resetUnreadMessages={() => {}}
/>
</UserProvider>
</ChatProviders>
)
screen.getByText('a message')
@@ -70,9 +90,9 @@ describe('<MessageList />', function () {
msgs[1].timestamp = new Date(2019, 6, 3, 4, 27).getTime()
render(
<UserProvider>
<ChatProviders>
<MessageList messages={msgs} resetUnreadMessages={() => {}} />
</UserProvider>
</ChatProviders>
)
screen.getByText('4:23 am Wed, 3rd Jul 19')
@@ -85,9 +105,9 @@ describe('<MessageList />', function () {
msgs[1].timestamp = new Date(2019, 6, 3, 4, 31).getTime()
render(
<UserProvider>
<ChatProviders>
<MessageList messages={msgs} resetUnreadMessages={() => {}} />
</UserProvider>
</ChatProviders>
)
screen.getByText('4:23 am Wed, 3rd Jul 19')
@@ -97,15 +117,101 @@ describe('<MessageList />', function () {
it('resets the number of unread messages after clicking on the input', function () {
const resetUnreadMessages = sinon.stub()
render(
<UserProvider>
<ChatProviders>
<MessageList
messages={createMessages()}
resetUnreadMessages={resetUnreadMessages}
/>
</UserProvider>
</ChatProviders>
)
fireEvent.click(screen.getByRole('list'))
expect(resetUnreadMessages).to.be.calledOnce
})
it('groups messages from different users separately', function () {
const anotherUser: User = {
id: 'another_user' as UserId,
first_name: 'another_user_first_name',
email: 'another@example.com',
}
const messages: Message[] = [
{
id: '1',
content: 'first message from current user',
user: currentUser,
timestamp: new Date('2025-09-01 4:20:10').getTime(),
},
{
id: '2',
content: 'second message from current user',
user: currentUser,
timestamp: new Date('2025-09-01 4:20:11').getTime(),
},
{
id: '3',
content: 'first message from another user',
user: anotherUser,
timestamp: new Date('2025-09-01 4:20:12').getTime(),
},
{
id: '4',
content: 'second message from another user',
user: anotherUser,
timestamp: new Date('2025-09-01 4:20:13').getTime(),
},
]
render(
<ChatProviders>
<MessageList messages={messages} resetUnreadMessages={() => {}} />
</ChatProviders>
)
const messageGroups = screen.getAllByRole('listitem')
// Should have 2 message groups
expect(messageGroups).to.have.length(2)
screen.getByText('first message from current user')
screen.getByText('second message from current user')
screen.getByText('first message from another user')
screen.getByText('second message from another user')
})
it('does not show deleted messages', function () {
const messages: Message[] = [
{
id: '1',
content: 'visible message',
user: currentUser,
timestamp: new Date().getTime(),
},
{
id: '2',
content: 'deleted message',
user: currentUser,
timestamp: new Date().getTime() + 1000,
deleted: true,
},
{
id: '3',
content: 'another visible message',
user: currentUser,
timestamp: new Date().getTime() + 2000,
},
]
render(
<ChatProviders>
<MessageList messages={messages} resetUnreadMessages={() => {}} />
</ChatProviders>
)
screen.getByText('visible message')
screen.getByText('another visible message')
expect(screen.queryByText('deleted message')).to.not.exist
})
})

View File

@@ -1,120 +0,0 @@
import { expect } from 'chai'
import { render, screen } from '@testing-library/react'
import Message from '../../../../../frontend/js/features/chat/components/message'
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
import { User, UserId } from '@ol-types/user'
import { Message as MessageType } from '@/features/chat/context/chat-context'
describe('<Message />', function () {
const currentUser: User = {
id: 'fake_user' as UserId,
first_name: 'fake_user_first_name',
email: 'fake@example.com',
}
beforeEach(function () {
window.metaAttributesCache.set('ol-user', currentUser)
stubMathJax()
})
afterEach(function () {
tearDownMathJaxStubs()
})
it('renders a basic message', function () {
const message: MessageType = {
contents: ['a message'],
user: currentUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
render(<Message message={message} fromSelf />)
screen.getByText('a message')
})
it('renders a message with multiple contents', function () {
const message: MessageType = {
contents: ['a message', 'another message'],
user: currentUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
render(<Message message={message} fromSelf />)
screen.getByText('a message')
screen.getByText('another message')
})
it('renders HTML links within messages', function () {
const message: MessageType = {
contents: [
'a message with a <a href="https://overleaf.com">link to Overleaf</a>',
],
user: currentUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
render(<Message message={message} fromSelf />)
screen.getByRole('link', { name: 'https://overleaf.com' })
})
describe('when the message is from the user themselves', function () {
const message: MessageType = {
contents: ['a message'],
user: currentUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
it('does not render the user name nor the email', function () {
render(<Message message={message} fromSelf />)
expect(screen.queryByText(currentUser.first_name!)).to.not.exist
expect(screen.queryByText(currentUser.email)).to.not.exist
})
})
describe('when the message is from other user', function () {
const otherUser: User = {
id: 'other_user' as UserId,
first_name: 'other_user_first_name',
email: 'other@example.com',
}
const message: MessageType = {
contents: ['a message'],
user: otherUser,
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
it('should render the other user name', function () {
render(<Message message={message} fromSelf={false} />)
screen.getByText(otherUser.first_name!)
})
it('should render the other user email when their name is not available', function () {
const msg: MessageType = {
contents: message.contents,
user: {
id: otherUser.id,
email: 'other@example.com',
},
id: 'msg_1',
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
}
render(<Message message={msg} fromSelf={false} />)
expect(screen.queryByText(otherUser.first_name!)).to.not.exist
screen.getByText(msg.user!.email)
})
})
})

View File

@@ -109,7 +109,7 @@ describe('ChatContext', function () {
await waitFor(() => {
const message = result.current.messages[0]
expect(message.id).to.equal('msg_1')
expect(message.contents).to.deep.equal(['new message'])
expect(message.content).to.deep.equal('new message')
})
})
@@ -161,7 +161,7 @@ describe('ChatContext', function () {
const message = result.current.messages[0]
expect(message.id).to.equal('msg_1')
expect(message.contents).to.deep.equal(['new message'])
expect(message.content).to.deep.equal('new message')
})
it('deduplicate messages from websocket', async function () {
@@ -209,7 +209,7 @@ describe('ChatContext', function () {
const message = result.current.messages[0]
expect(message.id).to.equal('msg_1')
expect(message.contents).to.deep.equal(['new message'])
expect(message.content).to.deep.equal('new message')
})
it("doesn't add received messages from the current user if a message was just sent", async function () {
@@ -225,14 +225,14 @@ describe('ChatContext', function () {
)
// Send a message from the current user
const sentMsg = 'sent message'
result.current.sendMessage(sentMsg)
const content = 'sent message'
result.current.sendMessage(content)
act(() => {
// Receive a message from the current user
socket.emitToClient('new-chat-message', {
id: 'msg_1',
content: 'received message',
content,
timestamp: Date.now(),
user,
clientId: uuidValue,
@@ -243,7 +243,7 @@ describe('ChatContext', function () {
const [message] = result.current.messages
expect(message.contents).to.deep.equal([sentMsg])
expect(message.content).to.deep.equal(content)
})
it('adds the new message from the current user if another message was received after sending', async function () {
@@ -259,13 +259,13 @@ describe('ChatContext', function () {
)
// Send a message from the current user
const sentMsg = 'sent message from current user'
const content = 'sent message from current user'
act(() => {
result.current.sendMessage(sentMsg)
result.current.sendMessage(content)
})
const [sentMessageFromCurrentUser] = result.current.messages
expect(sentMessageFromCurrentUser.contents).to.deep.equal([sentMsg])
expect(sentMessageFromCurrentUser.content).to.deep.equal(content)
const otherMsg = 'new message from other user'
@@ -285,25 +285,105 @@ describe('ChatContext', function () {
})
const [, messageFromOtherUser] = result.current.messages
expect(messageFromOtherUser.contents).to.deep.equal([otherMsg])
expect(messageFromOtherUser.content).to.deep.equal(otherMsg)
const receivedMessageTimestamp = Date.now()
act(() => {
// Receive a message from the current user
socket.emitToClient('new-chat-message', {
id: 'msg_2',
content: 'received message from current user',
timestamp: Date.now(),
content,
timestamp: receivedMessageTimestamp,
user,
clientId: uuidValue,
})
})
// Since the current user didn't just send a message, it is now shown
// Since this message has the same clientId, it should update the pending message
const updatedSentMessage = {
...sentMessageFromCurrentUser,
id: 'msg_2',
content,
pending: false,
user,
timestamp: receivedMessageTimestamp,
}
expect(result.current.messages).to.deep.equal([
sentMessageFromCurrentUser,
updatedSentMessage,
messageFromOtherUser,
])
})
it('handles multiple pending messages correctly when confirmed out of order', async function () {
const socket = new SocketIOMock()
const { result } = renderChatContextHook({
socket: socket as any as Socket,
})
// Wait until initial messages have loaded
result.current.loadInitialMessages()
await waitFor(
() => expect(result.current.initialMessagesLoaded).to.be.true
)
// Send first message
act(() => {
result.current.sendMessage('first message')
})
// Send second message quickly
act(() => {
result.current.sendMessage('second message')
})
// At this point we should have 2 pending messages
expect(result.current.messages).to.have.length(2)
expect(result.current.messages[0].content).to.equal('first message')
expect(result.current.messages[0].pending).to.be.true
expect(result.current.messages[1].content).to.equal('second message')
expect(result.current.messages[1].pending).to.be.true
// Server confirms the second message first
const secondMessageTimestamp = Date.now()
act(() => {
socket.emitToClient('new-chat-message', {
id: 'server-id-2',
content: 'second message',
user,
timestamp: secondMessageTimestamp,
clientId: uuidValue,
})
})
await waitFor(() => {
expect(result.current.messages[0].content).to.equal('first message')
expect(result.current.messages[0].pending).to.be.true // Still pending
expect(result.current.messages[1].content).to.equal('second message')
expect(result.current.messages[1].pending).to.be.false // Confirmed
expect(result.current.messages[1].id).to.equal('server-id-2')
})
// Server confirms the first message
const firstMessageTimestamp = Date.now()
act(() => {
socket.emitToClient('new-chat-message', {
id: 'server-id-1',
content: 'first message',
user,
timestamp: firstMessageTimestamp,
clientId: uuidValue,
})
})
await waitFor(() => {
expect(result.current.messages[0].content).to.equal('first message')
expect(result.current.messages[0].pending).to.be.false // Now confirmed
expect(result.current.messages[0].id).to.equal('server-id-1')
expect(result.current.messages[1].content).to.equal('second message')
expect(result.current.messages[1].pending).to.be.false // Still confirmed
expect(result.current.messages[1].id).to.equal('server-id-2')
})
})
})
describe('loadInitialMessages', function () {
@@ -323,7 +403,7 @@ describe('ChatContext', function () {
result.current.loadInitialMessages()
await waitFor(() =>
expect(result.current.messages[0].contents).to.deep.equal(['a message'])
expect(result.current.messages[0].content).to.deep.equal('a message')
)
})
@@ -374,9 +454,9 @@ describe('ChatContext', function () {
result.current.loadMoreMessages()
await waitFor(() =>
expect(result.current.messages[0].contents).to.deep.equal([
'first message',
])
expect(result.current.messages[0].content).to.deep.equal(
'first message'
)
)
// The before query param is not set
@@ -403,9 +483,7 @@ describe('ChatContext', function () {
const { result } = renderChatContextHook({})
result.current.loadMoreMessages()
await waitFor(() =>
expect(result.current.messages[0].contents).to.have.length(50)
)
await waitFor(() => expect(result.current.messages).to.have.length(50))
// Call a second time
result.current.loadMoreMessages()
@@ -414,7 +492,7 @@ describe('ChatContext', function () {
// Since both messages from the same user, they are collapsed into the
// same "message"
await waitFor(() =>
expect(result.current.messages[0].contents).to.include(
expect(result.current.messages[0].content).to.include(
'message from second page'
)
)
@@ -439,9 +517,7 @@ describe('ChatContext', function () {
const { result } = renderChatContextHook({})
result.current.loadMoreMessages()
await waitFor(() =>
expect(result.current.messages[0].contents).to.have.length(49)
)
await waitFor(() => expect(result.current.messages).to.have.length(49))
result.current.loadMoreMessages()
@@ -497,7 +573,7 @@ describe('ChatContext', function () {
// Although the loaded message was resolved last, it appears first (since
// requested messages must have come first)
const messageContents = result.current.messages.map(
({ contents }) => contents[0]
({ content }) => content
)
expect(messageContents).to.deep.equal([
'loaded message',
@@ -532,9 +608,7 @@ describe('ChatContext', function () {
result.current.sendMessage('sent message')
await waitFor(() =>
expect(result.current.messages[0].contents).to.deep.equal([
'sent message',
])
expect(result.current.messages[0].content).to.deep.equal('sent message')
)
})

View File

@@ -1,271 +0,0 @@
import { expect } from 'chai'
import {
appendMessage,
prependMessages,
} from '../../../../../frontend/js/features/chat/utils/message-list-appender'
import { User, UserId } from '@ol-types/user'
import {
Message,
ServerMessageEntry,
} from '@/features/chat/context/chat-context'
const testUser: User = {
id: '123abc' as UserId,
email: 'test-user@example.com',
}
const otherUser: User = {
id: '234other' as UserId,
email: 'other-user@example.com',
}
function createTestMessageList(): Message[] {
return [
{
id: 'msg_1',
contents: ['hello', 'world'],
timestamp: new Date().getTime(),
user: otherUser,
},
{
id: 'msg_2',
contents: ['foo'],
timestamp: new Date().getTime(),
user: testUser,
},
]
}
describe('prependMessages()', function () {
function createTestMessages(): ServerMessageEntry[] {
const message1 = {
id: 'prepended_message',
content: 'hello',
timestamp: new Date().getTime(),
user: testUser,
}
const message2 = { ...message1, id: 'prepended_message_2' }
return [message1, message2]
}
it('to an empty list', function () {
const messages = createTestMessages()
const uniqueMessageIds: string[] = []
expect(
prependMessages([], messages, uniqueMessageIds).messages
).to.deep.equal([
{
id: messages[0].id,
timestamp: messages[0].timestamp,
user: messages[0].user,
contents: [messages[0].content, messages[1].content],
},
])
})
describe('when the messages to prepend are from the same user', function () {
let list, messages: ServerMessageEntry[], uniqueMessageIds: string[]
beforeEach(function () {
list = createTestMessageList()
messages = createTestMessages()
messages[0].user = testUser // makes all the messages have the same author
uniqueMessageIds = []
})
it('when the prepended messages are close in time, contents should be merged into the same message', function () {
const result = prependMessages(
createTestMessageList(),
messages,
uniqueMessageIds
).messages
expect(result.length).to.equal(list.length + 1)
expect(result[0]).to.deep.equal({
id: messages[0].id,
timestamp: messages[0].timestamp,
user: messages[0].user,
contents: [messages[0].content, messages[1].content],
})
})
it('when the prepended messages are separated in time, each message is prepended', function () {
messages[0].timestamp = messages[1].timestamp - 6 * 60 * 1000 // 6 minutes before the next message
const result = prependMessages(
createTestMessageList(),
messages,
uniqueMessageIds
).messages
expect(result.length).to.equal(list.length + 2)
expect(result[0]).to.deep.equal({
id: messages[0].id,
timestamp: messages[0].timestamp,
user: messages[0].user,
contents: [messages[0].content],
})
expect(result[1]).to.deep.equal({
id: messages[1].id,
timestamp: messages[1].timestamp,
user: messages[1].user,
contents: [messages[1].content],
})
})
})
describe('when the messages to prepend are from different users', function () {
let list, messages: ServerMessageEntry[], uniqueMessageIds: string[]
beforeEach(function () {
list = createTestMessageList()
messages = createTestMessages()
uniqueMessageIds = []
})
it('should prepend separate messages to the list', function () {
messages[0].user = otherUser
const result = prependMessages(
createTestMessageList(),
messages,
uniqueMessageIds
).messages
expect(result.length).to.equal(list.length + 2)
expect(result[0]).to.deep.equal({
id: messages[0].id,
timestamp: messages[0].timestamp,
user: messages[0].user,
contents: [messages[0].content],
})
expect(result[1]).to.deep.equal({
id: messages[1].id,
timestamp: messages[1].timestamp,
user: messages[1].user,
contents: [messages[1].content],
})
})
})
it('should merge the prepended messages into the first existing one when user is same user and are close in time', function () {
const list = createTestMessageList()
const messages = createTestMessages()
messages[0].user = messages[1].user = list[0].user
const uniqueMessageIds: string[] = []
const result = prependMessages(
createTestMessageList(),
messages,
uniqueMessageIds
).messages
expect(result.length).to.equal(list.length)
expect(result[0]).to.deep.equal({
id: messages[0].id,
timestamp: messages[0].timestamp,
user: messages[0].user,
contents: [messages[0].content, messages[1].content, ...list[0].contents],
})
})
})
describe('appendMessage()', function () {
function createTestMessage() {
return {
id: 'appended_message',
content: 'hi!',
timestamp: new Date().getTime(),
user: testUser,
}
}
it('to an empty list', function () {
const testMessage = createTestMessage()
const uniqueMessageIds: string[] = []
expect(
appendMessage([], testMessage, uniqueMessageIds).messages
).to.deep.equal([
{
id: 'appended_message',
timestamp: testMessage.timestamp,
user: testMessage.user,
contents: [testMessage.content],
},
])
})
describe('messages appended shortly after the last message on the list', function () {
let list: Message[], message: ServerMessageEntry, uniqueMessageIds: string[]
beforeEach(function () {
list = createTestMessageList()
message = createTestMessage()
message.timestamp = list[1].timestamp + 6 * 1000 // 6 seconds after the last message in the list
uniqueMessageIds = []
})
describe('when the author is the same as the last message', function () {
it('should append the content to the last message', function () {
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result.length).to.equal(list.length)
expect(result[1].contents).to.deep.equal(
list[1].contents.concat(message.content)
)
})
it('should update the last message timestamp', function () {
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result[1].timestamp).to.equal(message.timestamp)
})
})
describe('when the author is different than the last message', function () {
beforeEach(function () {
message.user = otherUser
})
it('should append the new message to the list', function () {
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result.length).to.equal(list.length + 1)
expect(result[2]).to.deep.equal({
id: 'appended_message',
timestamp: message.timestamp,
user: message.user,
contents: [message.content],
})
})
})
})
describe('messages appended later after the last message on the list', function () {
let list: Message[], message: ServerMessageEntry, uniqueMessageIds: string[]
beforeEach(function () {
list = createTestMessageList()
message = createTestMessage()
message.timestamp = list[1].timestamp + 6 * 60 * 1000 // 6 minutes after the last message in the list
uniqueMessageIds = []
})
it('when the author is the same as the last message, should be appended as new message', function () {
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result.length).to.equal(3)
expect(result[2]).to.deep.equal({
id: 'appended_message',
timestamp: message.timestamp,
user: message.user,
contents: [message.content],
})
})
it('when the author is the different than the last message, should be appended as new message', function () {
message.user = otherUser
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result.length).to.equal(3)
expect(result[2]).to.deep.equal({
id: 'appended_message',
timestamp: message.timestamp,
user: message.user,
contents: [message.content],
})
})
})
})

View File

@@ -0,0 +1,429 @@
import { expect } from 'chai'
import {
appendMessage,
prependMessages,
editMessage,
deleteMessage,
confirmMessage,
} from '../../../../../frontend/js/features/chat/utils/message-list-utils'
import { User, UserId } from '@ol-types/user'
import {
Message,
ServerMessageEntry,
} from '@/features/chat/context/chat-context'
const testUser: User = {
id: '123abc' as UserId,
email: 'test-user@example.com',
}
const otherUser: User = {
id: '234other' as UserId,
email: 'other-user@example.com',
}
function createTestMessageList(): Message[] {
return [
{
id: 'msg_1',
content: 'hello world',
timestamp: new Date().getTime(),
user: otherUser,
},
{
id: 'msg_2',
content: 'foo',
timestamp: new Date().getTime(),
user: testUser,
},
]
}
describe('message-list-utils', function () {
describe('prependMessages()', function () {
function createTestMessages(): ServerMessageEntry[] {
const message1 = {
id: 'prepended_message',
content: 'hello',
timestamp: new Date().getTime(),
user: testUser,
}
const message2 = { ...message1, id: 'prepended_message_2' }
return [message1, message2]
}
it('to an empty list', function () {
const messages = createTestMessages()
const uniqueMessageIds: string[] = []
expect(
prependMessages([], messages, uniqueMessageIds).messages
).to.deep.equal([
{
id: messages[0].id,
timestamp: messages[0].timestamp,
user: messages[0].user,
content: messages[0].content,
edited: false,
},
{
id: messages[1].id,
timestamp: messages[1].timestamp,
user: messages[1].user,
content: messages[1].content,
edited: false,
},
])
})
describe('when the messages to prepend are from the same user', function () {
let list, messages: ServerMessageEntry[], uniqueMessageIds: string[]
beforeEach(function () {
list = createTestMessageList()
messages = createTestMessages()
messages[0].user = testUser // makes all the messages have the same author
uniqueMessageIds = []
})
it('when the prepended messages are close in time, contents should be merged into the same message', function () {
const result = prependMessages(
createTestMessageList(),
messages,
uniqueMessageIds
).messages
expect(result.length).to.equal(list.length + 2)
expect(result[0]).to.deep.equal({
id: messages[0].id,
timestamp: messages[0].timestamp,
user: messages[0].user,
content: messages[0].content,
edited: false,
})
expect(result[1]).to.deep.equal({
id: messages[1].id,
timestamp: messages[1].timestamp,
user: messages[1].user,
content: messages[1].content,
edited: false,
})
})
it('when the prepended messages are separated in time, each message is prepended', function () {
messages[0].timestamp = messages[1].timestamp - 6 * 60 * 1000 // 6 minutes before the next message
const result = prependMessages(
createTestMessageList(),
messages,
uniqueMessageIds
).messages
expect(result.length).to.equal(list.length + 2)
expect(result[0]).to.deep.equal({
id: messages[0].id,
timestamp: messages[0].timestamp,
user: messages[0].user,
content: messages[0].content,
edited: false,
})
expect(result[1]).to.deep.equal({
id: messages[1].id,
timestamp: messages[1].timestamp,
user: messages[1].user,
content: messages[1].content,
edited: false,
})
})
})
describe('when the messages to prepend are from different users', function () {
let list, messages: ServerMessageEntry[], uniqueMessageIds: string[]
beforeEach(function () {
list = createTestMessageList()
messages = createTestMessages()
uniqueMessageIds = []
})
it('should prepend separate messages to the list', function () {
messages[0].user = otherUser
const result = prependMessages(
createTestMessageList(),
messages,
uniqueMessageIds
).messages
expect(result.length).to.equal(list.length + 2)
expect(result[0]).to.deep.equal({
id: messages[0].id,
timestamp: messages[0].timestamp,
user: messages[0].user,
content: messages[0].content,
edited: false,
})
expect(result[1]).to.deep.equal({
id: messages[1].id,
timestamp: messages[1].timestamp,
user: messages[1].user,
content: messages[1].content,
edited: false,
})
})
})
it('should merge the prepended messages into the first existing one when user is same user and are close in time', function () {
const list = createTestMessageList()
const messages = createTestMessages()
messages[0].user = messages[1].user = list[0].user
const uniqueMessageIds: string[] = []
const result = prependMessages(
createTestMessageList(),
messages,
uniqueMessageIds
).messages
expect(result.length).to.equal(list.length + 2)
expect(result[0]).to.deep.equal({
id: messages[0].id,
timestamp: messages[0].timestamp,
user: messages[0].user,
content: messages[0].content,
edited: false,
})
})
})
describe('appendMessage()', function () {
function createTestMessage() {
return {
id: 'appended_message',
content: 'hi!',
timestamp: new Date().getTime(),
user: testUser,
pending: false,
}
}
it('to an empty list', function () {
const testMessage = createTestMessage()
const uniqueMessageIds: string[] = []
expect(
appendMessage([], testMessage, uniqueMessageIds).messages
).to.deep.equal([
{
id: 'appended_message',
timestamp: testMessage.timestamp,
user: testMessage.user,
content: testMessage.content,
pending: false,
edited: false,
},
])
})
describe('messages appended shortly after the last message on the list', function () {
let list: Message[],
message: ServerMessageEntry,
uniqueMessageIds: string[]
beforeEach(function () {
list = createTestMessageList()
message = createTestMessage()
message.timestamp = list[1].timestamp + 6 * 1000 // 6 seconds after the last message in the list
uniqueMessageIds = []
})
describe('when the author is the same as the last message', function () {
it('should append the content to the last message', function () {
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result.length).to.equal(list.length + 1)
expect(result[2]).to.deep.equal({
id: message.id,
timestamp: message.timestamp,
user: message.user,
content: message.content,
pending: false,
edited: false,
})
})
it('should update the last message timestamp', function () {
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result[2].timestamp).to.equal(message.timestamp)
})
})
describe('when the author is different than the last message', function () {
beforeEach(function () {
message.user = otherUser
})
it('should append the new message to the list', function () {
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result.length).to.equal(list.length + 1)
expect(result[2]).to.deep.equal({
id: 'appended_message',
timestamp: message.timestamp,
user: message.user,
content: message.content,
pending: false,
edited: false,
})
})
})
})
describe('messages appended later after the last message on the list', function () {
let list: Message[],
message: ServerMessageEntry,
uniqueMessageIds: string[]
beforeEach(function () {
list = createTestMessageList()
message = createTestMessage()
message.timestamp = list[1].timestamp + 6 * 60 * 1000 // 6 minutes after the last message in the list
uniqueMessageIds = []
})
it('when the author is the same as the last message, should be appended as new message', function () {
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result.length).to.equal(3)
expect(result[2]).to.deep.equal({
id: 'appended_message',
timestamp: message.timestamp,
user: message.user,
content: message.content,
pending: false,
edited: false,
})
})
it('when the author is the different than the last message, should be appended as new message', function () {
message.user = otherUser
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result.length).to.equal(3)
expect(result[2]).to.deep.equal({
id: 'appended_message',
timestamp: message.timestamp,
user: message.user,
content: message.content,
pending: false,
edited: false,
})
})
})
})
describe('editMessage()', function () {
it('should edit an existing message', function () {
const list = createTestMessageList()
const messageId = 'msg_1'
const newContent = 'edited content'
const result = editMessage(messageId, newContent, list)
expect(result.messages.length).to.equal(list.length)
expect(result.messages[0]).to.deep.equal({
id: messageId,
content: newContent,
timestamp: list[0].timestamp,
user: list[0].user,
edited: true,
})
expect(result.messages[1]).to.deep.equal(list[1])
})
it('should throw an error if message is not found', function () {
const list = createTestMessageList()
const nonExistentId = 'non_existent_id'
const newContent = 'edited content'
expect(() => {
editMessage(nonExistentId, newContent, list)
}).to.throw(`Message with id ${nonExistentId} not found in message list`)
})
})
describe('deleteMessage()', function () {
it('should mark an existing message as deleted', function () {
const list = createTestMessageList()
const messageId = 'msg_1'
const result = deleteMessage(messageId, list)
expect(result.messages.length).to.equal(list.length)
expect(result.messages[0]).to.deep.equal({
id: messageId,
content: list[0].content,
timestamp: list[0].timestamp,
user: list[0].user,
deleted: true,
})
expect(result.messages[1]).to.deep.equal(list[1])
})
it('should throw an error if message is not found', function () {
const list = createTestMessageList()
const nonExistentId = 'non_existent_id'
expect(() => {
deleteMessage(nonExistentId, list)
}).to.throw(`Message with id ${nonExistentId} not found in message list`)
})
})
describe('confirmMessage()', function () {
function createMessageListWithPendingMessage(): Message[] {
return [
{
id: 'msg_1',
content: 'hello world',
timestamp: new Date().getTime(),
user: otherUser,
},
{
id: 'temp_id',
content: 'pending message',
timestamp: new Date().getTime(),
user: testUser,
pending: true,
},
]
}
it('should confirm a pending message and update its ID', function () {
const list = createMessageListWithPendingMessage()
const updatedMessage: Message = {
id: 'server_id',
content: 'pending message',
timestamp: new Date().getTime() + 1000,
user: testUser,
}
const result = confirmMessage(updatedMessage, list)
expect(result.messages.length).to.equal(list.length)
expect(result.messages[0]).to.deep.equal(list[0])
expect(result.messages[1]).to.deep.equal({
id: 'server_id',
content: 'pending message',
timestamp: updatedMessage.timestamp,
user: testUser,
pending: false,
})
expect(result.uniqueMessageIds).to.deep.equal(['msg_1', 'server_id'])
})
it('should throw an error if pending message is not found', function () {
const list = createTestMessageList() // No pending messages
const updatedMessage: Message = {
id: 'server_id',
content: 'non-existent pending message',
timestamp: new Date().getTime(),
user: testUser,
}
expect(() => {
confirmMessage(updatedMessage, list)
}).to.throw("Couldn't find own message in local state")
})
})
})

View File

@@ -0,0 +1,10 @@
module.exports = {
reporterEnabled: 'spec, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
mochaFile: 'data/reports/junit-mocha-[hash]-[suiteFilename].xml',
includePending: true,
jenkinsMode: true,
jenkinsClassnamePrefix: process.env.MOCHA_ROOT_SUITE_NAME,
rootSuiteTitle: process.env.MOCHA_ROOT_SUITE_NAME,
},
}

View File

@@ -1,5 +1,20 @@
const { defineConfig } = require('vitest/config')
let reporterOptions = {}
if (process.env.CI && process.env.MOCHA_ROOT_SUITE_NAME) {
reporterOptions = {
reporters: [
'default',
[
'junit',
{
classnameTemplate: `${process.env.MOCHA_ROOT_SUITE_NAME}.{filename}`,
},
],
],
outputFile: 'data/reports/junit-vitest.xml',
}
}
module.exports = defineConfig({
test: {
include: [
@@ -9,5 +24,6 @@ module.exports = defineConfig({
setupFiles: ['./test/unit/vitest_bootstrap.mjs'],
globals: true,
isolate: false,
...reporterOptions,
},
})