mirror of
https://github.com/overleaf/overleaf.git
synced 2025-12-05 01:10:29 +00:00
Compare commits
13 Commits
f0f7899de4
...
72c02bbd98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c02bbd98 | ||
|
|
632a563ed2 | ||
|
|
5944d20340 | ||
|
|
5e03da3b58 | ||
|
|
c1645ebe2c | ||
|
|
3840eaa0d8 | ||
|
|
a9d805ef9b | ||
|
|
854536fcc9 | ||
|
|
b0b7822d8d | ||
|
|
e2e6a52b97 | ||
|
|
c3c04acfea | ||
|
|
e8bc186ca0 | ||
|
|
c22e44438e |
20
package-lock.json
generated
20
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 --')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
8
services/web/.mocharc.js
Normal 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
|
||||
311
services/web/Jenkinsfile
vendored
311
services/web/Jenkinsfile
vendored
@@ -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') {
|
||||
|
||||
@@ -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) \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -233,7 +233,7 @@ async function clearTokens(projectId) {
|
||||
).exec()
|
||||
}
|
||||
|
||||
async function _generateTokens(project, callback) {
|
||||
async function _generateTokens(project) {
|
||||
if (!project.tokens) {
|
||||
project.tokens = {}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -7,4 +7,3 @@ web
|
||||
--esmock-loader=False
|
||||
--node-version=22.18.0
|
||||
--public-repo=False
|
||||
--script-version=4.7.0
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
10
services/web/cypress/cypress-multi-reporters.json
Normal file
10
services/web/cypress/cypress-multi-reporters.json
Normal 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
0
services/web/data/reports/.gitignore
vendored
Normal 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 can’t be undone.",
|
||||
"delete_projects": "Delete Projects",
|
||||
"delete_row_or_column": "Delete row or column",
|
||||
"delete_sso_config": "Delete SSO configuration",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
10
services/web/test/mocha-multi-reporters.js
Normal file
10
services/web/test/mocha-multi-reporters.js
Normal 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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user