diff --git a/services/web/frontend/js/features/chat/components/chat-pane.js b/services/web/frontend/js/features/chat/components/chat-pane.js
index ab8e21660f..98dfbdc495 100644
--- a/services/web/frontend/js/features/chat/components/chat-pane.js
+++ b/services/web/frontend/js/features/chat/components/chat-pane.js
@@ -58,7 +58,7 @@ function LoadingSpinner() {
return (
diff --git a/services/web/package.json b/services/web/package.json
index daaee0740b..b76ae02edc 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -17,7 +17,7 @@
"test:unit:run_dir": "mocha --recursive --timeout 25000 --exit --grep=$MOCHA_GREP --file test/unit/bootstrap.js",
"test:unit:app": "npm run test:unit:run_dir -- test/unit/src",
"test:unit:app:parallel": "parallel --plain --keep-order --halt now,fail=1 npm run test:unit:run_dir -- {} ::: test/unit/src/*",
- "test:frontend": "NODE_ENV=test mocha --recursive --exit --grep=$MOCHA_GREP --require test/frontend/bootstrap.js test/frontend modules/*/test/frontend",
+ "test:frontend": "NODE_ENV=test TZ=GMT mocha --recursive --exit --grep=$MOCHA_GREP --require test/frontend/bootstrap.js test/frontend modules/*/test/frontend",
"test:frontend:coverage": "c8 --all --include 'frontend/js' --include 'modules/*/frontend/js' --exclude 'frontend/js/vendor' --reporter=lcov --reporter=text-summary npm run test:frontend",
"test:karma": "karma start",
"test:karma:single": "karma start --single-run",
diff --git a/services/web/test/frontend/bootstrap.js b/services/web/test/frontend/bootstrap.js
index c66435efb0..763bb9a595 100644
--- a/services/web/test/frontend/bootstrap.js
+++ b/services/web/test/frontend/bootstrap.js
@@ -11,3 +11,15 @@ chai.use(require('sinon-chai'))
window.i18n = { currentLangCode: 'en' }
require('../../frontend/js/i18n')
+
+const moment = require('moment')
+moment.updateLocale('en', {
+ calendar: {
+ lastDay: '[Yesterday]',
+ sameDay: '[Today]',
+ nextDay: '[Tomorrow]',
+ lastWeek: 'ddd, Do MMM YY',
+ nextWeek: 'ddd, Do MMM YY',
+ sameElse: 'ddd, Do MMM YY'
+ }
+})
diff --git a/services/web/test/frontend/features/chat/components/chat-pane.test.js b/services/web/test/frontend/features/chat/components/chat-pane.test.js
new file mode 100644
index 0000000000..73f257478b
--- /dev/null
+++ b/services/web/test/frontend/features/chat/components/chat-pane.test.js
@@ -0,0 +1,123 @@
+import React from 'react'
+import { expect } from 'chai'
+import { screen, render } from '@testing-library/react'
+
+import ChatPane from '../../../../../frontend/js/features/chat/components/chat-pane'
+import {
+ stubGlobalUser,
+ stubMathJax,
+ stubUIConfig,
+ tearDownGlobalUserStub,
+ tearDownMathJaxStubs,
+ tearDownUIConfigStubs
+} from './stubs'
+
+describe('
', function() {
+ const currentUser = {
+ id: 'fake_user',
+ first_name: 'fake_user_first_name',
+ email: 'fake@example.com'
+ }
+
+ function createMessages() {
+ return [
+ {
+ contents: ['a message'],
+ user: currentUser,
+ timestamp: new Date()
+ },
+ {
+ contents: ['another message'],
+ user: currentUser,
+ timestamp: new Date()
+ }
+ ]
+ }
+
+ before(function() {
+ stubGlobalUser(currentUser) // required by ColorManager
+ stubUIConfig()
+ stubMathJax()
+ })
+
+ after(function() {
+ tearDownGlobalUserStub()
+ tearDownUIConfigStubs()
+ tearDownMathJaxStubs()
+ })
+
+ it('renders multiple messages', function() {
+ render(
+
{}}
+ sendMessage={() => {}}
+ userId={currentUser.id}
+ messages={createMessages()}
+ resetUnreadMessages={() => {}}
+ />
+ )
+
+ screen.getByText('a message')
+ screen.getByText('another message')
+ })
+
+ describe('loading spinner', function() {
+ it('is rendered while the messages is loading', function() {
+ render(
+ {}}
+ sendMessage={() => {}}
+ userId={currentUser.id}
+ messages={createMessages()}
+ resetUnreadMessages={() => {}}
+ />
+ )
+ screen.getByText('Loading…')
+ })
+
+ it('is not rendered when the messages are not loading', function() {
+ render(
+ {}}
+ sendMessage={() => {}}
+ userId={currentUser.id}
+ messages={createMessages()}
+ resetUnreadMessages={() => {}}
+ />
+ )
+ })
+ expect(screen.queryByText('Loading…')).to.not.exist
+ })
+
+ describe('"send your first message" placeholder', function() {
+ it('is rendered when there are no messages ', function() {
+ render(
+ {}}
+ sendMessage={() => {}}
+ userId={currentUser.id}
+ messages={[]}
+ resetUnreadMessages={() => {}}
+ />
+ )
+ screen.getByText('Send your first message to your collaborators')
+ })
+
+ it('is not rendered when there are some messages', function() {
+ render(
+ {}}
+ sendMessage={() => {}}
+ userId={currentUser.id}
+ messages={createMessages()}
+ resetUnreadMessages={() => {}}
+ />
+ )
+ })
+ expect(screen.queryByText('Send your first message to your collaborators'))
+ .to.not.exist
+ })
+})
diff --git a/services/web/test/frontend/features/chat/components/message-input.test.js b/services/web/test/frontend/features/chat/components/message-input.test.js
new file mode 100644
index 0000000000..a921ddfdf9
--- /dev/null
+++ b/services/web/test/frontend/features/chat/components/message-input.test.js
@@ -0,0 +1,56 @@
+import { expect } from 'chai'
+import React from 'react'
+import sinon from 'sinon'
+import { screen, render, fireEvent } from '@testing-library/react'
+
+import MessageInput from '../../../../../frontend/js/features/chat/components/message-input'
+
+describe('', function() {
+ let resetUnreadMessages, sendMessage
+
+ beforeEach(function() {
+ resetUnreadMessages = sinon.stub()
+ sendMessage = sinon.stub()
+ })
+
+ it('renders successfully', function() {
+ render(
+
+ )
+
+ screen.getByPlaceholderText('Your Message…')
+ })
+
+ it('sends a message after typing and hitting enter', function() {
+ render(
+
+ )
+
+ const input = screen.getByPlaceholderText('Your Message…')
+
+ fireEvent.change(input, { target: { value: 'hello world' } })
+ fireEvent.keyDown(input, { key: 'Enter' })
+ expect(sendMessage).to.be.calledOnce
+ expect(sendMessage).to.be.calledWith('hello world')
+ })
+
+ it('resets the number of unread messages after clicking on the input', function() {
+ render(
+
+ )
+
+ const input = screen.getByPlaceholderText('Your Message…')
+
+ fireEvent.click(input)
+ expect(resetUnreadMessages).to.be.calledOnce
+ })
+})
diff --git a/services/web/test/frontend/features/chat/components/message-list.test.js b/services/web/test/frontend/features/chat/components/message-list.test.js
new file mode 100644
index 0000000000..a0a8f217cf
--- /dev/null
+++ b/services/web/test/frontend/features/chat/components/message-list.test.js
@@ -0,0 +1,110 @@
+import React from 'react'
+import sinon from 'sinon'
+import { expect } from 'chai'
+import { screen, render, fireEvent } from '@testing-library/react'
+
+import MessageList from '../../../../../frontend/js/features/chat/components/message-list'
+import {
+ stubGlobalUser,
+ stubMathJax,
+ stubUIConfig,
+ tearDownGlobalUserStub,
+ tearDownMathJaxStubs,
+ tearDownUIConfigStubs
+} from './stubs'
+
+describe('', function() {
+ const currentUser = {
+ id: 'fake_user',
+ first_name: 'fake_user_first_name',
+ email: 'fake@example.com'
+ }
+
+ function createMessages() {
+ return [
+ {
+ contents: ['a message'],
+ user: currentUser,
+ timestamp: new Date()
+ },
+ {
+ contents: ['another message'],
+ user: currentUser,
+ timestamp: new Date()
+ }
+ ]
+ }
+
+ before(function() {
+ stubGlobalUser(currentUser) // required by ColorManager
+ stubUIConfig()
+ stubMathJax()
+ })
+
+ after(function() {
+ tearDownGlobalUserStub()
+ tearDownUIConfigStubs()
+ tearDownMathJaxStubs()
+ })
+
+ it('renders multiple messages', function() {
+ render(
+ {}}
+ />
+ )
+
+ screen.getByText('a message')
+ screen.getByText('another message')
+ })
+
+ it('renders a single timestamp for all messages within 5 minutes', function() {
+ const msgs = createMessages()
+ msgs[0].timestamp = new Date(2019, 6, 3, 4, 23)
+ msgs[1].timestamp = new Date(2019, 6, 3, 4, 27)
+
+ render(
+ {}}
+ />
+ )
+
+ screen.getByText('4:23 am Wed, 3rd Jul 19')
+ expect(screen.queryByText('4:27 am Wed, 3rd Jul 19')).to.not.exist
+ })
+
+ it('renders a timestamp for each messages separated for more than 5 minutes', function() {
+ const msgs = createMessages()
+ msgs[0].timestamp = new Date(2019, 6, 3, 4, 23)
+ msgs[1].timestamp = new Date(2019, 6, 3, 4, 31)
+
+ render(
+ {}}
+ />
+ )
+
+ screen.getByText('4:23 am Wed, 3rd Jul 19')
+ screen.getByText('4:31 am Wed, 3rd Jul 19')
+ })
+
+ it('resets the number of unread messages after clicking on the input', function() {
+ const resetUnreadMessages = sinon.stub()
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('list'))
+ expect(resetUnreadMessages).to.be.calledOnce
+ })
+})
diff --git a/services/web/test/frontend/features/chat/components/message.test.js b/services/web/test/frontend/features/chat/components/message.test.js
new file mode 100644
index 0000000000..444980dc0e
--- /dev/null
+++ b/services/web/test/frontend/features/chat/components/message.test.js
@@ -0,0 +1,116 @@
+import { expect } from 'chai'
+import React from 'react'
+import { screen, render } from '@testing-library/react'
+
+import Message from '../../../../../frontend/js/features/chat/components/message'
+import {
+ stubGlobalUser,
+ stubMathJax,
+ stubUIConfig,
+ tearDownGlobalUserStub,
+ tearDownMathJaxStubs,
+ tearDownUIConfigStubs
+} from './stubs'
+
+describe('', function() {
+ const currentUser = {
+ id: 'fake_user',
+ first_name: 'fake_user_first_name',
+ email: 'fake@example.com'
+ }
+
+ before(function() {
+ stubGlobalUser(currentUser) // required by ColorManager
+ stubUIConfig()
+ stubMathJax()
+ })
+
+ after(function() {
+ tearDownGlobalUserStub()
+ tearDownUIConfigStubs()
+ tearDownMathJaxStubs()
+ })
+
+ it('renders a basic message', function() {
+ const message = {
+ contents: ['a message'],
+ user: currentUser
+ }
+
+ render()
+
+ screen.getByText('a message')
+ })
+
+ it('renders a message with multiple contents', function() {
+ const message = {
+ contents: ['a message', 'another message'],
+ user: currentUser
+ }
+
+ render()
+
+ screen.getByText('a message')
+ screen.getByText('another message')
+ })
+
+ it('renders HTML links within messages', function() {
+ const message = {
+ contents: [
+ 'a message with a link to Overleaf'
+ ],
+ user: currentUser
+ }
+
+ render()
+
+ screen.getByRole('link', { name: 'https://overleaf.com' })
+ })
+
+ describe('when the message is from the user themselves', function() {
+ const message = {
+ contents: ['a message'],
+ user: currentUser
+ }
+
+ it('does not render the user name nor the email', function() {
+ render()
+
+ 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 = {
+ id: 'other_user',
+ first_name: 'other_user_first_name'
+ }
+
+ const message = {
+ contents: ['a message'],
+ user: otherUser
+ }
+
+ it('should render the other user name', function() {
+ render()
+
+ screen.getByText(otherUser.first_name)
+ })
+
+ it('should render the other user email when their name is not available', function() {
+ const msg = {
+ contents: message.contents,
+ user: {
+ id: otherUser.id,
+ email: 'other@example.com'
+ }
+ }
+
+ render()
+
+ expect(screen.queryByText(otherUser.first_name)).to.not.exist
+ screen.getByText(msg.user.email)
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/chat/components/stubs.js b/services/web/test/frontend/features/chat/components/stubs.js
new file mode 100644
index 0000000000..fe22cb2b0f
--- /dev/null
+++ b/services/web/test/frontend/features/chat/components/stubs.js
@@ -0,0 +1,35 @@
+import sinon from 'sinon'
+
+export function stubUIConfig() {
+ window.uiConfig = {
+ chatMessageBorderSaturation: '85%',
+ chatMessageBorderLightness: '40%',
+ chatMessageBgSaturation: '85%',
+ chatMessageBgLightness: '40%'
+ }
+}
+
+export function tearDownUIConfigStubs() {
+ delete window.uiConfig
+}
+
+export function stubMathJax() {
+ window.MathJax = {
+ Hub: {
+ Queue: sinon.stub(),
+ config: { tex2jax: { inlineMath: [['$', '$']] } }
+ }
+ }
+}
+
+export function tearDownMathJaxStubs() {
+ delete window.MathJax
+}
+
+export function stubGlobalUser(user) {
+ window.user = user
+}
+
+export function tearDownGlobalUserStub() {
+ delete window.user
+}