first commit

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

106
services/web/test/frontend/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,106 @@
// Run babel on tests to allow support for import/export statements in Node
require('@babel/register')({
extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs'],
plugins: [['module-resolver', { alias: { '^@/(.+)': './frontend/js/\\1' } }]],
})
// Load JSDOM to mock the DOM in Node
// Set pretendToBeVisual to enable requestAnimationFrame
require('jsdom-global')(undefined, {
pretendToBeVisual: true,
url: 'https://www.test-overleaf.com/',
})
const path = require('path')
process.env.OVERLEAF_CONFIG = path.resolve(
__dirname,
'../../config/settings.webpack.js'
)
// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc')
// has a nicer failure messages
const chai = require('chai')
chai.use(require('sinon-chai'))
chai.use(require('chai-as-promised'))
// Populate meta for top-level access in modules on import
const { resetMeta } = require('./helpers/reset-meta')
resetMeta()
// i18n requires access to 'ol-i18n' as defined above
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',
},
})
// workaround for missing keys in jsdom-global's keys.js
globalThis.AbortController = global.AbortController = window.AbortController
globalThis.MutationObserver = global.MutationObserver = window.MutationObserver
globalThis.StorageEvent = global.StorageEvent = window.StorageEvent
globalThis.SVGElement = global.SVGElement = window.SVGElement
globalThis.localStorage = global.localStorage = window.localStorage
globalThis.performance = global.performance = window.performance
globalThis.cancelAnimationFrame = global.cancelAnimationFrame =
window.cancelAnimationFrame
globalThis.requestAnimationFrame = global.requestAnimationFrame =
window.requestAnimationFrame
globalThis.sessionStorage = global.sessionStorage = window.sessionStorage
// add polyfill for ResizeObserver
globalThis.ResizeObserver =
global.ResizeObserver =
window.ResizeObserver =
require('@juggle/resize-observer').ResizeObserver
// add stub for BroadcastChannel (unused in these tests)
globalThis.BroadcastChannel =
global.BroadcastChannel =
window.BroadcastChannel =
class BroadcastChannel {
addEventListener(type, listener) {}
removeEventListener(type, listener) {}
postMessage(message) {}
}
// add stub for WebSocket state enum
globalThis.WebSocket = class WebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
}
// node-fetch doesn't accept relative URL's: https://github.com/node-fetch/node-fetch/blob/master/docs/v2-LIMITS.md#known-differences
const fetch = require('node-fetch')
globalThis.fetch =
global.fetch =
window.fetch =
(url, ...options) => fetch(new URL(url, 'http://127.0.0.1'), ...options)
// ignore style/image files
const { addHook } = require('pirates')
addHook(() => '', {
exts: ['.css', '.less', '.svg', '.png', '.gif', '.mp4'],
ignoreNodeModules: false,
})
globalThis.HTMLElement.prototype.scrollIntoView = () => {}
globalThis.DOMParser = window.DOMParser
// Polyfill for IndexedDB
require('fake-indexeddb/auto')
const fetchMock = require('fetch-mock').default
fetchMock.spyGlobal()
fetchMock.config.fetch = global.fetch
fetchMock.config.Response = fetch.Response

View File

@@ -0,0 +1,905 @@
import EditorLeftMenu from '../../../../frontend/js/features/editor-left-menu/components/editor-left-menu'
import {
AllowedImageName,
OverallThemeMeta,
SpellCheckLanguage,
} from '../../../../types/project-settings'
import { EditorProviders } from '../../helpers/editor-providers'
import { mockScope } from './scope'
import { Folder } from '../../../../types/folder'
import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
import getMeta from '@/utils/meta'
describe('<EditorLeftMenu />', function () {
beforeEach(function () {
cy.viewport(800, 800)
cy.interceptCompile()
})
describe('for non-anonymous users', function () {
const overallThemes: OverallThemeMeta[] = [
{
name: 'Overall Theme 1',
val: '',
path: 'https://overleaf.com/overalltheme-1.css',
},
{
name: 'Overall Theme 2',
val: 'light-',
path: 'https://overleaf.com/overalltheme-2.css',
},
]
const allowedImageNames: AllowedImageName[] = [
{
imageDesc: 'Image 1',
imageName: 'img-1',
},
{
imageDesc: 'Image 2',
imageName: 'img-2',
},
]
beforeEach(function () {
window.metaAttributesCache.set('ol-overallThemes', overallThemes)
window.metaAttributesCache.set('ol-allowedImageNames', allowedImageNames)
window.metaAttributesCache.set('ol-anonymous', false)
window.metaAttributesCache.set('ol-gitBridgeEnabled', true)
window.metaAttributesCache.set('ol-showSupport', true)
Object.assign(getMeta('ol-ExposedSettings'), { ieeeBrandId: 123 })
window.metaAttributesCache.set('ol-user', {
email: 'sherlock@holmes.co.uk',
first_name: 'Sherlock',
last_name: 'Holmes',
})
})
it('render full menu', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
// Download Menu
cy.findByRole('heading', { name: 'Download' })
cy.findByRole('link', { name: 'Source' })
cy.findByRole('link', { name: 'PDF' })
// Actions Menu
cy.findByRole('heading', { name: 'Actions' })
cy.findByRole('button', { name: 'Copy Project' })
cy.findByRole('button', { name: 'Word Count' })
// Sync Menu
cy.findByRole('heading', { name: 'Sync' })
cy.findByRole('button', { name: 'Dropbox' })
cy.findByRole('button', { name: 'Git' })
cy.findByRole('button', { name: 'GitHub' })
// Settings Menu
cy.findByRole('heading', { name: 'Settings' })
cy.findByLabelText('Compiler')
cy.findByLabelText('TeX Live version')
cy.findByLabelText('Main document')
cy.findByLabelText('Spell check')
cy.findByLabelText('Auto-complete')
cy.findByLabelText('Auto-close brackets')
cy.findByLabelText('Code check')
cy.findByLabelText('Editor theme')
cy.findByLabelText('Overall theme')
cy.findByLabelText('Keybindings')
cy.findByLabelText('Font Size')
cy.findByLabelText('Font Family')
cy.findByLabelText('Line Height')
cy.findByLabelText('PDF Viewer')
// Help Menu
cy.findByRole('heading', { name: 'Help' })
cy.findByRole('button', { name: 'Show Hotkeys' })
cy.findByRole('link', { name: 'Documentation' })
cy.findByRole('button', { name: 'Contact Us' })
})
describe('download menu', function () {
it('have a correct source & pdf download url', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.findByRole('link', { name: 'Source' }).should(
'have.attr',
'href',
'/project/project123/download/zip'
)
cy.findByRole('link', { name: 'PDF' })
.should('have.attr', 'href')
.and('match', /\/download\/project\/project123\/build/)
})
})
describe('actions menu', function () {
it('shows copy project modal correctly', function () {
cy.intercept('POST', '/project/*/clone', {
body: {
project_id: 'new_project_id',
},
})
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.findByRole('button', { name: 'Copy Project' }).click()
cy.findByRole('heading', { name: 'Copy Project' })
// try closing & re-opening the modal with different methods
cy.findByRole('button', { name: 'Close' }).click()
cy.findByRole('button', { name: 'Copy Project' }).click()
cy.findByRole('button', { name: 'Cancel' }).click()
cy.findByRole('button', { name: 'Copy Project' }).click()
cy.findByLabelText('New Name').focus()
cy.findByLabelText('New Name').clear()
cy.findByLabelText('New Name').type('Project Renamed')
cy.get('#clone-project-form-name[value="Project Renamed"')
})
it('shows word count modal correctly', function () {
cy.intercept('GET', '/project/*/wordcount*', {
texcount: {
encode: 'ascii',
textWords: 781,
headWords: 66,
outside: 11,
headers: 41,
elements: 2,
mathInline: 6,
mathDisplay: 1,
errors: 0,
},
}).as('wordCount')
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.findByRole('button', { name: 'Word Count' }).click()
cy.wait('@wordCount')
cy.findByText('Total Words:')
cy.findByText('781')
cy.findByText('Headers:')
cy.findByText('41')
cy.findByText('Math Inline:')
cy.findByText('6')
cy.findByText('Math Display:')
cy.findByText('1')
})
})
describe('sync menu', function () {
it('shows dropbox modal correctly', function () {
cy.intercept('GET', '/dropbox/status', {
registered: true,
})
const scope = mockScope({
ui: {
leftMenuShown: true,
},
project: {
members: [],
owner: {
_id: '123',
},
},
user: {
features: {
dropbox: false,
},
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.findByRole('button', { name: 'Dropbox' }).click()
cy.findByText('Dropbox Sync')
})
it('shows git modal correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
project: {
owner: {
_id: '123',
},
features: {
gitBridge: true,
},
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.findByRole('button', { name: 'Git' }).click()
cy.findByText('Clone with Git')
cy.findByText(/clone your project by using the link below/)
})
it('shows git modal paywall correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
project: {
owner: {
_id: '123',
},
features: {
gitBridge: false,
},
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.findByRole('button', { name: 'Git' }).click()
cy.findByText('Collaborate online and offline, using your own workflow')
})
it('shows github modal correctly', function () {
cy.intercept('GET', '/user/github-sync/status', {
available: false,
enabled: false,
}).as('user-status')
cy.intercept('GET', '/project/*/github-sync/status', {
enabled: false,
}).as('project-status')
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.wait('@compile')
cy.findByRole('button', { name: 'GitHub' }).click()
cy.findByText('GitHub Sync')
cy.wait(['@user-status', '@project-status'])
cy.findByText('Push to GitHub, pull to Overleaf')
})
it('hides the entire sync section when git bridge is disabled', function () {
window.metaAttributesCache.set('ol-gitBridgeEnabled', false)
cy.findByRole('button', { name: 'Dropbox' }).should('not.exist')
cy.findByRole('button', { name: 'Git' }).should('not.exist')
cy.findByRole('button', { name: 'GitHub' }).should('not.exist')
})
})
describe('settings menu', function () {
it('shows compiler menu correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>('#settings-menu-compiler option').then(
options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq([
'pdflatex',
'latex',
'xelatex',
'lualatex',
])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq([
'pdfLaTeX',
'LaTeX',
'XeLaTeX',
'LuaLaTeX',
])
}
)
})
it('shows texlive version menu correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>('#settings-menu-imageName option').then(
options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq(['img-1', 'img-2'])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq(['Image 1', 'Image 2'])
}
)
})
it('shows document menu correctly', function () {
const rootFolder: Folder = {
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: 'id1',
name: 'main.tex',
},
{
_id: 'id2',
name: 'main2.tex',
},
],
fileRefs: [],
folders: [],
}
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope} rootFolder={[rootFolder as any]}>
<EditorLeftMenu />
</EditorProviders>
)
const docs = docsInFolder(rootFolder)
cy.get<HTMLOptionElement>('#settings-menu-rootDocId option').then(
options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq(docs.map(doc => doc.doc.id))
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq(docs.map(doc => doc.path))
}
)
})
it('shows spellcheck menu correctly', function () {
const languages: SpellCheckLanguage[] = [
{
name: 'Lang 1',
code: 'lang-1',
dic: 'lang_1',
},
{
name: 'Lang 2',
code: 'lang-2',
dic: 'lang_2',
},
]
window.metaAttributesCache.set('ol-languages', languages)
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>(
'#settings-menu-spellCheckLanguage option'
).then(options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq(['', 'lang-1', 'lang-2'])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq(['Off', 'Lang 1', 'Lang 2'])
})
})
it('shows dictionary modal correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get('label[for="dictionary-settings"] ~ button').click()
cy.findByText('Edit Dictionary')
cy.findByText('Your custom dictionary is empty.')
})
it('shows auto-complete menu correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>('#settings-menu-autoComplete option').then(
options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq(['true', 'false'])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq(['On', 'Off'])
}
)
})
it('shows auto-close brackets menu correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>(
'#settings-menu-autoPairDelimiters option'
).then(options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq(['true', 'false'])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq(['On', 'Off'])
})
})
it('shows code check menu correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>(
'#settings-menu-syntaxValidation option'
).then(options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq(['true', 'false'])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq(['On', 'Off'])
})
})
it('shows editor theme menu correctly', function () {
const editorThemes = ['editortheme-1', 'editortheme-2', 'editortheme-3']
const legacyEditorThemes = [
'legacytheme-1',
'legacytheme-2',
'legacytheme-3',
]
window.metaAttributesCache.set('ol-editorThemes', editorThemes)
window.metaAttributesCache.set(
'ol-legacyEditorThemes',
legacyEditorThemes
)
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>('#settings-menu-editorTheme option').then(
options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq([
'editortheme-1',
'editortheme-2',
'editortheme-3',
'-',
'legacytheme-1',
'legacytheme-2',
'legacytheme-3',
])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq([
'editortheme-1',
'editortheme-2',
'editortheme-3',
'—————————————————',
'legacytheme-1 (Legacy)',
'legacytheme-2 (Legacy)',
'legacytheme-3 (Legacy)',
])
}
)
})
it('shows overall theme menu correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>('#settings-menu-overallTheme option').then(
options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq(['', 'light-'])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq(['Overall Theme 1', 'Overall Theme 2'])
}
)
})
it('shows keybindings menu correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>('#settings-menu-mode option').then(
options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq(['default', 'vim', 'emacs'])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq(['None', 'Vim', 'Emacs'])
}
)
})
it('shows font size menu correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>('#settings-menu-fontSize option').then(
options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq([
'10',
'11',
'12',
'13',
'14',
'16',
'18',
'20',
'22',
'24',
])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq([
'10px',
'11px',
'12px',
'13px',
'14px',
'16px',
'18px',
'20px',
'22px',
'24px',
])
}
)
})
it('shows font family menu correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>('#settings-menu-fontFamily option').then(
options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq(['monaco', 'lucida', 'opendyslexicmono'])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq([
'Monaco / Menlo / Consolas',
'Lucida / Source Code Pro',
'OpenDyslexic Mono',
])
}
)
})
it('shows line height menu correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>('#settings-menu-lineHeight option').then(
options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq(['compact', 'normal', 'wide'])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq(['Compact', 'Normal', 'Wide'])
}
)
})
it('shows pdf viewer menu correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.get<HTMLOptionElement>('#settings-menu-pdfViewer option').then(
options => {
const values = [...options].map(o => o.value)
expect(values).to.deep.eq(['pdfjs', 'native'])
const texts = [...options].map(o => o.text)
expect(texts).to.deep.eq(['Overleaf', 'Browser'])
}
)
})
})
describe('help menu', function () {
it('shows hotkeys modal correctly', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.findByRole('button', { name: 'Show Hotkeys' }).click()
cy.findByText('Hotkeys')
})
it('shows correct url for documentation', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.findByRole('link', { name: 'Documentation' }).should(
'have.attr',
'href',
'/learn'
)
})
it('shows correct contact us modal', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
cy.findByRole('button', { name: 'Contact Us' }).click()
cy.findByText('Affected project URL (Optional)')
})
})
})
describe('for anonymous users', function () {
it('render minimal menu', function () {
const scope = mockScope({
ui: {
leftMenuShown: true,
},
})
window.metaAttributesCache.set('ol-anonymous', true)
Object.assign(getMeta('ol-ExposedSettings'), { ieeeBrandId: 123 })
cy.mount(
<EditorProviders scope={scope}>
<EditorLeftMenu />
</EditorProviders>
)
// Download Menu
cy.findByRole('heading', { name: 'Download' })
cy.findByRole('link', { name: 'Source' })
cy.findByRole('link', { name: 'PDF' })
// Actions Menu
cy.findByRole('heading', { name: 'Actions' }).should('not.exist')
cy.findByRole('button', { name: 'Copy Project' }).should('not.exist')
cy.findByRole('button', { name: 'Word Count' }).should('not.exist')
// Sync Menu
cy.findByRole('heading', { name: 'Sync' }).should('not.exist')
cy.findByRole('button', { name: 'Dropbox' }).should('not.exist')
cy.findByRole('button', { name: 'Git' }).should('not.exist')
cy.findByRole('button', { name: 'GitHub' }).should('not.exist')
// Settings Menu
cy.findByRole('heading', { name: 'Settings' }).should('not.exist')
cy.findByLabelText('Compiler').should('not.exist')
cy.findByLabelText('TeX Live version').should('not.exist')
cy.findByLabelText('Main document').should('not.exist')
cy.findByLabelText('Spell check').should('not.exist')
cy.findByLabelText('Auto-complete').should('not.exist')
cy.findByLabelText('Auto-close brackets').should('not.exist')
cy.findByLabelText('Code check').should('not.exist')
cy.findByLabelText('Editor theme').should('not.exist')
cy.findByLabelText('Overall theme').should('not.exist')
cy.findByLabelText('Keybindings').should('not.exist')
cy.findByLabelText('Font Size').should('not.exist')
cy.findByLabelText('Font Family').should('not.exist')
cy.findByLabelText('Line Height').should('not.exist')
cy.findByLabelText('PDF Viewer').should('not.exist')
// Help Menu
cy.findByRole('heading', { name: 'Help' })
cy.findByRole('button', { name: 'Show Hotkeys' })
cy.findByRole('button', { name: 'Documentation' }).should('not.exist')
cy.findByRole('link', { name: 'Contact Us' }).should('not.exist')
})
})
})

View File

@@ -0,0 +1,56 @@
import { MainDocument } from '../../../../types/project-settings'
import { PdfViewer } from '../../../../types/user-settings'
type Scope = {
settings?: {
syntaxValidation?: boolean
pdfViewer?: PdfViewer
}
editor?: {
sharejs_doc?: {
doc_id?: string
getSnapshot?: () => string
}
}
hasLintingError?: boolean
ui?: {
view?: 'editor' | 'history' | 'file' | 'pdf'
pdfLayout?: 'flat' | 'sideBySide' | 'split'
leftMenuShown?: boolean
}
project?: {
members?: any[]
owner: {
_id: string
}
features?: {
gitBridge?: boolean
}
}
user?: {
features?: {
dropbox: boolean
}
}
docs?: MainDocument[]
}
export const mockScope = (scope?: Scope) => ({
settings: {
syntaxValidation: false,
pdfViewer: 'pdfjs',
},
editor: {
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
},
},
hasLintingError: false,
ui: {
view: 'editor',
pdfLayout: 'sideBySide',
leftMenuShown: false,
},
...scope,
})

View File

@@ -0,0 +1,84 @@
import { EditorProviders } from '../../helpers/editor-providers'
import DetachCompileButtonWrapper from '../../../../frontend/js/features/pdf-preview/components/detach-compile-button-wrapper'
import { mockScope } from './scope'
import { testDetachChannel } from '../../helpers/detach-channel'
describe('<DetachCompileButtonWrapper />', function () {
beforeEach(function () {
cy.interceptEvents()
})
it('detacher mode and not linked: does not show button ', function () {
cy.interceptCompile()
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detacher')
})
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<DetachCompileButtonWrapper />
</EditorProviders>
)
cy.waitForCompile()
cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
})
it('detacher mode and linked: show button', function () {
cy.interceptCompile()
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detacher')
})
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<DetachCompileButtonWrapper />
</EditorProviders>
)
cy.waitForCompile()
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detached',
event: 'connected',
})
})
cy.findByRole('button', { name: 'Recompile' })
})
it('not detacher mode and linked: does not show button ', function () {
cy.interceptCompile()
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<DetachCompileButtonWrapper />
</EditorProviders>
)
cy.waitForCompile()
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'connected',
})
})
cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
})
})

View File

@@ -0,0 +1,103 @@
import { EditorProviders } from '../../helpers/editor-providers'
import PdfJsViewer from '../../../../frontend/js/features/pdf-preview/components/pdf-js-viewer'
import { mockScope } from './scope'
import { getContainerEl } from 'cypress/react'
import { unmountComponentAtNode } from 'react-dom'
import { PdfPreviewProvider } from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-provider'
// Unicode directional isolates, added around placeables by @fluent/bundle/esm/resolver
const FSI = '\u2068'
const PDI = '\u2069'
describe('<PdfJSViewer/>', function () {
beforeEach(function () {
cy.interceptEvents()
})
it('loads all PDF pages', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<PdfPreviewProvider>
<div className="pdf-viewer">
<PdfJsViewer url="/build/123/output.pdf?clsiserverid=foo" />
</div>
</PdfPreviewProvider>
</EditorProviders>
)
cy.waitForCompile({ pdf: true })
cy.findByRole('region', { name: `Page ${FSI}1${PDI}` })
cy.findByRole('region', { name: `Page ${FSI}2${PDI}` })
cy.findByRole('region', { name: `Page ${FSI}3${PDI}` })
cy.findByRole('region', { name: `Page ${FSI}4${PDI}` }).should('not.exist')
cy.contains('Your Paper')
})
it('renders pages in a "loading" state', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<PdfPreviewProvider>
<div className="pdf-viewer">
<PdfJsViewer url="/build/123/output.pdf" />
</div>
</PdfPreviewProvider>
</EditorProviders>
)
cy.waitForCompile()
cy.get('.page.loading')
})
it('can be unmounted while loading a document', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<PdfPreviewProvider>
<div className="pdf-viewer">
<PdfJsViewer url="/build/123/output.pdf?clsiserverid=foo" />
</div>
</PdfPreviewProvider>
</EditorProviders>
)
cy.waitForCompile()
cy.then(() => unmountComponentAtNode(getContainerEl()))
})
it('can be unmounted after loading a document', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<PdfPreviewProvider>
<div className="pdf-viewer">
<PdfJsViewer url="/build/123/output.pdf?clsiserverid=foo" />
</div>
</PdfPreviewProvider>
</EditorProviders>
)
cy.waitForCompile({ pdf: true })
cy.findByRole('region', { name: `Page ${FSI}1${PDI}` })
cy.then(() => unmountComponentAtNode(getContainerEl()))
})
})

View File

@@ -0,0 +1,186 @@
import { EditorProviders } from '../../helpers/editor-providers'
import PdfLogsEntries from '../../../../frontend/js/features/pdf-preview/components/pdf-logs-entries'
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { FindResult } from '@/features/file-tree/util/path'
import { FC } from 'react'
import {
EditorManager,
EditorManagerContext,
} from '@/features/ide-react/context/editor-manager-context'
import { EditorView } from '@codemirror/view'
import { OpenDocuments } from '@/features/ide-react/editor/open-documents'
import { LogEntry } from '@/features/pdf-preview/util/types'
describe('<PdfLogsEntries/>', function () {
const fakeFindEntityResult: FindResult = {
type: 'doc',
entity: { _id: '123', name: '123 Doc' },
}
const FileTreePathProvider: FC = ({ children }) => (
<FileTreePathContext.Provider
value={{
dirname: cy.stub(),
findEntityByPath: cy
.stub()
.as('findEntityByPath')
.returns(fakeFindEntityResult),
pathInFolder: cy.stub(),
previewByPath: cy.stub(),
}}
>
{children}
</FileTreePathContext.Provider>
)
const EditorManagerProvider: FC = ({ children }) => {
const value = {
openDocWithId: cy.spy().as('openDocWithId'),
// @ts-ignore
openDocs: new OpenDocuments(),
} as unknown as EditorManager
return (
<EditorManagerContext.Provider value={value}>
{children}
</EditorManagerContext.Provider>
)
}
const logEntries: LogEntry[] = [
{
file: 'main.tex',
line: 9,
column: 8,
level: 'error',
message: 'LaTeX Error',
content: 'See the LaTeX manual',
raw: '',
ruleId: 'hint_misplaced_alignment_tab_character',
key: '',
},
]
const scope = {
'editor.view': new EditorView({ doc: '\\documentclass{article}' }),
}
beforeEach(function () {
cy.interceptCompile()
cy.interceptEvents()
})
it('displays human readable hint', function () {
cy.mount(
<EditorProviders scope={scope}>
<PdfLogsEntries entries={logEntries} />
</EditorProviders>
)
cy.contains('You have placed an alignment tab character')
})
it('opens doc on click', function () {
cy.mount(
<EditorProviders
scope={scope}
providers={{ EditorManagerProvider, FileTreePathProvider }}
>
<PdfLogsEntries entries={logEntries} />
</EditorProviders>
)
cy.findByRole('button', {
name: 'Navigate to log position in source code: main.tex, 9',
}).click()
cy.get('@findEntityByPath').should('have.been.calledOnceWith', 'main.tex')
cy.get('@openDocWithId').should(
'have.been.calledOnceWith',
fakeFindEntityResult.entity._id,
{
gotoLine: 9,
gotoColumn: 8,
keepCurrentView: false,
}
)
})
it('opens doc via detached action', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detacher')
})
cy.mount(
<EditorProviders
scope={scope}
providers={{ EditorManagerProvider, FileTreePathProvider }}
>
<PdfLogsEntries entries={logEntries} />
</EditorProviders>
).then(() => {
testDetachChannel.postMessage({
role: 'detached',
event: 'action-sync-to-entry',
data: {
args: [
{
file: 'main.tex',
line: 7,
column: 6,
},
],
},
})
})
cy.get('@findEntityByPath').should('have.been.calledOnce')
cy.get('@openDocWithId').should(
'have.been.calledOnceWith',
fakeFindEntityResult.entity._id,
{
gotoLine: 7,
gotoColumn: 6,
keepCurrentView: false,
}
)
})
it('sends open doc clicks via detached action', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
cy.mount(
<EditorProviders
scope={scope}
providers={{ EditorManagerProvider, FileTreePathProvider }}
>
<PdfLogsEntries entries={logEntries} />
</EditorProviders>
)
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.findByRole('button', {
name: 'Navigate to log position in source code: main.tex, 9',
}).click()
cy.get('@findEntityByPath').should('not.have.been.called')
cy.get('@openDocWithId').should('not.have.been.called')
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detached',
event: 'action-sync-to-entry',
data: {
args: [
{
file: 'main.tex',
line: 9,
column: 8,
},
],
},
})
})
})

View File

@@ -0,0 +1,73 @@
import PdfPreviewDetachedRoot from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-detached-root'
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
describe('<PdfPreviewDetachedRoot/>', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-user', { id: 'user1' })
window.metaAttributesCache.set('ol-project_id', 'project1')
window.metaAttributesCache.set('ol-detachRole', 'detached')
window.metaAttributesCache.set('ol-projectName', 'Project Name')
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
it('syncs compiling state', function () {
cy.interceptCompile()
cy.mount(<PdfPreviewDetachedRoot />)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'connected',
})
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-compiling',
data: { value: true },
})
})
cy.findByRole('button', { name: 'Compiling…' })
cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-compiling',
data: { value: false },
})
})
cy.findByRole('button', { name: 'Recompile' })
cy.findByRole('button', { name: 'Compiling…' }).should('not.exist')
})
it('sends a clear cache request when the button is pressed', function () {
cy.interceptCompile()
cy.mount(<PdfPreviewDetachedRoot />)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-showLogs',
data: { value: true },
})
})
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.findByRole('button', { name: 'Clear cached files' })
.should('not.be.disabled')
.click()
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detached',
event: 'action-clearCache',
data: {
args: [],
},
})
})
})

View File

@@ -0,0 +1,100 @@
import { EditorProviders } from '../../helpers/editor-providers'
import PdfPreviewHybridToolbar from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
import { testDetachChannel } from '../../helpers/detach-channel'
describe('<PdfPreviewHybridToolbar/>', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
it('shows normal mode', function () {
cy.mount(
<EditorProviders>
<PdfPreviewHybridToolbar />
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' })
})
describe('orphan mode', function () {
it('shows connecting message on load', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
cy.mount(
<EditorProviders>
<PdfPreviewHybridToolbar />
</EditorProviders>
)
cy.contains('Connecting with the editor')
})
it('shows compile UI when connected', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
cy.mount(
<EditorProviders>
<PdfPreviewHybridToolbar />
</EditorProviders>
)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'connected',
})
})
cy.findByRole('button', { name: 'Recompile' })
})
it('shows connecting message when disconnected', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
cy.mount(
<EditorProviders>
<PdfPreviewHybridToolbar />
</EditorProviders>
)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'connected',
})
testDetachChannel.postMessage({
role: 'detacher',
event: 'closed',
})
})
cy.contains('Connecting with the editor')
})
it('shows redirect button after timeout', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
cy.clock()
cy.mount(
<EditorProviders>
<PdfPreviewHybridToolbar />
</EditorProviders>
)
cy.tick(6000)
cy.findByRole('button', { name: 'Redirect to editor' })
})
})
})

View File

@@ -0,0 +1,872 @@
import localStorage from '@/infrastructure/local-storage'
import PdfPreview from '../../../../frontend/js/features/pdf-preview/components/pdf-preview'
import { EditorProviders } from '../../helpers/editor-providers'
import { mockScope } from './scope'
import {
IdeLayout,
IdeView,
useLayoutContext,
} from '../../../../frontend/js/shared/context/layout-context'
import { FC, useEffect } from 'react'
const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => {
localStorage.setItem(key, value)
win.dispatchEvent(new StorageEvent('storage', { key }))
}
const Layout: FC<{ layout: IdeLayout; view?: IdeView }> = ({
layout,
view,
}) => {
const { changeLayout } = useLayoutContext()
useEffect(() => {
changeLayout(layout, view)
}, [changeLayout, layout, view])
return null
}
describe('<PdfPreview/>', function () {
let projectId: string
beforeEach(function () {
/**
* There are time sensitive tests in this test suite. They need to wait for a Promise before resolving a request.
*
* Using a promise across the test-env (browser) vs stub-env (server) causes additional latency.
*
* This latency seems to stack up when adding more intercepts for the same path. Using static responses for some of these intercepts does not help.
*
* All of that seems like a bug in Cypress. For now just work around it by using a unique projectId for each intercept.
*/
projectId = Math.random().toString().slice(2)
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
window.metaAttributesCache.set(
'ol-compilesUserContentDomain',
'https://compiles-user.dev-overleaf.com'
)
window.metaAttributesCache.set('ol-splitTestVariants', {
'initial-compile-from-clsi-cache': 'enabled',
})
cy.interceptEvents()
})
it('renders the PDF preview', function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// wait for "compile on load" to finish
cy.waitForCompile({ pdf: true })
cy.findByRole('button', { name: 'Recompile' })
})
it('uses the cache when available', function () {
cy.interceptCompile({
prefix: 'compile',
times: 1,
cached: true,
regular: false,
})
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// wait for "compile from cache on load" to finish
cy.waitForCompile({ pdf: true, cached: true, regular: false })
cy.contains('Your Paper')
})
it('uses the cache when available then compiles', function () {
cy.interceptCompile({
prefix: 'compile',
times: 1,
cached: true,
regular: false,
})
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// wait for "compile from cache on load" to finish
cy.waitForCompile({ pdf: true, cached: true, regular: false })
cy.contains('Your Paper')
// Then trigger a new compile
cy.interceptCompile({
prefix: 'recompile',
times: 1,
cached: false,
outputPDFFixture: 'output-2.pdf',
})
// press the Recompile button => compile
cy.findByRole('button', { name: 'Recompile' }).click()
// wait for compile to finish
cy.waitForCompile({ prefix: 'recompile', pdf: true })
cy.contains('Modern Authoring Tools for Science')
})
describe('racing compile from cache and regular compile trigger', function () {
for (const [timing] of ['before rendering', 'after rendering']) {
it(`replaces the compile from cache with a regular compile - ${timing}`, function () {
const requestedOnce = new Set()
;['log', 'pdf', 'blg'].forEach(ext => {
cy.intercept({ pathname: `/build/*/output.${ext}` }, req => {
if (requestedOnce.has(ext)) {
throw new Error(
`compile from cache triggered extra ${ext} request: ${req.url}`
)
}
requestedOnce.add(ext)
req.reply({ fixture: `build/output.${ext},null` })
}).as(`compile-${ext}`)
})
const { promise, resolve } = Promise.withResolvers<void>()
cy.interceptCompileFromCacheRequest({
promise,
times: 1,
}).as('cached-compile')
cy.interceptCompileRequest().as('compile')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope} projectId={projectId}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// press the Recompile button => compile
cy.findByRole('button', { name: 'Recompile' }).click()
if (timing === 'before rendering') {
cy.then(() => resolve())
cy.wait('@cached-compile')
}
// wait for rendering to finish
cy.waitForCompile({ pdf: true, cached: false })
if (timing === 'after rendering') {
cy.then(() => resolve())
cy.wait('@cached-compile')
}
cy.contains('Your Paper')
cy.then(() => Array.from(requestedOnce).sort().join(',')).should(
'equal',
'blg,log,pdf'
)
})
}
})
describe('clsi-cache project settings validation', function () {
const cases = {
// Flaky, skip for now
'uses compile from cache when nothing changed': {
cached: true,
setup: () => {},
props: {},
},
'ignores the compile from cache when imageName changed': {
cached: false,
setup: () => {},
props: {
imageName: 'texlive-full:2025.1',
},
},
'ignores the compile from cache when compiler changed': {
cached: false,
setup: () => {},
props: {
compiler: 'lualatex',
},
},
'ignores the compile from cache when draft mode changed': {
cached: false,
setup: () => {
cy.window().then(w =>
w.localStorage.setItem(`draft:${projectId}`, 'true')
)
},
props: {},
},
'ignores the compile from cache when stopOnFirstError mode changed': {
cached: false,
setup: () => {
cy.window().then(w =>
w.localStorage.setItem(`stop_on_first_error:${projectId}`, 'true')
)
},
props: {},
},
'ignores the compile from cache when rootDoc changed': {
cached: false,
setup: () => {},
props: {
rootDocId: 'new-root-doc-id',
rootFolder: [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: '_root_doc_id',
name: 'main.tex',
},
{
_id: 'new-root-doc-id',
name: 'new-main.tex',
},
],
folders: [],
fileRefs: [],
},
],
},
},
}
Object.entries(cases).forEach(([name, { cached, setup, props }]) => {
it(name, function () {
cy.interceptCompile({
cached: true,
regular: !cached,
})
const scope = mockScope()
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
setup()
cy.mount(
<EditorProviders scope={scope} projectId={projectId} {...props}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// wait for compile to finish
cy.waitForCompile({ pdf: true, cached, regular: !cached })
cy.contains('Your Paper')
})
})
})
it('runs a compile when the Recompile button is pressed', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// press the Recompile button => compile
cy.findByRole('button', { name: 'Recompile' }).click()
// wait for compile to finish
cy.waitForCompile({ pdf: true })
cy.contains('Your Paper')
})
it('runs a compile on `pdf:recompile` event', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.window().then(win => {
win.dispatchEvent(new CustomEvent('pdf:recompile'))
})
// wait for compile to finish
cy.waitForCompile({ pdf: true })
cy.contains('Your Paper')
})
it('does not compile while compiling', function () {
let counter = 0
cy.interceptDeferredCompile(() => counter++).then(
resolveDeferredCompile => {
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// start compiling
cy.findByRole('button', { name: 'Recompile' }).click()
cy.findByRole('button', { name: 'Compiling…' }).then(() => {
// trigger a recompile
cy.window().then(win => {
win.dispatchEvent(new CustomEvent('pdf:recompile'))
})
// finish the original compile
resolveDeferredCompile()
// wait for the original compile to finish
cy.waitForCompile().then(() => {
// NOTE: difficult to assert that a second request won't be sent, at some point
expect(counter).to.equal(1)
})
})
}
)
})
it('disables compile button while compile is running', function () {
cy.interceptDeferredCompile().then(resolveDeferredCompile => {
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.findByRole('button', { name: 'Compiling…' })
.should('be.disabled')
.then(() => resolveDeferredCompile())
cy.waitForCompile()
cy.findByRole('button', { name: 'Recompile' }).should('not.be.disabled')
})
})
it('runs a compile on doc change if autocompile is enabled', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.window().then(win => {
cy.clock()
// switch on auto compile
storeAndFireEvent(win, 'autocompile_enabled:project123', true)
// fire a doc:changed event => compile
win.dispatchEvent(new CustomEvent('doc:changed'))
// wait enough time for the compile to start
cy.tick(6000) // > AUTO_COMPILE_DEBOUNCE
cy.clock().invoke('restore')
})
// wait for compile to finish
cy.waitForCompile({ pdf: true })
cy.findByRole('button', { name: 'Recompile' })
})
it('does not run a compile on doc change if autocompile is disabled', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.window().then(win => {
cy.clock()
// make sure auto compile is switched off
storeAndFireEvent(win, 'autocompile_enabled:project123', false)
// fire a doc:changed event => no compile
win.dispatchEvent(new CustomEvent('doc:changed'))
// wait enough time for the compile to start
cy.tick(6000) // AUTO_COMPILE_DEBOUNCE
cy.clock().invoke('restore')
})
// NOTE: difficult to assert that a request hasn't been sent
cy.findByRole('button', { name: 'Recompile' })
})
it('does not run a compile on doc change if autocompile is blocked by syntax check', function () {
cy.interceptCompile()
const scope = mockScope()
// enable linting in the editor
const userSettings = { syntaxValidation: true }
// mock a linting error
scope.hasLintingError = true
cy.mount(
<EditorProviders scope={scope} userSettings={userSettings}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.window().then(win => {
cy.clock()
// switch on auto compile
storeAndFireEvent(win, 'autocompile_enabled:project123', true)
// switch on syntax checking
storeAndFireEvent(win, 'stop_on_validation_error', true)
// fire a doc:changed event => no compile
win.dispatchEvent(new CustomEvent('doc:changed'))
// wait enough time for the compile to start
cy.tick(6000) // AUTO_COMPILE_DEBOUNCE
cy.clock().invoke('restore')
})
// NOTE: difficult to assert that a request hasn't been sent
cy.findByRole('button', { name: 'Recompile' })
cy.findByText('Code check failed')
})
it('does not run a compile on doc change if the PDF preview is not open', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<Layout layout="flat" view="editor" />
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.window().then(win => {
cy.clock()
// switch on auto compile
storeAndFireEvent(win, 'autocompile_enabled:project123', true)
// fire a doc:changed event => compile
win.dispatchEvent(new CustomEvent('doc:changed'))
// wait enough time for the compile to start
cy.tick(6000) // > AUTO_COMPILE_DEBOUNCE
cy.clock().invoke('restore')
})
// NOTE: difficult to assert that a request hasn't been sent
cy.findByRole('button', { name: 'Recompile' })
})
describe('error messages', function () {
const compileErrorStatuses = {
'clear-cache':
'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
'clsi-maintenance':
'The compile servers are down for maintenance, and will be back shortly.',
'compile-in-progress':
'A previous compile is still running. Please wait a minute and try compiling again.',
exited: 'Server Error',
failure: 'No PDF',
generic: 'Server Error',
'project-too-large': 'Project too large',
'rate-limited': 'Compile rate limit hit',
terminated: 'Compilation cancelled',
timedout: 'Timed out',
'too-recently-compiled':
'This project was compiled very recently, so this compile has been skipped.',
unavailable:
'Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.',
foo: 'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
}
for (const [status, message] of Object.entries(compileErrorStatuses)) {
it(`displays error message for '${status}' status`, function () {
cy.intercept('POST', '/project/*/compile*', {
body: {
status,
clsiServerId: 'foo',
compileGroup: 'priority',
},
}).as('compile')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.wait('@compile')
cy.findByText(message)
})
}
})
it('displays expandable raw logs', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile({ pdf: true })
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByRole('button', { name: 'View PDF' })
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByRole('button', { name: 'Collapse' }).click()
})
it('displays error messages if there were validation problems', function () {
const validationProblems = {
sizeCheck: {
resources: [
{ path: 'foo/bar', kbSize: 76221 },
{ path: 'bar/baz', kbSize: 2342 },
],
},
mainFile: true,
conflictedPaths: [
{
path: 'foo/bar',
},
{
path: 'foo/baz',
},
],
}
cy.intercept('POST', '/project/*/compile*', {
body: {
status: 'validation-problems',
validationProblems,
clsiServerId: 'foo',
compileGroup: 'priority',
},
}).as('compile')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.wait('@compile')
cy.findByText('Project too large')
cy.findByText('Unknown main document')
cy.findByText('Conflicting Paths Found')
})
describe('clear cache', function () {
it('sends a clear cache request when the button is pressed', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile({ pdf: true })
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByRole('button', { name: 'Clear cached files' }).should(
'not.be.disabled'
)
const { promise, resolve } = Promise.withResolvers<void>()
cy.intercept('DELETE', '/project/*/output*', req => {
return promise
.then(() => Cypress.Promise.delay(100))
.then(() => {
req.reply({ statusCode: 204 })
})
}).as('clear-cache')
// click the button
cy.findByRole('button', { name: 'Clear cached files' }).click()
cy.findByRole('button', { name: 'Clear cached files' }).should(
'be.disabled'
)
cy.then(() => {
resolve()
})
cy.wait('@clear-cache')
cy.findByRole('button', { name: 'Clear cached files' }).should(
'not.be.disabled'
)
})
it('handle "recompile from scratch"', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile({ pdf: true })
cy.interceptCompile({ prefix: 'recompile' })
cy.intercept('DELETE', '/project/*/output*', {
statusCode: 204,
delay: 100,
}).as('clear-cache')
// show the logs UI
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByRole('button', { name: 'Clear cached files' }).should(
'not.be.disabled'
)
cy.interceptDeferredCompile().then(resolveDeferredCompile => {
cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
cy.findByRole('menuitem', {
name: 'Recompile from scratch',
}).trigger('click')
cy.findByRole('button', { name: 'Clear cached files' }).should(
'be.disabled'
)
cy.wait('@clear-cache')
cy.findByRole('button', { name: 'Compiling…' }).then(() =>
resolveDeferredCompile()
)
// wait for recompile from scratch to finish
cy.waitForCompile({ pdf: true })
cy.findByRole('button', { name: 'Recompile' })
})
})
})
describe('invalid URLs and broken PDFs', function () {
it('shows an error for an invalid URL', function () {
cy.interceptCompile()
cy.intercept('/build/*/output.pdf*', {
statusCode: 500,
body: {
message: 'something awful happened',
code: 'AWFUL_ERROR',
},
}).as('compile-pdf-error')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile()
cy.wait('@compile-pdf-error')
cy.contains('Something went wrong while rendering this PDF.')
cy.contains(
'Please try recompiling the project from scratch, and if that doesnt help, follow our troubleshooting guide.'
)
cy.findByLabelText('Page 1').should('not.exist')
})
it('shows an error for a corrupt PDF', function () {
cy.interceptCompile()
cy.intercept('/build/*/output.pdf*', {
fixture: 'build/output-corrupt.pdf,null',
}).as('compile-pdf-corrupt')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile()
cy.wait('@compile-pdf-corrupt')
cy.contains('Something went wrong while rendering this PDF.')
cy.contains(
'Please try recompiling the project from scratch, and if that doesnt help, follow our troubleshooting guide.'
)
cy.findByLabelText('Page 1').should('not.exist')
})
})
describe('human readable logs', function () {
it('shows human readable hint for undefined reference errors', function () {
cy.interceptCompile()
cy.intercept('/build/*/output.log*', {
fixture: 'build/output-human-readable.log',
}).as('compile-log')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile()
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByText(
"Reference `intorduction' on page 1 undefined on input line 11."
)
cy.findByText(
"Reference `section1' on page 1 undefined on input line 13."
)
cy.findByText('There were undefined references.')
cy.findAllByText(
/You have referenced something which has not yet been labelled/
).should('have.length', 3)
})
it('does not show human readable hint when no undefined reference errors', function () {
cy.interceptCompile()
cy.intercept('/build/*/output.log?*', {
fixture: 'build/output-undefined-references.log',
}).as('compile-log')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile()
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByText(
"Package rerunfilecheck Warning: File `output.brf' has changed. Rerun to get bibliographical references right."
)
cy.findByText(
/You have referenced something which has not yet been labelled/
).should('not.exist')
})
})
})

View File

@@ -0,0 +1,439 @@
import PdfSynctexControls from '../../../../frontend/js/features/pdf-preview/components/pdf-synctex-controls'
import { cloneDeep } from 'lodash'
import { useDetachCompileContext as useCompileContext } from '../../../../frontend/js/shared/context/detach-compile-context'
import { useFileTreeData } from '../../../../frontend/js/shared/context/file-tree-data-context'
import { useEffect } from 'react'
import { EditorProviders } from '../../helpers/editor-providers'
import { mockScope } from './scope'
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
import { FindResult } from '@/features/file-tree/util/path'
const mockHighlights = [
{
page: 1,
h: 85.03936,
v: 509.999878,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 486.089539,
width: 441.921265,
height: 8.855677,
},
]
type Position = {
page: number
offset: { top: number; left: number }
pageSize: { height: number; width: number }
}
const mockPosition: Position = {
page: 1,
offset: { top: 10, left: 10 },
pageSize: { height: 500, width: 500 },
}
const mockSelectedEntities = [{ type: 'doc' }] as FindResult[]
const WithPosition = ({ mockPosition }: { mockPosition: Position }) => {
const { setPosition } = useCompileContext()
// mock PDF scroll position update
useEffect(() => {
setPosition(mockPosition)
}, [mockPosition, setPosition])
return null
}
// mock PDF scroll position update
const setDetachedPosition = (mockPosition: Position) => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-position',
data: { value: mockPosition },
})
}
const WithSelectedEntities = ({
mockSelectedEntities = [],
}: {
mockSelectedEntities: FindResult[]
}) => {
const { setSelectedEntities } = useFileTreeData()
useEffect(() => {
setSelectedEntities(mockSelectedEntities)
}, [mockSelectedEntities, setSelectedEntities])
return null
}
describe('<PdfSynctexControls/>', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-project_id', 'test-project')
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
cy.interceptEvents()
})
it('handles clicks on sync buttons', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.get('.synctex-control-icon').should('have.length', 2)
// mock editor cursor position update
cy.window().then(win => {
win.dispatchEvent(
new CustomEvent('cursor:editor:update', {
detail: { row: 100, column: 10 },
})
)
})
cy.wrap(null).then(() => {
setDetachedPosition(mockPosition)
})
cy.interceptAsync({ pathname: '/project/*/sync/code' }, 'sync-code').then(
syncCodeResponse => {
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
cy.findByRole('button', { name: 'Go to code location in PDF' })
.should('be.disabled')
.then(() => {
syncCodeResponse.resolve({
body: { pdf: cloneDeep(mockHighlights) },
})
})
cy.wait('@sync-code')
}
)
cy.interceptAsync({ pathname: '/project/*/sync/pdf' }, 'sync-pdf').then(
syncPdfResponse => {
cy.findByRole('button', { name: /^Go to PDF location in code/ }).click()
cy.findByRole('button', { name: /^Go to PDF location in code/ })
.should('be.disabled')
.then(() => {
syncPdfResponse.resolve({
body: { code: [{ file: 'main.tex', line: 100 }] },
})
})
cy.wait('@sync-pdf')
}
)
})
it('disables button when multiple entities are selected', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities
mockSelectedEntities={
[{ type: 'doc' }, { type: 'doc' }] as FindResult[]
}
/>
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.findByRole('button', { name: 'Go to code location in PDF' }).should(
'be.disabled'
)
})
it('disables button when a file is selected', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities
mockSelectedEntities={[{ type: 'fileRef' }] as FindResult[]}
/>
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.findByRole('button', { name: 'Go to code location in PDF' }).should(
'be.disabled'
)
})
describe('with detacher role', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-detachRole', 'detacher')
})
it('does not have go to PDF location button nor arrow icon', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'not.exist'
)
cy.get('.synctex-control-icon').should('not.exist')
})
it('send set highlights action', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
// mock editor cursor position update
cy.window().then(win => {
win.dispatchEvent(
new CustomEvent('cursor:editor:update', {
detail: { row: 100, column: 10 },
})
)
})
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.interceptAsync({ pathname: '/project/*/sync/code' }, 'sync-code').then(
syncCodeResponse => {
cy.findByRole('button', {
name: 'Go to code location in PDF',
})
.should('not.be.disabled')
.click()
cy.findByRole('button', {
name: 'Go to code location in PDF',
})
.should('be.disabled')
.then(() => {
syncCodeResponse.resolve({
body: { pdf: cloneDeep(mockHighlights) },
})
})
cy.wait('@sync-code')
}
)
cy.findByRole('button', {
name: 'Go to code location in PDF',
}).should('not.be.disabled')
// synctex is called locally and the result are broadcast for the detached tab
// NOTE: can't use `.to.deep.include({…})` as it doesn't match the nested array
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detacher',
event: 'action-setHighlights',
data: { args: [mockHighlights] },
})
})
it('reacts to sync to code action', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.interceptAsync({ pathname: '/project/*/sync/pdf' }, 'sync-pdf')
.then(syncPdfResponse => {
syncPdfResponse.resolve({
body: { code: [{ file: 'main.tex', line: 100 }] },
})
testDetachChannel.postMessage({
role: 'detached',
event: 'action-sync-to-code',
data: {
args: [mockPosition],
},
})
})
.wait('@sync-pdf')
})
})
describe('with detached role', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-detachRole', 'detached')
})
it('does not have go to code location button nor arrow icon', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.findByRole('button', {
name: 'Go to code location in PDF',
}).should('not.exist')
cy.get('.synctex-control-icon').should('not.exist')
})
it('send go to code line action', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile().then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: `state-position`,
data: { value: mockPosition },
})
})
cy.findByRole('button', {
name: /^Go to PDF location in code/,
})
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'not.be.disabled'
)
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.findByRole('button', { name: /^Go to PDF location in code/ }).click()
// the button is only disabled when the state is updated
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'not.be.disabled'
)
cy.get('.synctex-spin-icon').should('not.exist')
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detached',
event: 'action-sync-to-code',
data: {
args: [{ visualOffset: 72 }],
},
})
})
it('update inflight state', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile().then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: `state-position`,
data: { value: mockPosition },
})
})
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'not.be.disabled'
)
cy.findByRole('status', { hidden: true }).should('not.exist')
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-sync-to-code-inflight',
data: { value: true },
})
})
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'be.disabled'
)
cy.findByRole('status', { hidden: true }).should('have.length', 1)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-sync-to-code-inflight',
data: { value: false },
})
})
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'not.be.disabled'
)
cy.get('.synctex-spin-icon').should('not.exist')
})
})
})

View File

@@ -0,0 +1,24 @@
import { EditorView } from '@codemirror/view'
export const mockScope = () => ({
settings: {
syntaxValidation: false,
pdfViewer: 'pdfjs',
},
editor: {
open_doc_name: 'main.tex',
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
hasBufferedOps: () => false,
},
view: new EditorView({
doc: '\\documentclass{article}',
}),
},
hasLintingError: false,
ui: {
view: 'editor',
pdfLayout: 'sideBySide',
},
})

View File

@@ -0,0 +1,133 @@
import GroupInvitationNotification from '@/features/project-list/components/notifications/groups/group-invitation/group-invitation'
import { NotificationGroupInvitation } from '../../../../../types/project/dashboard/notification'
type Props = {
notification: NotificationGroupInvitation
}
function GroupInvitation({ notification }: Props) {
return (
<div className="user-notifications">
<ul className="list-unstyled">
<GroupInvitationNotification notification={notification} />
</ul>
</div>
)
}
describe('<GroupInvitationNotification />', function () {
const notification: NotificationGroupInvitation = {
_id: 1,
templateKey: 'notification_group_invitation',
messageOpts: {
inviterName: 'inviter@overleaf.com',
token: '123abc',
managedUsersEnabled: false,
},
}
beforeEach(function () {
cy.intercept(
'PUT',
`/subscription/invites/${notification.messageOpts.token}`,
{
statusCode: 204,
}
).as('acceptInvite')
})
describe('user without existing personal subscription', function () {
it('is able to join group successfully', function () {
cy.mount(<GroupInvitation notification={notification} />)
cy.findByRole('alert')
cy.contains(
'inviter@overleaf.com has invited you to join a group subscription on Overleaf'
)
cy.findByRole('button', { name: 'Join now' }).click()
cy.wait('@acceptInvite')
cy.findByText(
'Congratulations! Youve successfully joined the group subscription.'
)
cy.findByRole('button', { name: /close/i }).click()
cy.findByRole('alert').should('not.exist')
})
})
describe('user with existing personal subscription', function () {
beforeEach(function () {
window.metaAttributesCache.set(
'ol-hasIndividualRecurlySubscription',
true
)
})
it('is able to join group successfully without cancelling personal subscription', function () {
cy.mount(<GroupInvitation notification={notification} />)
cy.findByRole('alert')
cy.contains(
'inviter@overleaf.com has invited you to join a group Overleaf subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it?'
)
cy.findByRole('button', { name: 'Not now' }).click()
cy.contains(
'inviter@overleaf.com has invited you to join a group subscription on Overleaf'
)
cy.findByRole('button', { name: 'Join now' }).click()
cy.wait('@acceptInvite')
cy.findByText(
'Congratulations! Youve successfully joined the group subscription.'
)
cy.findByRole('button', { name: /close/i }).click()
cy.findByRole('alert').should('not.exist')
})
it('is able to join group successfully after cancelling personal subscription', function () {
cy.intercept('POST', '/user/subscription/cancel', {
statusCode: 204,
}).as('cancelPersonalSubscription')
cy.mount(<GroupInvitation notification={notification} />)
cy.findByRole('alert')
cy.contains(
'inviter@overleaf.com has invited you to join a group Overleaf subscription. If you join this group, you may not need your individual subscription. Would you like to cancel it?'
)
cy.findByRole('button', { name: 'Cancel my subscription' }).click()
cy.wait('@cancelPersonalSubscription')
cy.contains(
'inviter@overleaf.com has invited you to join a group subscription on Overleaf'
)
cy.findByRole('button', { name: 'Join now' }).click()
cy.wait('@acceptInvite')
cy.findByText(
'Congratulations! Youve successfully joined the group subscription.'
)
cy.findByRole('button', { name: /close/i }).click()
cy.findByRole('alert').should('not.exist')
})
})
})

View File

@@ -0,0 +1,17 @@
import BetaBadge from '../../../../frontend/js/shared/components/beta-badge'
describe('beta badge', function () {
it('renders the url and tooltip text', function () {
cy.mount(
<BetaBadge
link={{ href: '/foo' }}
tooltip={{
id: 'test-tooltip',
text: 'This is a test',
}}
/>
)
cy.get('a[href="/foo"]').contains('This is a test')
})
})

View File

@@ -0,0 +1,50 @@
import React from 'react'
import LanguagePicker from '../../../../frontend/js/features/ui/components/bootstrap-5/language-picker'
import getMeta from '@/utils/meta'
import exposedSettings from '../../../../modules/admin-panel/test/frontend/js/features/user/data/exposedSettings'
describe('LanguagePicker', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-i18n', {
currentLangCode: 'en',
})
window.metaAttributesCache.set('ol-footer', {
showThinFooter: false,
translatedLanguages: {
en: 'English',
fr: 'Français',
es: 'Español',
},
subdomainLang: {
en: { lngCode: 'en', url: 'overleaf.com' },
fr: { lngCode: 'fr', url: 'fr.overleaf.com' },
es: { lngCode: 'es', url: 'es.overleaf.com' },
},
})
Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
})
it('renders the language picker with the current language', function () {
cy.mount(<LanguagePicker showHeader />)
cy.get('#language-picker-toggle').should('contain', 'English')
})
it('opens the dropdown and lists available languages', function () {
cy.mount(<LanguagePicker showHeader />)
cy.get('#language-picker-toggle').click()
cy.get('.dropdown-menu').within(() => {
cy.contains('English').should('exist')
cy.contains('Français').should('exist')
cy.contains('Español').should('exist')
})
})
it('changes the language and updates the URL when a language is selected', function () {
cy.mount(<LanguagePicker showHeader />)
cy.get('#language-picker-toggle').should('exist').click()
cy.contains('Français').click()
cy.url().should('include', 'fr.overleaf.com')
})
})

View File

@@ -0,0 +1,30 @@
import MaterialIcon from '@/shared/components/material-icon'
import unfilledIconTypes from '../../../../frontend/fonts/material-symbols/unfilled-symbols.mjs'
const FONT_SIZE = 40
describe('MaterialIcon', function () {
describe('Filled', function () {
it('contains symbols', function () {
cy.mount(<MaterialIcon type="home" style={{ fontSize: FONT_SIZE }} />)
cy.get('.material-symbols').as('icon')
cy.get('@icon')
.invoke('width')
.should('be.within', FONT_SIZE - 1, FONT_SIZE + 1)
})
})
describe('Unfilled', function () {
it('Contain all unfilled symbol', function () {
for (const type of unfilledIconTypes) {
cy.mount(
<MaterialIcon type={type} unfilled style={{ fontSize: FONT_SIZE }} />
)
cy.get('.material-symbols').as('icon')
cy.get('@icon')
.invoke('width')
.should('be.within', FONT_SIZE - 1, FONT_SIZE + 1)
}
})
})
})

View File

@@ -0,0 +1,345 @@
import { useCallback, FormEvent } from 'react'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormControl from '@/features/ui/components/ol/ol-form-control'
import {
Select,
SelectProps,
} from '../../../../frontend/js/shared/components/select'
const testData = [1, 2, 3].map(index => ({
key: index,
value: `Demo item ${index}`,
sub: `Subtitle ${index}`,
}))
type RenderProps = Partial<SelectProps<(typeof testData)[number]>> & {
onSubmit?: (formData: object) => void
}
function render(props: RenderProps) {
const submitHandler = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (props.onSubmit) {
const formData = new FormData(event.target as HTMLFormElement)
// a plain object is more convenient to work later with assertions
props.onSubmit(Object.fromEntries(formData.entries()))
}
}
cy.mount(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
}}
>
<form onSubmit={submitHandler}>
<Select
items={testData}
itemToString={x => String(x?.value)}
label={props.label}
name="select_control"
defaultText={props.defaultText}
defaultItem={props.defaultItem}
itemToSubtitle={props.itemToSubtitle}
itemToKey={x => String(x.key)}
onSelectedItemChanged={props.onSelectedItemChanged}
selected={props.selected}
disabled={props.disabled}
itemToDisabled={props.itemToDisabled}
optionalLabel={props.optionalLabel}
loading={props.loading}
selectedIcon={props.selectedIcon}
/>
<button type="submit">submit</button>
</form>
</div>
)
}
describe('<Select />', function () {
describe('initial rendering', function () {
it('renders default text', function () {
render({ defaultText: 'Choose an item' })
cy.findByTestId('spinner').should('not.exist')
cy.findByRole('textbox', { name: 'Choose an item' })
})
it('renders default item', function () {
render({ defaultItem: testData[2] })
cy.findByRole('textbox', { name: 'Demo item 3' })
})
it('default item takes precedence over default text', function () {
render({ defaultText: 'Choose an item', defaultItem: testData[2] })
cy.findByRole('textbox', { name: 'Demo item 3' })
})
it('renders label', function () {
render({
defaultText: 'Choose an item',
label: 'test label',
optionalLabel: false,
})
cy.findByRole('textbox', { name: 'test label' })
cy.findByRole('textbox', { name: '(Optional)' }).should('not.exist')
})
it('renders optional label', function () {
render({
defaultText: 'Choose an item',
label: 'test label',
optionalLabel: true,
})
cy.findByRole('textbox', { name: 'test label (Optional)' })
})
it('renders a spinner while loading when there is a label', function () {
render({
defaultText: 'Choose an item',
label: 'test label',
loading: true,
})
cy.findByTestId('spinner')
})
it('does not render a spinner while loading if there is no label', function () {
render({
defaultText: 'Choose an item',
loading: true,
})
cy.findByTestId('spinner').should('not.exist')
})
})
describe('items rendering', function () {
it('renders all items', function () {
render({ defaultText: 'Choose an item' })
cy.findByRole('textbox', { name: 'Choose an item' }).click()
cy.findByRole('option', { name: 'Demo item 1' })
cy.findByRole('option', { name: 'Demo item 2' })
cy.findByRole('option', { name: 'Demo item 3' })
})
it('renders subtitles', function () {
render({
defaultText: 'Choose an item',
itemToSubtitle: x => String(x?.sub),
})
cy.findByRole('textbox', { name: 'Choose an item' }).click()
cy.findByRole('option', { name: 'Demo item 1 Subtitle 1' })
cy.findByRole('option', { name: 'Demo item 2 Subtitle 2' })
cy.findByRole('option', { name: 'Demo item 3 Subtitle 3' })
})
})
describe('item selection', function () {
it('cannot select an item when disabled', function () {
render({ defaultText: 'Choose an item', disabled: true })
cy.findByRole('textbox', { name: 'Choose an item' }).click({
force: true,
})
cy.findByRole('option', { name: 'Demo item 1' }).should('not.exist')
cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
cy.findByRole('option', { name: 'Demo item 3' }).should('not.exist')
cy.findByRole('textbox', { name: 'Choose an item' })
})
it('renders only the selected item after selection', function () {
render({ defaultText: 'Choose an item' })
cy.findByRole('textbox', { name: 'Choose an item' }).click()
cy.findByRole('option', { name: 'Demo item 1' })
cy.findByRole('option', { name: 'Demo item 2' })
cy.findByRole('option', { name: 'Demo item 3' }).click()
cy.findByRole('textbox', { name: 'Choose an item' }).should('not.exist')
cy.findByRole('option', { name: 'Demo item 1' }).should('not.exist')
cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
cy.findByRole('textbox', { name: 'Demo item 3' })
})
it('invokes callback after selection', function () {
const selectionHandler = cy.stub().as('selectionHandler')
render({
defaultText: 'Choose an item',
onSelectedItemChanged: selectionHandler,
})
cy.findByRole('textbox', { name: 'Choose an item' }).click()
cy.findByRole('option', { name: 'Demo item 2' }).click()
cy.get('@selectionHandler').should(
'have.been.calledOnceWith',
testData[1]
)
})
})
describe('when the form is submitted', function () {
it('populates FormData with the default selected item', function () {
const submitHandler = cy.stub().as('submitHandler')
render({ defaultItem: testData[1], onSubmit: submitHandler })
cy.findByText('submit').click()
cy.get('@submitHandler').should('have.been.calledOnceWith', {
select_control: 'Demo item 2',
})
})
it('populates FormData with the selected item', function () {
const submitHandler = cy.stub().as('submitHandler')
render({ defaultItem: testData[1], onSubmit: submitHandler })
cy.findByRole('textbox', { name: 'Demo item 2' }).click() // open dropdown
cy.findByText('Demo item 3').click() // choose a different item
cy.findByText('submit').click()
cy.get('@submitHandler').should('have.been.calledOnceWith', {
select_control: 'Demo item 3',
})
})
it('does not populate FormData when no item is selected', function () {
const submitHandler = cy.stub().as('submitHandler')
render({ defaultText: 'Choose an item', onSubmit: submitHandler })
cy.findByText('submit').click()
cy.get('@submitHandler').should('have.been.calledOnceWith', {})
})
})
describe('with react-bootstrap forms', function () {
type FormWithSelectProps = {
onSubmit: (formData: object) => void
}
const FormWithSelect = ({ onSubmit }: FormWithSelectProps) => {
const selectComponent = useCallback(
() => (
<Select
name="select_control"
items={testData}
defaultItem={testData[0]}
itemToString={x => String(x?.value)}
itemToKey={x => String(x.key)}
/>
),
[]
)
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const formData = new FormData(event.target as HTMLFormElement)
// a plain object is more convenient to work later with assertions
onSubmit(Object.fromEntries(formData.entries()))
}
return (
<OLForm onSubmit={handleSubmit}>
<OLFormControl as={selectComponent} />
<OLButton type="submit">submit</OLButton>
</OLForm>
)
}
it('populates FormData with the selected item when the form is submitted', function () {
const submitHandler = cy.stub().as('submitHandler')
cy.mount(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
}}
>
<FormWithSelect onSubmit={submitHandler} />
</div>
)
cy.findByRole('textbox', { name: 'Demo item 1' }).click() // open dropdown
cy.findByRole('option', { name: 'Demo item 3' }).click() // choose a different item
cy.findByText('submit').click()
cy.get('@submitHandler').should('have.been.calledOnceWith', {
select_control: 'Demo item 3',
})
})
})
describe('keyboard navigation', function () {
it('can select an item using the keyboard', function () {
render({ defaultText: 'Choose an item' })
cy.findByRole('textbox', { name: 'Choose an item' }).type(
'{Enter}{downArrow}{Enter}',
{ force: true }
)
cy.findByRole('textbox', { name: 'Demo item 1' }).should('exist')
cy.findByRole('option', { name: 'Demo item 2' }).should('not.exist')
})
})
describe('selectedIcon', function () {
it('renders a selected icon if the prop is set', function () {
render({
defaultText: 'Choose an item',
selectedIcon: true,
})
cy.findByRole('textbox', { name: 'Choose an item' }).click()
cy.findByRole('option', { name: 'Demo item 1' }).click()
cy.findByRole('textbox', { name: 'Demo item 1' }).click()
cy.findByText('check').should('exist')
})
it('renders no selected icon if the prop is not set', function () {
render({
defaultText: 'Choose an item',
selectedIcon: false,
})
cy.findByRole('textbox', { name: 'Choose an item' }).click()
cy.findByRole('option', { name: 'Demo item 1' }).click()
cy.findByRole('textbox', { name: 'Demo item 1' }).click()
cy.findByText('check').should('not.exist')
})
})
describe('itemToDisabled', function () {
it('prevents selecting a disabled item', function () {
render({
defaultText: 'Choose an item',
itemToDisabled: x => x?.key === 2,
})
cy.findByRole('textbox', { name: 'Choose an item' }).click()
cy.findByRole('option', { name: 'Demo item 2' }).click({ force: true })
// still showing other list items
cy.findByRole('option', { name: 'Demo item 3' }).should('exist')
cy.findByRole('option', { name: 'Demo item 1' }).click()
// clicking an enabled item dismisses the list
cy.findByRole('option', { name: 'Demo item 3' }).should('not.exist')
})
})
describe('selected', function () {
it('shows the item provided in the selected prop', function () {
render({
defaultText: 'Choose an item',
selected: testData[1],
})
cy.findByRole('textbox', { name: 'Demo item 2' }).should('exist')
})
it('should show default text when selected is null', function () {
render({
selected: null,
defaultText: 'Choose an item',
})
cy.findByRole('textbox', { name: 'Choose an item' }).should('exist')
})
})
})

View File

@@ -0,0 +1,228 @@
import SplitTestBadge from '../../../../frontend/js/shared/components/split-test-badge'
import { EditorProviders } from '../../helpers/editor-providers'
describe('split test badge', function () {
it('renders an alpha badge with the url and tooltip text', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-splitTestVariants', {
'cypress-test': 'active',
})
win.metaAttributesCache.set('ol-splitTestInfo', {
'cypress-test': {
phase: 'alpha',
badgeInfo: {
url: '/alpha/participate',
tooltipText: 'This is an alpha feature',
},
},
})
})
cy.mount(
<EditorProviders>
<SplitTestBadge
splitTestName="cypress-test"
displayOnVariants={['active']}
/>
</EditorProviders>
)
cy.findByRole('link', { name: /this is an alpha feature/i })
.should('have.attr', 'href', '/alpha/participate')
.find('.badge')
.contains('α')
})
it('does not render the alpha badge when user is not assigned to the variant', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-splitTestVariants', {
'cypress-test': 'default',
})
win.metaAttributesCache.set('ol-splitTestInfo', {
'cypress-test': {
phase: 'alpha',
badgeInfo: {
url: '/alpha/participate',
tooltipText: 'This is an alpha feature',
},
},
})
})
cy.mount(
<EditorProviders>
<SplitTestBadge
splitTestName="cypress-test"
displayOnVariants={['active']}
/>
</EditorProviders>
)
cy.get('.badge').should('not.exist')
})
it('renders a beta badge with the url and tooltip text', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-splitTestVariants', {
'cypress-test': 'active',
})
win.metaAttributesCache.set('ol-splitTestInfo', {
'cypress-test': {
phase: 'beta',
badgeInfo: {
url: '/beta/participate',
tooltipText: 'This is a beta feature',
},
},
})
})
cy.mount(
<EditorProviders>
<SplitTestBadge
splitTestName="cypress-test"
displayOnVariants={['active']}
/>
</EditorProviders>
)
cy.findByRole('link', { name: /this is a beta feature/i })
.should('have.attr', 'href', '/beta/participate')
.find('.badge')
.contains('β')
})
it('does not render the beta badge when user is not assigned to the variant', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-splitTestVariants', {
'cypress-test': 'default',
})
win.metaAttributesCache.set('ol-splitTestInfo', {
'cypress-test': {
phase: 'beta',
badgeInfo: {
url: '/beta/participate',
tooltipText: 'This is a beta feature',
},
},
})
})
cy.mount(
<EditorProviders>
<SplitTestBadge
splitTestName="cypress-test"
displayOnVariants={['active']}
/>
</EditorProviders>
)
cy.get('.badge').should('not.exist')
})
it('renders an info badge with the url and tooltip text', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-splitTestVariants', {
'cypress-test': 'active',
})
win.metaAttributesCache.set('ol-splitTestInfo', {
'cypress-test': {
phase: 'release',
badgeInfo: {
url: '/feedback/form',
tooltipText: 'This is a new feature',
},
},
})
})
cy.mount(
<EditorProviders>
<SplitTestBadge
splitTestName="cypress-test"
displayOnVariants={['active']}
/>
</EditorProviders>
)
cy.findByRole('link', { name: /this is a new feature/i })
.should('have.attr', 'href', '/feedback/form')
.find('.info-badge')
})
it('does not render the info badge when user is not assigned to the variant', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-splitTestVariants', {
'cypress-test': 'default',
})
win.metaAttributesCache.set('ol-splitTestInfo', {
'cypress-test': {
phase: 'release',
badgeInfo: {
url: '/feedback/form',
tooltipText: 'This is a new feature',
},
},
})
})
cy.mount(
<EditorProviders>
<SplitTestBadge
splitTestName="cypress-test"
displayOnVariants={['active']}
/>
</EditorProviders>
)
cy.get('.badge').should('not.exist')
})
it('does not render the badge when no split test info is available', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-splitTestVariants', {
'cypress-test': 'active',
})
win.metaAttributesCache.set('ol-splitTestInfo', {})
})
cy.mount(
<EditorProviders>
<SplitTestBadge
splitTestName="cypress-test"
displayOnVariants={['active']}
/>
</EditorProviders>
)
cy.get('.badge').should('not.exist')
})
it('default badge url and text are used when not provided', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-splitTestVariants', {
'cypress-test': 'active',
})
win.metaAttributesCache.set('ol-splitTestInfo', {
'cypress-test': {
phase: 'release',
},
})
})
cy.mount(
<EditorProviders>
<SplitTestBadge
splitTestName="cypress-test"
displayOnVariants={['active']}
/>
</EditorProviders>
)
cy.findByRole('link', {
name: /we are testing this new feature.*click to give feedback/i,
})
.should('have.attr', 'href', '/beta/participate')
.find('.info-badge')
})
})

View File

@@ -0,0 +1,95 @@
import StartFreeTrialButton from '../../../../frontend/js/shared/components/start-free-trial-button'
import getMeta from '@/utils/meta'
describe('start free trial button', function () {
beforeEach(function () {
cy.intercept('POST', '/event/paywall-prompt', {
statusCode: 204,
}).as('event-paywall-prompt')
cy.intercept('POST', '/event/paywall-click', {
statusCode: 204,
}).as('event-paywall-click')
getMeta('ol-ExposedSettings').isOverleaf = true
})
it('renders the button with default text', function () {
cy.mount(<StartFreeTrialButton source="cypress-test" />)
cy.wait('@event-paywall-prompt')
.its('request.body.paywall-type')
.should('eq', 'cypress-test')
cy.get('button').contains('Start Free Trial!')
})
it('renders the button with custom text', function () {
cy.mount(
<StartFreeTrialButton source="cypress-test">
Some Custom Text
</StartFreeTrialButton>
)
cy.wait('@event-paywall-prompt')
.its('request.body.paywall-type')
.should('eq', 'cypress-test')
cy.get('button').contains('Some Custom Text')
})
it('renders the button with styled button', function () {
cy.mount(
<StartFreeTrialButton
source="cypress-test"
buttonProps={{
variant: 'danger',
size: 'lg',
}}
/>
)
cy.wait('@event-paywall-prompt')
cy.get('button.btn.btn-danger.btn-lg').contains('Start Free Trial!')
})
it('renders the button with custom class', function () {
cy.mount(
<StartFreeTrialButton
source="cypress-test"
buttonProps={{ className: 'ct-test-class' }}
/>
)
cy.wait('@event-paywall-prompt')
.its('request.body.paywall-type')
.should('eq', 'cypress-test')
cy.get('.ct-test-class').contains('Start Free Trial!')
})
it('calls onClick callback and opens a new tab to the subscription page on click', function () {
const onClickStub = cy.stub()
cy.mount(
<StartFreeTrialButton source="cypress-test" handleClick={onClickStub} />
)
cy.wait('@event-paywall-prompt')
cy.window().then(win => {
cy.stub(win, 'open').as('Open')
})
cy.get('button.btn').contains('Start Free Trial!').click()
cy.wrap(null).then(() => {
cy.wait('@event-paywall-click')
.its('request.body.paywall-type')
.should('eq', 'cypress-test')
cy.get('@Open').should(
'have.been.calledOnceWithExactly',
'/user/subscription/choose-your-plan?itm_campaign=cypress-test'
)
expect(onClickStub).to.be.called
})
})
})

View File

@@ -0,0 +1,35 @@
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
describe('<OLTooltip />', function () {
it('calls the bound handler and blur then hides text on click', function () {
const clickHandler = cy.stub().as('clickHandler')
const blurHandler = cy.stub().as('blurHandler')
const description = 'foo'
const btnText = 'Click me!'
cy.mount(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
}}
>
<OLTooltip id="abc" description={description}>
<button onClick={clickHandler} onBlur={blurHandler}>
{btnText}
</button>
</OLTooltip>
</div>
)
cy.findByRole('button', { name: btnText }).as('button')
cy.get('@button').trigger('mouseover')
cy.findByText(description)
cy.get('@button').click()
cy.get('@clickHandler').should('have.been.calledOnce')
cy.get('@blurHandler').should('have.been.calledOnce')
cy.findByText(description).should('not.exist')
})
})

View File

@@ -0,0 +1,164 @@
import TokenAccessPage from '@/features/token-access/components/token-access-root'
import { location } from '@/shared/components/location'
describe('<TokenAccessPage/>', function () {
// this is a URL for a read-only token, but the process is the same for read-write tokens
const url = '/read/123/grant'
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-postUrl', url)
win.metaAttributesCache.set('ol-user', { email: 'test@example.com' })
})
})
it('handles a successful token access request', function () {
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
requireAccept: { projectName: 'Test Project' },
},
}
).as('grantRequest')
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest').then(interception => {
expect(interception.request.body.confirmedByUser).to.be.false
})
cy.get('.link-sharing-invite-header').should(
'have.text',
['Youre joining', 'Test Project', 'as test@example.com'].join('')
)
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
redirect: '/project/123',
},
}
).as('confirmedGrantRequest')
cy.stub(location, 'replace').as('replaceLocation')
cy.findByRole('button', { name: 'OK, join project' }).click()
cy.wait('@confirmedGrantRequest').then(interception => {
expect(interception.request.body.confirmedByUser).to.be.true
})
cy.get('@replaceLocation').should(
'have.been.calledOnceWith',
'/project/123'
)
})
it('handles a project not found response', function () {
cy.intercept({ method: 'post', url, times: 1 }, { statusCode: 404 }).as(
'grantRequest'
)
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest')
cy.get('h3').should('have.text', 'Join Project')
cy.get('h4').should('have.text', 'Project not found')
cy.findByRole('button', { name: 'Join Project' }).should('not.exist')
})
it('handles a redirect response', function () {
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
redirect: '/restricted',
},
}
).as('grantRequest')
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest')
cy.get('@replaceLocation').should('have.been.calledOnceWith', '/restricted')
})
it('handles a v1 "must login" response', function () {
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
v1Import: { status: 'mustLogin' },
},
}
).as('grantRequest')
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest')
cy.get('h1').should('have.text', 'Please log in')
cy.findByRole('link', { name: 'Log in to access project' })
.should('have.attr', 'href')
.and('match', /^\/login\?redir=/)
})
it('handles a v1 "cannot import" response', function () {
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
v1Import: { status: 'cannotImport' },
},
}
).as('grantRequest')
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest')
cy.get('h1').should('have.text', 'Overleaf v1 Project')
cy.get('h2').should('have.text', 'Cannot Access Overleaf v1 Project')
})
it('handles a v1 "can download zip" response', function () {
cy.intercept(
{ method: 'post', url, times: 1 },
{
body: {
v1Import: {
status: 'canDownloadZip',
projectId: '123',
name: 'Test Project',
},
},
}
).as('grantRequest')
cy.stub(location, 'replace').as('replaceLocation')
cy.mount(<TokenAccessPage />)
cy.wait('@grantRequest')
cy.get('h1').should('have.text', 'Overleaf v1 Project')
cy.findByRole('link', { name: 'Download project zip file' }).should(
'have.attr',
'href',
'/overleaf/project/123/download/zip'
)
})
})

View File

@@ -0,0 +1,10 @@
/* eslint-disable no-console */
import { debugConsole } from '@/utils/debugging'
if (process.env.VERBOSE_LOGGING === 'true') {
debugConsole.debug = console.debug
debugConsole.log = console.log
} else {
debugConsole.warn = () => {}
debugConsole.error = () => {}
}

View File

@@ -0,0 +1,123 @@
import { expect } from 'chai'
import {
fireEvent,
screen,
waitForElementToBeRemoved,
} from '@testing-library/react'
import fetchMock from 'fetch-mock'
import ChatPane from '../../../../../frontend/js/features/chat/components/chat-pane'
import {
cleanUpContext,
renderWithEditorContext,
} from '../../../helpers/render-with-context'
import { stubMathJax, tearDownMathJaxStubs } from './stubs'
describe('<ChatPane />', function () {
const user = {
id: 'fake_user',
first_name: 'fake_user_first_name',
email: 'fake@example.com',
}
beforeEach(function () {
window.metaAttributesCache.set('ol-user', user)
window.metaAttributesCache.set('ol-chatEnabled', true)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
const testMessages = [
{
id: 'msg_1',
content: 'a message',
user,
timestamp: new Date().getTime(),
},
{
id: 'msg_2',
content: 'another message',
user,
timestamp: new Date().getTime(),
},
]
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
cleanUpContext()
stubMathJax()
})
afterEach(function () {
tearDownMathJaxStubs()
})
it('renders multiple messages', async function () {
fetchMock.get(/messages/, testMessages)
renderWithEditorContext(<ChatPane />, { user })
await screen.findByText('a message')
await screen.findByText('another message')
})
it('provides error message with reload button on FetchError', async function () {
fetchMock.get(/messages/, 500)
renderWithEditorContext(<ChatPane />, { user })
// should have hit a FetchError and will prompt user to reconnect
await screen.findByText('Try again')
// bring chat back up
fetchMock.removeRoutes().clearHistory()
fetchMock.get(/messages/, [])
const reconnectButton = screen.getByRole('button', {
name: 'Try again',
})
expect(reconnectButton).to.exist
// should now reconnect with placeholder message
fireEvent.click(reconnectButton)
await screen.findByText('Send your first message to your collaborators')
})
it('a loading spinner is rendered while the messages are loading, then disappears', async function () {
fetchMock.get(/messages/, [], { delay: 1000 })
renderWithEditorContext(<ChatPane />, { user })
// not displayed initially
expect(screen.queryByText('Loading…')).to.not.exist
// eventually displayed
await screen.findByText('Loading…')
// eventually removed when the fetch call returns
await waitForElementToBeRemoved(() => screen.getByText('Loading…'))
})
describe('"send your first message" placeholder', function () {
it('is rendered when there are no messages ', async function () {
fetchMock.get(/messages/, [])
renderWithEditorContext(<ChatPane />, { user })
await screen.findByText('Send your first message to your collaborators')
})
it('is not rendered when messages are displayed', function () {
fetchMock.get(/messages/, testMessages)
renderWithEditorContext(<ChatPane />, { user })
expect(
screen.queryByText('Send your first message to your collaborators')
).to.not.exist
})
})
})

View File

@@ -0,0 +1,57 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { screen, render, fireEvent } from '@testing-library/react'
import MessageInput from '../../../../../frontend/js/features/chat/components/message-input'
describe('<MessageInput />', function () {
let resetUnreadMessages, sendMessage
beforeEach(function () {
resetUnreadMessages = sinon.stub()
sendMessage = sinon.stub()
})
it('renders successfully', function () {
render(
<MessageInput
sendMessage={sendMessage}
resetUnreadMessages={resetUnreadMessages}
/>
)
screen.getByLabelText('Send a message to your collaborators')
})
it('sends a message after typing and hitting enter', function () {
render(
<MessageInput
sendMessage={sendMessage}
resetUnreadMessages={resetUnreadMessages}
/>
)
const input = screen.getByRole('textbox')
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(
<MessageInput
sendMessage={sendMessage}
resetUnreadMessages={resetUnreadMessages}
/>
)
const input = screen.getByPlaceholderText(
'Send a message to your collaborators…'
)
fireEvent.click(input)
expect(resetUnreadMessages).to.be.calledOnce
})
})

View File

@@ -0,0 +1,119 @@
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 { stubMathJax, tearDownMathJaxStubs } from './stubs'
import { UserProvider } from '@/shared/context/user-context'
describe('<MessageList />', function () {
const currentUser = {
id: 'fake_user',
first_name: 'fake_user_first_name',
email: 'fake@example.com',
}
function createMessages() {
return [
{
id: '1',
contents: ['a message'],
user: currentUser,
timestamp: new Date().getTime(),
},
{
id: '2',
contents: ['another message'],
user: currentUser,
timestamp: new Date().getTime(),
},
]
}
before(function () {
stubMathJax()
})
after(function () {
tearDownMathJaxStubs()
})
let olUser
beforeEach(function () {
olUser = window.metaAttributesCache.get('ol-user')
window.metaAttributesCache.set('ol-user', currentUser)
})
afterEach(function () {
window.metaAttributesCache.set('ol-user', olUser)
})
it('renders multiple messages', function () {
render(
<UserProvider>
<MessageList
userId={currentUser.id}
messages={createMessages()}
resetUnreadMessages={() => {}}
/>
</UserProvider>
)
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).getTime()
msgs[1].timestamp = new Date(2019, 6, 3, 4, 27).getTime()
render(
<UserProvider>
<MessageList
userId={currentUser.id}
messages={msgs}
resetUnreadMessages={() => {}}
/>
</UserProvider>
)
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).getTime()
msgs[1].timestamp = new Date(2019, 6, 3, 4, 31).getTime()
render(
<UserProvider>
<MessageList
userId={currentUser.id}
messages={msgs}
resetUnreadMessages={() => {}}
/>
</UserProvider>
)
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(
<UserProvider>
<MessageList
userId={currentUser.id}
messages={createMessages()}
resetUnreadMessages={resetUnreadMessages}
/>
</UserProvider>
)
fireEvent.click(screen.getByRole('list'))
expect(resetUnreadMessages).to.be.calledOnce
})
})

View File

@@ -0,0 +1,105 @@
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'
describe('<Message />', function () {
const currentUser = {
id: 'fake_user',
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 = {
contents: ['a message'],
user: currentUser,
}
render(<Message message={message} fromSelf />)
screen.getByText('a message')
})
it('renders a message with multiple contents', function () {
const message = {
contents: ['a message', 'another message'],
user: currentUser,
}
render(<Message message={message} fromSelf />)
screen.getByText('a message')
screen.getByText('another message')
})
it('renders HTML links within messages', function () {
const message = {
contents: [
'a message with a <a href="https://overleaf.com">link to Overleaf</a>',
],
user: currentUser,
}
render(<Message message={message} fromSelf />)
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(<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 = {
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(<Message message={message} />)
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(<Message message={msg} />)
expect(screen.queryByText(otherUser.first_name)).to.not.exist
screen.getByText(msg.user.email)
})
})
})

View File

@@ -0,0 +1,14 @@
import sinon from 'sinon'
export function stubMathJax() {
window.MathJax = {
Hub: {
Queue: sinon.stub(),
config: { tex2jax: { inlineMath: [['$', '$']] } },
},
}
}
export function tearDownMathJaxStubs() {
delete window.MathJax
}

View File

@@ -0,0 +1,614 @@
// Disable prop type checks for test harnesses
/* eslint-disable react/prop-types */
import { renderHook, act } from '@testing-library/react-hooks/dom'
import { expect } from 'chai'
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
import {
useChatContext,
chatClientIdGenerator,
} from '@/features/chat/context/chat-context'
import { cleanUpContext } from '../../../helpers/render-with-context'
import { stubMathJax, tearDownMathJaxStubs } from '../components/stubs'
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
import { EditorProviders } from '../../../helpers/editor-providers'
describe('ChatContext', function () {
const user = {
id: 'fake_user',
first_name: 'fake_user_first_name',
email: 'fake@example.com',
}
const uuidValue = '00000000-0000-0000-0000-000000000000'
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
cleanUpContext()
stubMathJax()
window.metaAttributesCache.set('ol-user', user)
window.metaAttributesCache.set('ol-chatEnabled', true)
this.stub = sinon.stub(chatClientIdGenerator, 'generate').returns(uuidValue)
})
afterEach(function () {
tearDownMathJaxStubs()
this.stub.restore()
})
describe('socket connection', function () {
beforeEach(function () {
// Mock GET messages to return no messages
// FIXME?
// fetchMock.get('express:/project/:projectId/messages', [])
// Mock POST new message to return 200
fetchMock.post('express:/project/:projectId/messages', 200)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('subscribes when mounted', function () {
const socket = new SocketIOMock()
renderChatContextHook({ socket })
expect(socket.countEventListeners('new-chat-message')).to.equal(1)
})
it('unsubscribes when unmounted', function () {
const socket = new SocketIOMock()
const { unmount } = renderChatContextHook({ socket })
unmount()
expect(socket.countEventListeners('new-chat-message')).to.equal(0)
})
it('adds received messages to the list', async function () {
// Mock socket: we only need to emit events, not mock actual connections
const socket = new SocketIOMock()
const { result, waitForNextUpdate } = renderChatContextHook({
socket,
})
// Wait until initial messages have loaded
result.current.loadInitialMessages()
await waitForNextUpdate()
// No messages shown at first
expect(result.current.messages).to.deep.equal([])
// Mock message being received from another user
socket.emitToClient('new-chat-message', {
id: 'msg_1',
content: 'new message',
timestamp: Date.now(),
user: {
id: 'another_fake_user',
first_name: 'another_fake_user_first_name',
email: 'another_fake@example.com',
},
})
const message = result.current.messages[0]
expect(message.id).to.equal('msg_1')
expect(message.contents).to.deep.equal(['new message'])
})
it('deduplicate messages from preloading', async function () {
// Mock socket: we only need to emit events, not mock actual connections
const socket = new SocketIOMock()
const { result, waitForNextUpdate } = renderChatContextHook({
socket,
})
fetchMock.get('express:/project/:projectId/messages', [
{
id: 'msg_1',
content: 'new message',
timestamp: Date.now(),
user: {
id: 'another_fake_user',
first_name: 'another_fake_user_first_name',
email: 'another_fake@example.com',
},
},
])
// Mock message being received from another user
socket.emitToClient('new-chat-message', {
id: 'msg_1',
content: 'new message',
timestamp: Date.now(),
user: {
id: 'another_fake_user',
first_name: 'another_fake_user_first_name',
email: 'another_fake@example.com',
},
})
// Check if received the message ID
expect(result.current.messages).to.have.length(1)
// Wait until initial messages have loaded
result.current.loadInitialMessages()
await waitForNextUpdate()
// Check if there are no message duplication
expect(result.current.messages).to.have.length(1)
const message = result.current.messages[0]
expect(message.id).to.equal('msg_1')
expect(message.contents).to.deep.equal(['new message'])
})
it('deduplicate messages from websocket', async function () {
// Mock socket: we only need to emit events, not mock actual connections
const socket = new SocketIOMock()
const { result, waitForNextUpdate } = renderChatContextHook({
socket,
})
fetchMock.get('express:/project/:projectId/messages', [
{
id: 'msg_1',
content: 'new message',
timestamp: Date.now(),
user: {
id: 'another_fake_user',
first_name: 'another_fake_user_first_name',
email: 'another_fake@example.com',
},
},
])
// Wait until initial messages have loaded
result.current.loadInitialMessages()
await waitForNextUpdate()
// Check if received the message ID
expect(result.current.messages).to.have.length(1)
// Mock message being received from another user
socket.emitToClient('new-chat-message', {
id: 'msg_1',
content: 'new message',
timestamp: Date.now(),
user: {
id: 'another_fake_user',
first_name: 'another_fake_user_first_name',
email: 'another_fake@example.com',
},
})
// Check if there are no message duplication
expect(result.current.messages).to.have.length(1)
const message = result.current.messages[0]
expect(message.id).to.equal('msg_1')
expect(message.contents).to.deep.equal(['new message'])
})
it("doesn't add received messages from the current user if a message was just sent", async function () {
const socket = new SocketIOMock()
const { result, waitForNextUpdate } = renderChatContextHook({
socket,
})
// Wait until initial messages have loaded
result.current.loadInitialMessages()
await waitForNextUpdate()
// Send a message from the current user
const sentMsg = 'sent message'
result.current.sendMessage(sentMsg)
act(() => {
// Receive a message from the current user
socket.emitToClient('new-chat-message', {
id: 'msg_1',
content: 'received message',
timestamp: Date.now(),
user,
clientId: uuidValue,
})
})
expect(result.current.messages).to.have.length(1)
const [message] = result.current.messages
expect(message.contents).to.deep.equal([sentMsg])
})
it('adds the new message from the current user if another message was received after sending', async function () {
const socket = new SocketIOMock()
const { result, waitForNextUpdate } = renderChatContextHook({
socket,
})
// Wait until initial messages have loaded
result.current.loadInitialMessages()
await waitForNextUpdate()
// Send a message from the current user
const sentMsg = 'sent message from current user'
result.current.sendMessage(sentMsg)
const [sentMessageFromCurrentUser] = result.current.messages
expect(sentMessageFromCurrentUser.contents).to.deep.equal([sentMsg])
const otherMsg = 'new message from other user'
act(() => {
// Receive a message from another user.
socket.emitToClient('new-chat-message', {
id: 'msg_1',
content: otherMsg,
timestamp: Date.now(),
user: {
id: 'another_fake_user',
first_name: 'another_fake_user_first_name',
email: 'another_fake@example.com',
},
clientId: '123',
})
})
const [, messageFromOtherUser] = result.current.messages
expect(messageFromOtherUser.contents).to.deep.equal([otherMsg])
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(),
user,
clientId: uuidValue,
})
})
// Since the current user didn't just send a message, it is now shown
expect(result.current.messages).to.deep.equal([
sentMessageFromCurrentUser,
messageFromOtherUser,
])
})
})
describe('loadInitialMessages', function () {
beforeEach(function () {
fetchMock.get('express:/project/:projectId/messages', [
{
id: 'msg_1',
content: 'a message',
user,
timestamp: Date.now(),
},
])
})
it('adds messages to the list', async function () {
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadInitialMessages()
await waitForNextUpdate()
expect(result.current.messages[0].contents).to.deep.equal(['a message'])
})
it("won't load messages a second time", async function () {
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadInitialMessages()
await waitForNextUpdate()
expect(result.current.initialMessagesLoaded).to.equal(true)
// Calling a second time won't do anything
result.current.loadInitialMessages()
expect(
fetchMock.callHistory.calls('express:/project/:projectId/messages')
).to.have.lengthOf(1)
})
it('provides an error on failure', async function () {
fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/project/:projectId/messages', 500)
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadInitialMessages()
await waitForNextUpdate()
expect(result.current.error).to.exist
expect(result.current.status).to.equal('error')
})
})
describe('loadMoreMessages', function () {
it('adds messages to the list', async function () {
// Mock a GET request for an initial message
fetchMock.getOnce('express:/project/:projectId/messages', [
{
id: 'msg_1',
content: 'first message',
user,
timestamp: new Date('2021-03-04T10:00:00').getTime(),
},
])
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadMoreMessages()
await waitForNextUpdate()
expect(result.current.messages[0].contents).to.deep.equal([
'first message',
])
// The before query param is not set
expect(getLastFetchMockQueryParam('before')).to.be.null
})
it('adds more messages if called a second time', async function () {
// Mock 2 GET requests, with different content
fetchMock
.getOnce(
'express:/project/:projectId/messages',
// Resolve a full "page" of messages (50)
createMessages(50, user, new Date('2021-03-04T10:00:00').getTime())
)
.getOnce('express:/project/:projectId/messages', [
{
id: 'msg_51',
content: 'message from second page',
user,
timestamp: new Date('2021-03-04T11:00:00').getTime(),
},
])
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadMoreMessages()
await waitForNextUpdate()
// Call a second time
result.current.loadMoreMessages()
await waitForNextUpdate()
// The second request is added to the list
// Since both messages from the same user, they are collapsed into the
// same "message"
expect(result.current.messages[0].contents).to.include(
'message from second page'
)
// The before query param for the second request matches the timestamp
// of the first message
const beforeParam = parseInt(getLastFetchMockQueryParam('before'), 10)
expect(beforeParam).to.equal(new Date('2021-03-04T10:00:00').getTime())
})
it("won't load more messages if there are no more messages", async function () {
// Mock a GET request for 49 messages. This is less the the full page size
// (50 messages), meaning that there are no further messages to be loaded
fetchMock.getOnce(
'express:/project/:projectId/messages',
createMessages(49, user)
)
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadMoreMessages()
await waitForNextUpdate()
expect(result.current.messages[0].contents).to.have.length(49)
result.current.loadMoreMessages()
expect(result.current.atEnd).to.be.true
expect(
fetchMock.callHistory.calls('express:/project/:projectId/messages')
).to.have.lengthOf(1)
})
it('handles socket messages while loading', async function () {
// Mock GET messages so that we can control when the promise is resolved
let resolveLoadingMessages
fetchMock.get(
'express:/project/:projectId/messages',
new Promise(resolve => {
resolveLoadingMessages = resolve
})
)
const socket = new SocketIOMock()
const { result, waitForNextUpdate } = renderChatContextHook({
socket,
})
// Start loading messages
result.current.loadMoreMessages()
// Mock message being received from the socket while the request is in
// flight
socket.emitToClient('new-chat-message', {
id: 'socket_msg',
content: 'socket message',
timestamp: Date.now(),
user: {
id: 'another_fake_user',
first_name: 'another_fake_user_first_name',
email: 'another_fake@example.com',
},
})
// Resolve messages being loaded
resolveLoadingMessages([
{
id: 'fetched_msg',
content: 'loaded message',
user,
timestamp: Date.now(),
},
])
await waitForNextUpdate()
// 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]
)
expect(messageContents).to.deep.equal([
'loaded message',
'socket message',
])
})
it('provides an error on failures', async function () {
fetchMock.removeRoutes().clearHistory()
fetchMock.get('express:/project/:projectId/messages', 500)
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadMoreMessages()
await waitForNextUpdate()
expect(result.current.error).to.exist
expect(result.current.status).to.equal('error')
})
})
describe('sendMessage', function () {
beforeEach(function () {
// Mock GET messages to return no messages and POST new message to be
// successful
fetchMock
.get('express:/project/:projectId/messages', [])
.postOnce('express:/project/:projectId/messages', 200)
})
it('optimistically adds the message to the list', function () {
const { result } = renderChatContextHook({})
result.current.sendMessage('sent message')
expect(result.current.messages[0].contents).to.deep.equal([
'sent message',
])
})
it('POSTs the message to the backend', function () {
const { result } = renderChatContextHook({})
result.current.sendMessage('sent message')
const {
options: { body },
} = fetchMock.callHistory
.calls('express:/project/:projectId/messages', { method: 'POST' })
.at(-1)
expect(JSON.parse(body)).to.deep.include({ content: 'sent message' })
})
it("doesn't send if the content is empty", function () {
const { result } = renderChatContextHook({})
result.current.sendMessage('')
expect(result.current.messages).to.be.empty
expect(
fetchMock.callHistory.called('express:/project/:projectId/messages', {
method: 'post',
})
).to.be.false
})
it('provides an error on failure', async function () {
fetchMock.removeRoutes().clearHistory()
fetchMock
.get('express:/project/:projectId/messages', [])
.postOnce('express:/project/:projectId/messages', 500)
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.sendMessage('sent message')
await waitForNextUpdate()
expect(result.current.error).to.exist
expect(result.current.status).to.equal('error')
})
})
describe('unread messages', function () {
beforeEach(function () {
// Mock GET messages to return no messages
fetchMock.get('express:/project/:projectId/messages', [])
})
it('increments unreadMessageCount when a new message is received', function () {
const socket = new SocketIOMock()
const { result } = renderChatContextHook({ socket })
// Receive a new message from the socket
socket.emitToClient('new-chat-message', {
id: 'msg_1',
content: 'new message',
timestamp: Date.now(),
user,
})
expect(result.current.unreadMessageCount).to.equal(1)
})
it('resets unreadMessageCount when markMessagesAsRead is called', function () {
const socket = new SocketIOMock()
const { result } = renderChatContextHook({ socket })
// Receive a new message from the socket, incrementing unreadMessageCount
// by 1
socket.emitToClient('new-chat-message', {
id: 'msg_1',
content: 'new message',
timestamp: Date.now(),
user,
})
result.current.markMessagesAsRead()
expect(result.current.unreadMessageCount).to.equal(0)
})
})
})
function renderChatContextHook(props) {
return renderHook(() => useChatContext(), {
// Wrap with ChatContext.Provider (and the other editor context providers)
// eslint-disable-next-line react/display-name
wrapper: ({ children }) => (
<EditorProviders {...props}>{children}</EditorProviders>
),
})
}
function createMessages(number, user, timestamp = Date.now()) {
return Array.from({ length: number }, (_m, idx) => ({
id: `msg_${idx + 1}`,
content: `message ${idx + 1}`,
user,
timestamp,
}))
}
/*
* Get query param by key from the last fetchMock response
*/
function getLastFetchMockQueryParam(key) {
const { url } = fetchMock.callHistory.calls().at(-1)
const { searchParams } = new URL(url, 'https://www.overleaf.com')
return searchParams.get(key)
}

View File

@@ -0,0 +1,264 @@
import { expect } from 'chai'
import {
appendMessage,
prependMessages,
} from '../../../../../frontend/js/features/chat/utils/message-list-appender'
const testUser = {
id: '123abc',
}
const otherUser = {
id: '234other',
}
function createTestMessageList() {
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() {
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 = []
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, uniqueMessageIds
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, uniqueMessageIds
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 = []
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 = []
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, uniqueMessageIds
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, uniqueMessageIds
beforeEach(function () {
list = createTestMessageList()
message = createTestMessage()
message.timestamp = list[1].timestamp + 6 * 60 * 1000 // 6 minutes after the last message in the list
uniqueMessageIds = []
})
it('when the author is the same as the last message, should be appended as new message', function () {
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result.length).to.equal(3)
expect(result[2]).to.deep.equal({
id: 'appended_message',
timestamp: message.timestamp,
user: message.user,
contents: [message.content],
})
})
it('when the author is the different than the last message, should be appended as new message', function () {
message.user = otherUser
const result = appendMessage(list, message, uniqueMessageIds).messages
expect(result.length).to.equal(3)
expect(result[2]).to.deep.equal({
id: 'appended_message',
timestamp: message.timestamp,
user: message.user,
contents: [message.content],
})
})
})
})

View File

@@ -0,0 +1,182 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
import EditorCloneProjectModalWrapper from '../../../../../frontend/js/features/clone-project-modal/components/editor-clone-project-modal-wrapper'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
describe('<EditorCloneProjectModalWrapper />', function () {
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
})
after(function () {
fetchMock.removeRoutes().clearHistory()
})
const project = {
_id: 'project-1',
name: 'Test Project',
}
it('renders the translated modal title', async function () {
const handleHide = sinon.stub()
const openProject = sinon.stub()
renderWithEditorContext(
<EditorCloneProjectModalWrapper
handleHide={handleHide}
openProject={openProject}
show
/>,
{ scope: { project } }
)
await screen.findByText('Copy Project')
})
it('posts the generated project name', async function () {
fetchMock.post(
'express:/project/:projectId/clone',
{
status: 200,
body: { project_id: 'cloned-project' },
},
{ delay: 10 }
)
const handleHide = sinon.stub()
const openProject = sinon.stub()
renderWithEditorContext(
<EditorCloneProjectModalWrapper
handleHide={handleHide}
openProject={openProject}
show
/>,
{ scope: { project } }
)
const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
expect(cancelButton.disabled).to.be.false
const submitButton = await screen.findByRole('button', { name: 'Copy' })
expect(submitButton.disabled).to.be.false
const input = await screen.getByLabelText('New Name')
fireEvent.change(input, {
target: { value: '' },
})
expect(submitButton.disabled).to.be.true
fireEvent.change(input, {
target: { value: 'A Cloned Project' },
})
expect(submitButton.disabled).to.be.false
fireEvent.click(submitButton)
expect(submitButton.disabled).to.be.true
await fetchMock.callHistory.flush(true)
expect(fetchMock.callHistory.done()).to.be.true
const { url, options } = fetchMock.callHistory
.calls('express:/project/:projectId/clone')
.at(-1)
expect(url).to.equal(
'https://www.test-overleaf.com/project/project-1/clone'
)
expect(JSON.parse(options.body)).to.deep.equal({
projectName: 'A Cloned Project',
tags: [],
})
await waitFor(() => {
expect(openProject).to.be.calledOnce
})
const errorMessage = screen.queryByText('Sorry, something went wrong')
expect(errorMessage).to.be.null
await waitFor(() => {
expect(submitButton.disabled).to.be.false
expect(cancelButton.disabled).to.be.false
})
})
it('handles a generic error response', async function () {
const matcher = 'express:/project/:projectId/clone'
fetchMock.postOnce(matcher, {
status: 500,
body: 'There was an error!',
})
const handleHide = sinon.stub()
const openProject = sinon.stub()
renderWithEditorContext(
<EditorCloneProjectModalWrapper
handleHide={handleHide}
openProject={openProject}
show
/>,
{ scope: { project } }
)
const button = await screen.findByRole('button', { name: 'Copy' })
expect(button.disabled).to.be.false
const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
expect(cancelButton.disabled).to.be.false
fireEvent.click(button)
expect(fetchMock.callHistory.done(matcher)).to.be.true
expect(openProject).not.to.be.called
await screen.findByText('Sorry, something went wrong')
expect(button.disabled).to.be.false
expect(cancelButton.disabled).to.be.false
})
it('handles a specific error response', async function () {
const matcher = 'express:/project/:projectId/clone'
fetchMock.postOnce(matcher, {
status: 400,
body: 'There was an error!',
})
const handleHide = sinon.stub()
const openProject = sinon.stub()
renderWithEditorContext(
<EditorCloneProjectModalWrapper
handleHide={handleHide}
openProject={openProject}
show
/>,
{ scope: { project } }
)
const button = await screen.findByRole('button', { name: 'Copy' })
expect(button.disabled).to.be.false
const cancelButton = await screen.findByRole('button', { name: 'Cancel' })
expect(cancelButton.disabled).to.be.false
fireEvent.click(button)
await fetchMock.callHistory.flush(true)
expect(fetchMock.callHistory.done(matcher)).to.be.true
expect(openProject).not.to.be.called
await screen.findByText('There was an error!')
expect(button.disabled).to.be.false
expect(cancelButton.disabled).to.be.false
})
})

View File

@@ -0,0 +1,102 @@
import DictionaryModal from '@/features/dictionary/components/dictionary-modal'
import { EditorProviders } from '../../../helpers/editor-providers'
import { learnedWords } from '@/features/source-editor/extensions/spelling/learned-words'
describe('<DictionaryModalContent />', function () {
let originalLearnedWords
beforeEach(function () {
cy.then(() => {
originalLearnedWords = learnedWords.global
})
cy.interceptCompile()
})
afterEach(function () {
cy.then(() => {
learnedWords.global = originalLearnedWords
})
})
it('list words', function () {
cy.then(win => {
learnedWords.global = new Set(['foo', 'bar'])
})
cy.mount(
<EditorProviders>
<DictionaryModal show handleHide={cy.stub()} />
</EditorProviders>
)
cy.findByText('foo')
cy.findByText('bar')
})
it('shows message when empty', function () {
cy.then(win => {
learnedWords.global = new Set([])
})
cy.mount(
<EditorProviders>
<DictionaryModal show handleHide={cy.stub()} />
</EditorProviders>
)
cy.contains('Your custom dictionary is empty.')
})
it('removes words', function () {
cy.intercept('/spelling/unlearn', { statusCode: 200 })
cy.then(win => {
learnedWords.global = new Set(['Foo', 'bar'])
})
cy.mount(
<EditorProviders>
<DictionaryModal show handleHide={cy.stub()} />
</EditorProviders>
)
cy.findByText('Foo')
cy.findByText('bar')
cy.findAllByRole('button', {
name: 'Remove from dictionary',
})
.eq(0)
.click()
cy.findByText('bar').should('not.exist')
cy.findByText('Foo')
})
it('handles errors', function () {
cy.intercept('/spelling/unlearn', { statusCode: 500 }).as('unlearn')
cy.then(win => {
learnedWords.global = new Set(['foo'])
})
cy.mount(
<EditorProviders>
<DictionaryModal show handleHide={cy.stub()} />
</EditorProviders>
)
cy.findByText('foo')
cy.findAllByRole('button', {
name: 'Remove from dictionary',
})
.eq(0)
.click()
cy.wait('@unlearn')
cy.findByText('Sorry, something went wrong')
cy.findByText('foo')
})
})

View File

@@ -0,0 +1,60 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import sinon from 'sinon'
import { expect } from 'chai'
import ActionsCopyProject from '../../../../../frontend/js/features/editor-left-menu/components/actions-copy-project'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
describe('<ActionsCopyProject />', function () {
let assignStub
beforeEach(function () {
assignStub = sinon.stub()
this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({
assign: assignStub,
replace: sinon.stub(),
reload: sinon.stub(),
})
})
afterEach(function () {
this.locationStub.restore()
fetchMock.removeRoutes().clearHistory()
})
it('shows correct modal when clicked', async function () {
renderWithEditorContext(<ActionsCopyProject />)
fireEvent.click(screen.getByRole('button', { name: 'Copy Project' }))
screen.getByPlaceholderText('New Project Name')
})
it('loads the project page when submitted', async function () {
fetchMock.post('express:/project/:id/clone', {
status: 200,
body: {
project_id: 'new-project',
},
})
renderWithEditorContext(<ActionsCopyProject />)
fireEvent.click(screen.getByRole('button', { name: 'Copy Project' }))
const input = screen.getByPlaceholderText('New Project Name')
fireEvent.change(input, { target: { value: 'New Project' } })
const button = screen.getByRole('button', { name: 'Copy' })
button.click()
await waitFor(() => {
expect(button.textContent).to.equal('Copying…')
})
await waitFor(() => {
expect(assignStub).to.have.been.calledOnceWith('/project/new-project')
})
})
})

View File

@@ -0,0 +1,84 @@
import { screen, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import ActionsMenu from '../../../../../frontend/js/features/editor-left-menu/components/actions-menu'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
describe('<ActionsMenu />', function () {
beforeEach(function () {
fetchMock.post('express:/project/:projectId/compile', {
status: 'success',
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
outputFiles: [
{
path: 'output.pdf',
build: 'build-123',
url: '/build/build-123/output.pdf',
type: 'pdf',
},
],
})
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu for non-anonymous users', async function () {
window.metaAttributesCache.set('ol-anonymous', false)
renderWithEditorContext(<ActionsMenu />, {
projectId: '123abc',
scope: {
editor: {
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
},
},
},
})
screen.getByText('Actions')
screen.getByRole('button', {
name: 'Copy Project',
})
await waitFor(() => {
screen.getByRole('button', {
name: 'Word Count',
})
})
})
it('does not show anything for anonymous users', async function () {
window.metaAttributesCache.set('ol-anonymous', true)
renderWithEditorContext(<ActionsMenu />, {
projectId: '123abc',
scope: {
editor: {
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
},
},
},
})
expect(screen.queryByText('Actions')).to.equal(null)
expect(
screen.queryByRole('button', {
name: 'Copy Project',
})
).to.equal(null)
await waitFor(() => {
expect(
screen.queryByRole('button', {
name: 'Word Count',
})
).to.equal(null)
})
})
})

View File

@@ -0,0 +1,65 @@
import { screen, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import ActionsWordCount from '../../../../../frontend/js/features/editor-left-menu/components/actions-word-count'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
describe('<ActionsWordCount />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct modal when clicked after document is compiled', async function () {
const compileEndpoint = 'express:/project/:projectId/compile'
const wordcountEndpoint = 'express:/project/:projectId/wordcount'
fetchMock.post(compileEndpoint, {
status: 'success',
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
outputFiles: [
{
path: 'output.pdf',
build: 'build-123',
url: '/build/build-123/output.pdf',
type: 'pdf',
},
],
})
fetchMock.get(wordcountEndpoint, {
texcount: {
encode: 'ascii',
textWords: 0,
headers: 0,
mathInline: 0,
mathDisplay: 0,
},
})
renderWithEditorContext(<ActionsWordCount />, {
projectId: '123abc',
scope: {
editor: {
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
},
},
},
})
// when loading, we don't render the "Word Count" as button yet
expect(screen.queryByRole('button', { name: 'Word Count' })).to.equal(null)
await waitFor(
() => expect(fetchMock.callHistory.called(compileEndpoint)).to.be.true
)
const button = await screen.findByRole('button', { name: 'Word Count' })
button.click()
await waitFor(
() => expect(fetchMock.callHistory.called(wordcountEndpoint)).to.be.true
)
})
})

View File

@@ -0,0 +1,58 @@
import { screen, waitFor } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import DownloadMenu from '../../../../../frontend/js/features/editor-left-menu/components/download-menu'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
describe('<DownloadMenu />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows download links with correct url', async function () {
fetchMock.post('express:/project/:projectId/compile', {
clsiServerId: 'foo',
compileGroup: 'priority',
status: 'success',
pdfDownloadDomain: 'https://clsi.test-overleaf.com',
outputFiles: [
{
path: 'output.pdf',
build: 'build-123',
url: '/build/build-123/output.pdf',
type: 'pdf',
},
],
})
renderWithEditorContext(<DownloadMenu />, {
projectId: '123abc',
scope: {
editor: {
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
},
},
},
})
const sourceLink = screen.getByRole('link', {
name: 'Source',
})
expect(sourceLink.getAttribute('href')).to.equal(
'/project/123abc/download/zip'
)
await waitFor(() => {
const pdfLink = screen.getByRole('link', {
name: 'PDF',
})
expect(pdfLink.getAttribute('href')).to.equal(
'/download/project/123abc/build/build-123/output/output.pdf?compileGroup=priority&clsiserverid=foo&popupDownload=true'
)
})
})
})

View File

@@ -0,0 +1,29 @@
import { expect } from 'chai'
import { screen, fireEvent, within } from '@testing-library/react'
import HelpContactUs from '../../../../../frontend/js/features/editor-left-menu/components/help-contact-us'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import fetchMock from 'fetch-mock'
describe('<HelpContactUs />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-user', {
email: 'sherlock@holmes.co.uk',
first_name: 'Sherlock',
last_name: 'Holmes',
})
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('open contact us modal when clicked', function () {
renderWithEditorContext(<HelpContactUs />)
expect(screen.queryByRole('dialog')).to.equal(null)
fireEvent.click(screen.getByRole('button', { name: 'Contact Us' }))
const modal = screen.getAllByRole('dialog')[0]
within(modal).getAllByText('Get in touch')
within(modal).getByText('Subject')
})
})

View File

@@ -0,0 +1,12 @@
import { expect } from 'chai'
import { screen, render } from '@testing-library/react'
import HelpDocumentation from '../../../../../frontend/js/features/editor-left-menu/components/help-documentation'
describe('<HelpDocumentation />', function () {
it('has correct href attribute', function () {
render(<HelpDocumentation />)
const link = screen.getByRole('link', { name: 'Documentation' })
expect(link.getAttribute('href')).to.equal('/learn')
})
})

View File

@@ -0,0 +1,39 @@
import { screen } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import HelpMenu from '../../../../../frontend/js/features/editor-left-menu/components/help-menu'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
describe('<HelpMenu />', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-user', {
email: 'sherlock@holmes.co.uk',
first_name: 'Sherlock',
last_name: 'Holmes',
})
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu if `showSupport` is `true`', function () {
window.metaAttributesCache.set('ol-showSupport', true)
renderWithEditorContext(<HelpMenu />)
screen.getByRole('button', { name: 'Show Hotkeys' })
screen.getByRole('button', { name: 'Contact Us' })
screen.getByRole('link', { name: 'Documentation' })
})
it('shows correct menu if `showSupport` is `false`', function () {
window.metaAttributesCache.set('ol-showSupport', false)
renderWithEditorContext(<HelpMenu />)
screen.getByRole('button', { name: 'Show Hotkeys' })
expect(screen.queryByRole('button', { name: 'Contact Us' })).to.equal(null)
expect(screen.queryByRole('link', { name: 'Documentation' })).to.equal(null)
})
})

View File

@@ -0,0 +1,20 @@
import { expect } from 'chai'
import { screen, fireEvent, within } from '@testing-library/react'
import HelpShowHotkeys from '../../../../../frontend/js/features/editor-left-menu/components/help-show-hotkeys'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import fetchMock from 'fetch-mock'
describe('<HelpShowHotkeys />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('open hotkeys modal when clicked', function () {
renderWithEditorContext(<HelpShowHotkeys />)
expect(screen.queryByRole('dialog')).to.equal(null)
fireEvent.click(screen.getByRole('button', { name: 'Show Hotkeys' }))
const modal = screen.getAllByRole('dialog')[0]
within(modal).getByText('Common')
})
})

View File

@@ -0,0 +1,30 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsAutoCloseBrackets from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-auto-close-brackets'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
import { EditorProviders } from '../../../../helpers/editor-providers'
describe('<SettingsAutoCloseBrackets />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsAutoCloseBrackets />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Auto-close brackets')
const optionOn = within(select).getByText('On')
expect(optionOn.getAttribute('value')).to.equal('true')
const optionOff = within(select).getByText('Off')
expect(optionOff.getAttribute('value')).to.equal('false')
})
})

View File

@@ -0,0 +1,30 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsAutoComplete from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-auto-complete'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
import { EditorProviders } from '../../../../helpers/editor-providers'
describe('<SettingsAutoComplete />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsAutoComplete />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Auto-complete')
const optionOn = within(select).getByText('On')
expect(optionOn.getAttribute('value')).to.equal('true')
const optionOff = within(select).getByText('Off')
expect(optionOff.getAttribute('value')).to.equal('false')
})
})

View File

@@ -0,0 +1,36 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsCompiler from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-compiler'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
import { EditorProviders } from '../../../../helpers/editor-providers'
describe('<SettingsCompiler />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsCompiler />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Compiler')
const optionPdfLaTeX = within(select).getByText('pdfLaTeX')
expect(optionPdfLaTeX.getAttribute('value')).to.equal('pdflatex')
const optionLaTeX = within(select).getByText('LaTeX')
expect(optionLaTeX.getAttribute('value')).to.equal('latex')
const optionXeLaTeX = within(select).getByText('XeLaTeX')
expect(optionXeLaTeX.getAttribute('value')).to.equal('xelatex')
const optionLuaLaTeX = within(select).getByText('LuaLaTeX')
expect(optionLuaLaTeX.getAttribute('value')).to.equal('lualatex')
})
})

View File

@@ -0,0 +1,33 @@
import { fireEvent, screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import SettingsDictionary from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-dictionary'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
import { EditorProviders } from '../../../../helpers/editor-providers'
describe('<SettingsDictionary />', function () {
it('open dictionary modal', function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsDictionary />
</EditorLeftMenuProvider>
</EditorProviders>
)
screen.getByText('Dictionary')
const button = screen.getByText('Edit')
fireEvent.click(button)
const modal = screen.getByTestId('dictionary-modal')
within(modal).getByRole('heading', { name: 'Edit Dictionary' })
within(modal).getByText('Your custom dictionary is empty.')
const [, closeButton] = within(modal).getAllByRole('button', {
name: 'Close',
})
fireEvent.click(closeButton)
expect(screen.getByTestId('dictionary-modal')).to.not.be.null
})
})

View File

@@ -0,0 +1,51 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsDocument from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-document'
import { Folder } from '../../../../../../types/folder'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
import { EditorProviders } from '../../../../helpers/editor-providers'
describe('<SettingsDocument />', function () {
const rootFolder: Folder = {
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: '123abc',
name: 'main.tex',
},
],
fileRefs: [],
folders: [],
}
let originalSettings: typeof window.metaAttributesCache
beforeEach(function () {
originalSettings = window.metaAttributesCache.get('ol-ExposedSettings')
window.metaAttributesCache.set('ol-ExposedSettings', {
validRootDocExtensions: ['tex'],
})
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
window.metaAttributesCache.set('ol-ExposedSettings', originalSettings)
})
it('shows correct menu', async function () {
render(
<EditorProviders rootFolder={[rootFolder as any]}>
<EditorLeftMenuProvider>
<SettingsDocument />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Main document')
const optionOn = within(select).getByText('main.tex')
expect(optionOn.getAttribute('value')).to.equal('123abc')
})
})

View File

@@ -0,0 +1,45 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsEditorTheme from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-editor-theme'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
import { EditorProviders } from '../../../../helpers/editor-providers'
describe('<SettingsEditorTheme />', function () {
const editorThemes = ['editortheme-1', 'editortheme-2', 'editortheme-3']
const legacyEditorThemes = ['legacytheme-1', 'legacytheme-2', 'legacytheme-3']
beforeEach(function () {
window.metaAttributesCache.set('ol-editorThemes', editorThemes)
window.metaAttributesCache.set('ol-legacyEditorThemes', legacyEditorThemes)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsEditorTheme />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Editor theme')
for (const theme of editorThemes) {
const option = within(select).getByText(theme.replace(/_/g, ' '))
expect(option.getAttribute('value')).to.equal(theme)
}
for (const theme of legacyEditorThemes) {
const option = within(select).getByText(
theme.replace(/_/g, ' ') + ' (Legacy)'
)
expect(option.getAttribute('value')).to.equal(theme)
}
})
})

View File

@@ -0,0 +1,35 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsFontFamily from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-font-family'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
import { EditorProviders } from '../../../../helpers/editor-providers'
describe('<SettingsFontFamily />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsFontFamily />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Font Family')
const optionMonaco = within(select).getByText('Monaco / Menlo / Consolas')
expect(optionMonaco.getAttribute('value')).to.equal('monaco')
const optionLucida = within(select).getByText('Lucida / Source Code Pro')
expect(optionLucida.getAttribute('value')).to.equal('lucida')
const optionOpenDyslexicMono = within(select).getByText('OpenDyslexic Mono')
expect(optionOpenDyslexicMono.getAttribute('value')).to.equal(
'opendyslexicmono'
)
})
})

View File

@@ -0,0 +1,31 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsFontSize from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-font-size'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
import { EditorProviders } from '../../../../helpers/editor-providers'
describe('<SettingsFontSize />', function () {
const sizes = ['10', '11', '12', '13', '14', '16', '18', '20', '22', '24']
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsFontSize />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Font Size')
for (const size of sizes) {
const option = within(select).getByText(`${size}px`)
expect(option.getAttribute('value')).to.equal(size)
}
})
})

View File

@@ -0,0 +1,45 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsImageName from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-image-name'
import type { AllowedImageName } from '../../../../../../types/project-settings'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
import { EditorProviders } from '../../../../helpers/editor-providers'
describe('<SettingsImageName />', function () {
const allowedImageNames: AllowedImageName[] = [
{
imageDesc: 'Image 1',
imageName: 'img-1',
},
{
imageDesc: 'Image 2',
imageName: 'img-2',
},
]
beforeEach(function () {
window.metaAttributesCache.set('ol-allowedImageNames', allowedImageNames)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsImageName />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('TeX Live version')
for (const { imageName, imageDesc } of allowedImageNames) {
const option = within(select).getByText(imageDesc)
expect(option.getAttribute('value')).to.equal(imageName)
}
})
})

View File

@@ -0,0 +1,33 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsKeybindings from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-keybindings'
import { EditorProviders } from '../../../../helpers/editor-providers'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
describe('<SettingsKeybindings />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsKeybindings />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Keybindings')
const optionNone = within(select).getByText('None')
expect(optionNone.getAttribute('value')).to.equal('default')
const optionVim = within(select).getByText('Vim')
expect(optionVim.getAttribute('value')).to.equal('vim')
const optionEmacs = within(select).getByText('Emacs')
expect(optionEmacs.getAttribute('value')).to.equal('emacs')
})
})

View File

@@ -0,0 +1,33 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsLineHeight from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-line-height'
import { EditorProviders } from '../../../../helpers/editor-providers'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
describe('<SettingsLineHeight />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsLineHeight />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Line Height')
const optionCompact = within(select).getByText('Compact')
expect(optionCompact.getAttribute('value')).to.equal('compact')
const optionNormal = within(select).getByText('Normal')
expect(optionNormal.getAttribute('value')).to.equal('normal')
const optionWide = within(select).getByText('Wide')
expect(optionWide.getAttribute('value')).to.equal('wide')
})
})

View File

@@ -0,0 +1,30 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsMathPreview from '@/features/editor-left-menu/components/settings/settings-math-preview'
import { EditorProviders } from '../../../../helpers/editor-providers'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
describe('<SettingsMathPreview />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsMathPreview />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Equation preview')
const optionOn = within(select).getByText('On')
expect(optionOn.getAttribute('value')).to.equal('true')
const optionOff = within(select).getByText('Off')
expect(optionOff.getAttribute('value')).to.equal('false')
})
})

View File

@@ -0,0 +1,98 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsOverallTheme from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-overall-theme'
import type { OverallThemeMeta } from '../../../../../../types/project-settings'
import getMeta from '@/utils/meta'
import { EditorProviders } from '../../../../helpers/editor-providers'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
const IEEE_BRAND_ID = 1234
const OTHER_BRAND_ID = 2234
describe('<SettingsOverallTheme />', function () {
const overallThemes: OverallThemeMeta[] = [
{
name: 'Overall Theme 1',
val: '',
path: 'https://overleaf.com/overalltheme-1.css',
},
{
name: 'Overall Theme 2',
val: 'light-',
path: 'https://overleaf.com/overalltheme-2.css',
},
]
beforeEach(function () {
window.metaAttributesCache.set('ol-overallThemes', overallThemes)
Object.assign(getMeta('ol-ExposedSettings'), {
ieeeBrandId: IEEE_BRAND_ID,
})
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsOverallTheme />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Overall theme')
for (const theme of overallThemes) {
const option = within(select).getByText(theme.name)
expect(option.getAttribute('value')).to.equal(theme.val)
}
})
describe('Branded Project', function () {
it('should hide overall theme picker for IEEE branded projects', function () {
window.metaAttributesCache.set('ol-brandVariation', {
brand_id: IEEE_BRAND_ID,
})
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsOverallTheme />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.queryByText('Overall theme')
expect(select).to.not.exist
})
it('should show overall theme picker for branded projects that are not IEEE', function () {
window.metaAttributesCache.set('ol-brandVariation', {
brand_id: OTHER_BRAND_ID,
})
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsOverallTheme />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Overall theme')
expect(select).to.exist
})
it('should show overall theme picker for non branded projects', function () {
window.metaAttributesCache.set('ol-brandVariation', undefined)
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsOverallTheme />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Overall theme')
expect(select).to.exist
})
})
})

View File

@@ -0,0 +1,30 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsPdfViewer from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-pdf-viewer'
import { EditorProviders } from '../../../../helpers/editor-providers'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
describe('<SettingsPdfViewer />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsPdfViewer />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('PDF Viewer')
const optionOverleaf = within(select).getByText('Overleaf')
expect(optionOverleaf.getAttribute('value')).to.equal('pdfjs')
const optionBrowser = within(select).getByText('Browser')
expect(optionBrowser.getAttribute('value')).to.equal('native')
})
})

View File

@@ -0,0 +1,50 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsSpellCheckLanguage from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language'
import type { SpellCheckLanguage } from '../../../../../../types/project-settings'
import { EditorProviders } from '../../../../helpers/editor-providers'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
describe('<SettingsSpellCheckLanguage />', function () {
const languages: SpellCheckLanguage[] = [
{
name: 'Lang 1',
code: 'lang-1',
dic: 'lang_1',
},
{
name: 'Lang 2',
code: 'lang-2',
dic: 'lang_2',
},
]
beforeEach(function () {
window.metaAttributesCache.set('ol-languages', languages)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsSpellCheckLanguage />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Spell check')
const optionEmpty = within(select).getByText('Off')
expect(optionEmpty.getAttribute('value')).to.equal('')
for (const language of languages) {
const option = within(select).getByText(language.name)
expect(option.getAttribute('value')).to.equal(language.code)
}
})
})

View File

@@ -0,0 +1,30 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import SettingsSyntaxValidation from '../../../../../../frontend/js/features/editor-left-menu/components/settings/settings-syntax-validation'
import { EditorProviders } from '../../../../helpers/editor-providers'
import { EditorLeftMenuProvider } from '@/features/editor-left-menu/components/editor-left-menu-context'
describe('<SettingsSyntaxValidation />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('shows correct menu', async function () {
render(
<EditorProviders>
<EditorLeftMenuProvider>
<SettingsSyntaxValidation />
</EditorLeftMenuProvider>
</EditorProviders>
)
const select = screen.getByLabelText('Code check')
const optionOn = within(select).getByText('On')
expect(optionOn.getAttribute('value')).to.equal('true')
const optionOff = within(select).getByText('Off')
expect(optionOff.getAttribute('value')).to.equal('false')
})
})

View File

@@ -0,0 +1,26 @@
import { expect } from 'chai'
import { render, screen } from '@testing-library/react'
import ChatToggleButton from '../../../../../frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button'
describe('<ChatToggleButton />', function () {
const defaultProps = {
chatIsOpen: false,
unreadMessageCount: 0,
onClick: () => {},
}
it('displays the number of unread messages', function () {
const props = {
...defaultProps,
unreadMessageCount: 113,
}
render(<ChatToggleButton {...props} />)
screen.getByText('113')
})
it("doesn't display the unread messages badge when the number of unread messages is zero", function () {
render(<ChatToggleButton {...defaultProps} />)
expect(screen.queryByText('0')).to.not.exist
})
})

View File

@@ -0,0 +1,219 @@
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
import { expect } from 'chai'
import { screen } from '@testing-library/react'
import LayoutDropdownButton from '../../../../../frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import * as eventTracking from '@/infrastructure/event-tracking'
describe('<LayoutDropdownButton />', function () {
let openStub
let sendMBSpy
const defaultUi = {
pdfLayout: 'flat',
view: 'pdf',
}
beforeEach(function () {
openStub = sinon.stub(window, 'open')
sendMBSpy = sinon.spy(eventTracking, 'sendMB')
})
afterEach(function () {
openStub.restore()
sendMBSpy.restore()
fetchMock.removeRoutes().clearHistory()
})
it('should mark current layout option as selected', async function () {
// Selected is aria-label, visually we show a checkmark
renderWithEditorContext(<LayoutDropdownButton />, { ui: defaultUi })
screen.getByRole('button', { name: 'Layout' }).click()
expect(
screen
.getByRole('menuitem', {
name: 'Editor & PDF',
})
.getAttribute('aria-selected')
).to.equal('false')
expect(
screen
.getByRole('menuitem', {
name: 'PDF only (hide editor)',
})
.getAttribute('aria-selected')
).to.equal('true')
expect(
screen
.getByRole('menuitem', {
name: 'Editor only (hide PDF)',
})
.getAttribute('aria-selected')
).to.equal('false')
expect(
screen
.getByRole('menuitem', {
name: 'PDF in separate tab',
})
.getAttribute('aria-selected')
).to.equal('false')
})
it('should not select any option in history view', function () {
// Selected is aria-label, visually we show a checkmark
renderWithEditorContext(<LayoutDropdownButton />, {
ui: { ...defaultUi, view: 'history' },
})
screen.getByRole('button', { name: 'Layout' }).click()
expect(
screen
.getByRole('menuitem', {
name: 'Editor & PDF',
})
.getAttribute('aria-selected')
).to.equal('false')
expect(
screen
.getByRole('menuitem', {
name: 'PDF only (hide editor)',
})
.getAttribute('aria-selected')
).to.equal('false')
expect(
screen
.getByRole('menuitem', {
name: 'Editor only (hide PDF)',
})
.getAttribute('aria-selected')
).to.equal('false')
expect(
screen
.getByRole('menuitem', {
name: 'PDF in separate tab',
})
.getAttribute('aria-selected')
).to.equal('false')
})
it('should treat file and editor views the same way', function () {
// Selected is aria-label, visually we show a checkmark
renderWithEditorContext(<LayoutDropdownButton />, {
ui: {
pdfLayout: 'flat',
view: 'file',
},
})
screen.getByRole('button', { name: 'Layout' }).click()
expect(
screen
.getByRole('menuitem', {
name: 'Editor & PDF',
})
.getAttribute('aria-selected')
).to.equal('false')
expect(
screen
.getByRole('menuitem', {
name: 'PDF only (hide editor)',
})
.getAttribute('aria-selected')
).to.equal('false')
expect(
screen
.getByRole('menuitem', {
name: 'Editor only (hide PDF)',
})
.getAttribute('aria-selected')
).to.equal('true')
expect(
screen
.getByRole('menuitem', {
name: 'PDF in separate tab',
})
.getAttribute('aria-selected')
).to.equal('false')
})
describe('on detach', function () {
let originalBroadcastChannel
beforeEach(function () {
window.BroadcastChannel = originalBroadcastChannel || true // ensure that window.BroadcastChannel is truthy
renderWithEditorContext(<LayoutDropdownButton />, {
ui: { ...defaultUi, view: 'editor' },
})
screen.getByRole('button', { name: 'Layout' }).click()
screen
.getByRole('menuitem', {
name: 'PDF in separate tab',
})
.click()
})
afterEach(function () {
window.BroadcastChannel = originalBroadcastChannel
})
it('should show processing', function () {
screen.getByText('Layout processing')
})
it('should record event', function () {
sinon.assert.calledWith(sendMBSpy, 'project-layout-detach')
})
})
describe('on layout change / reattach', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-detachRole', 'detacher')
renderWithEditorContext(<LayoutDropdownButton />, {
ui: { ...defaultUi, view: 'editor' },
})
screen.getByRole('button', { name: 'Layout' }).click()
screen
.getByRole('menuitem', {
name: 'Editor only (hide PDF)',
})
.click()
})
it('should not show processing', function () {
const processingText = screen.queryByText('Layout processing')
expect(processingText).to.not.exist
})
it('should record events', function () {
sinon.assert.calledWith(sendMBSpy, 'project-layout-reattach')
sinon.assert.calledWith(sendMBSpy, 'project-layout-change', {
layout: 'flat',
view: 'editor',
page: '/detacher',
})
})
it('should select new menu item', function () {
screen.getByRole('menuitem', {
name: 'Editor only (hide PDF)',
})
})
})
})

View File

@@ -0,0 +1,104 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { fireEvent, render, screen } from '@testing-library/react'
import OnlineUsersWidget from '../../../../../frontend/js/features/editor-navigation-toolbar/components/online-users-widget'
describe('<OnlineUsersWidget />', function () {
const defaultProps = {
onlineUsers: [
{
user_id: 'test_user',
name: 'test_user',
},
{
user_id: 'another_test_user',
name: 'another_test_user',
},
],
goToUser: () => {},
}
describe('with less than 4 users', function () {
it('displays user initials', function () {
render(<OnlineUsersWidget {...defaultProps} />)
screen.getByText('t')
screen.getByText('a')
})
it('displays user name in a tooltip', async function () {
render(<OnlineUsersWidget {...defaultProps} />)
const icon = screen.getByText('t')
fireEvent.mouseOver(icon)
await screen.findByRole('tooltip', { name: 'test_user' })
})
it('calls "goToUser" when the user initial is clicked', function () {
const props = {
...defaultProps,
goToUser: sinon.stub(),
}
render(<OnlineUsersWidget {...props} />)
const icon = screen.getByText('t')
fireEvent.click(icon)
expect(props.goToUser).to.be.calledWith({
name: 'test_user',
user_id: 'test_user',
})
})
})
describe('with 4 users and more', function () {
const props = {
...defaultProps,
onlineUsers: defaultProps.onlineUsers.concat([
{
user_id: 'user_3',
name: 'user_3',
},
{
user_id: 'user_4',
name: 'user_4',
},
]),
}
it('displays the count of users', function () {
render(<OnlineUsersWidget {...props} />)
screen.getByText('4')
})
it('displays user names on hover', function () {
render(<OnlineUsersWidget {...props} />)
const toggleButton = screen.getByRole('button')
fireEvent.click(toggleButton)
screen.getByText('test_user')
screen.getByText('another_test_user')
screen.getByText('user_3')
screen.getByText('user_4')
})
it('calls "goToUser" when the user name is clicked', function () {
const testProps = {
...props,
goToUser: sinon.stub(),
}
render(<OnlineUsersWidget {...testProps} />)
const toggleButton = screen.getByRole('button')
fireEvent.click(toggleButton)
const icon = screen.getByText('user_3')
fireEvent.click(icon)
expect(testProps.goToUser).to.be.calledWith({
name: 'user_3',
user_id: 'user_3',
})
})
})
})

View File

@@ -0,0 +1,78 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { fireEvent, render, screen } from '@testing-library/react'
import ProjectNameEditableLabel from '../../../../../frontend/js/features/editor-navigation-toolbar/components/project-name-editable-label'
describe('<ProjectNameEditableLabel />', function () {
const defaultProps = { projectName: 'test-project', onChange: () => {} }
it('displays the project name', function () {
render(<ProjectNameEditableLabel {...defaultProps} />)
screen.getByText('test-project')
})
describe('when the name is editable', function () {
const editableProps = { ...defaultProps, hasRenamePermissions: true }
it('displays an editable input when the edit button is clicked', function () {
render(<ProjectNameEditableLabel {...editableProps} />)
fireEvent.click(screen.getByRole('button'))
screen.getByRole('textbox')
})
it('displays an editable input when the project name is double clicked', function () {
render(<ProjectNameEditableLabel {...editableProps} />)
fireEvent.doubleClick(screen.getByText('test-project'))
screen.getByRole('textbox')
})
it('calls "onChange" when the project name is updated', function () {
const props = {
...editableProps,
onChange: sinon.stub(),
}
render(<ProjectNameEditableLabel {...props} />)
fireEvent.doubleClick(screen.getByText('test-project'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new project name' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(props.onChange).to.be.calledWith('new project name')
})
it('calls "onChange" when the input loses focus', function () {
const props = {
...editableProps,
onChange: sinon.stub(),
}
render(<ProjectNameEditableLabel {...props} />)
fireEvent.doubleClick(screen.getByText('test-project'))
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new project name' } })
fireEvent.blur(screen.getByRole('textbox'))
expect(props.onChange).to.be.calledWith('new project name')
})
})
describe('when the name is not editable', function () {
const nonEditableProps = { hasRenamePermissions: false, ...defaultProps }
it('the edit button is not displayed', function () {
render(<ProjectNameEditableLabel {...nonEditableProps} />)
expect(screen.queryByRole('button')).to.not.exist
})
it('does not display an editable input when the project name is double clicked', function () {
render(<ProjectNameEditableLabel {...nonEditableProps} />)
fireEvent.doubleClick(screen.getByText('test-project'))
expect(screen.queryByRole('textbox')).to.not.exist
})
})
})

View File

@@ -0,0 +1,116 @@
import { expect } from 'chai'
import { screen } from '@testing-library/react'
import ToolbarHeader from '../../../../../frontend/js/features/editor-navigation-toolbar/components/toolbar-header'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
describe('<ToolbarHeader />', function () {
const defaultProps = {
onShowLeftMenuClick: () => {},
toggleChatOpen: () => {},
toggleReviewPanelOpen: () => {},
toggleHistoryOpen: () => {},
unreadMessageCount: 0,
onlineUsers: [],
goToUser: () => {},
projectName: 'test project',
renameProject: () => {},
openShareModal: () => {},
hasPublishPermissions: true,
chatVisible: true,
trackChangesVisible: true,
handleChangeLayout: () => {},
pdfLayout: 'sideBySide',
view: 'editor',
reattach: () => {},
detach: () => {},
}
beforeEach(function () {
window.metaAttributesCache.set('ol-chatEnabled', true)
})
describe('cobranding logo', function () {
it('is not displayed by default', function () {
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
expect(screen.queryByRole('link', { name: 'variation' })).to.not.exist
})
it('is displayed when cobranding data is available', function () {
const props = {
...defaultProps,
cobranding: {
brandVariationHomeUrl: 'http://cobranding',
brandVariationName: 'variation',
logoImgUrl: 'http://cobranding/logo',
},
}
renderWithEditorContext(<ToolbarHeader {...props} />)
screen.getByRole('link', { name: 'variation' })
})
})
describe('track changes toggle button', function () {
it('is displayed by default', function () {
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
screen.getByText('Review')
})
it('is not displayed when "trackChangesVisible" prop is set to false', function () {
const props = {
...defaultProps,
trackChangesVisible: false,
}
renderWithEditorContext(<ToolbarHeader {...props} />)
expect(screen.queryByText('Review')).to.not.exist
})
})
describe('History toggle button', function () {
it('is displayed by default', function () {
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
screen.getByText('History')
})
it('is not displayed when "isRestrictedTokenMember" prop is set to true', function () {
const props = {
...defaultProps,
isRestrictedTokenMember: true,
}
renderWithEditorContext(<ToolbarHeader {...props} />)
expect(screen.queryByText('History')).to.not.exist
})
})
describe('Chat toggle button', function () {
it('is displayed by default', function () {
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
screen.getByText('Chat')
})
it('is not displayed when "chatVisible" prop is set to false', function () {
const props = {
...defaultProps,
chatVisible: false,
}
renderWithEditorContext(<ToolbarHeader {...props} />)
expect(screen.queryByText('Chat')).to.not.exist
})
})
describe('Publish button', function () {
it('is displayed by default', function () {
renderWithEditorContext(<ToolbarHeader {...defaultProps} />)
screen.getByText('Submit')
})
it('is not displayed for users with no publish permissions', function () {
const props = {
...defaultProps,
hasPublishPermissions: false,
}
renderWithEditorContext(<ToolbarHeader {...props} />)
expect(screen.queryByText('Submit')).to.not.exist
})
})
})

View File

@@ -0,0 +1,92 @@
import FileTreeCreateNameInput from '../../../../../../frontend/js/features/file-tree/components/file-tree-create/file-tree-create-name-input'
import FileTreeCreateNameProvider from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-create-name'
describe('<FileTreeCreateNameInput/>', function () {
it('renders an empty input', function () {
cy.mount(
<FileTreeCreateNameProvider>
<FileTreeCreateNameInput inFlight={false} />
</FileTreeCreateNameProvider>
)
cy.findByLabelText('File Name')
cy.findByPlaceholderText('File Name')
})
it('renders a custom label and placeholder', function () {
cy.mount(
<FileTreeCreateNameProvider>
<FileTreeCreateNameInput
label="File name in this project"
placeholder="Enter a file name…"
inFlight={false}
/>
</FileTreeCreateNameProvider>
)
cy.findByLabelText('File name in this project')
cy.findByPlaceholderText('Enter a file name…')
})
it('uses an initial name', function () {
cy.mount(
<FileTreeCreateNameProvider initialName="test.tex">
<FileTreeCreateNameInput inFlight={false} />
</FileTreeCreateNameProvider>
)
cy.findByLabelText('File Name').should('have.value', 'test.tex')
})
it('focuses the name', function () {
cy.spy(window, 'requestAnimationFrame').as('requestAnimationFrame')
cy.mount(
<FileTreeCreateNameProvider initialName="test.tex">
<FileTreeCreateNameInput focusName inFlight={false} />
</FileTreeCreateNameProvider>
)
cy.findByLabelText('File Name').as('input')
cy.get('@input').should('have.value', 'test.tex')
cy.get('@requestAnimationFrame').should('have.been.calledOnce')
// https://github.com/jsdom/jsdom/issues/2995
// "window.getSelection doesn't work with selection of <input> element"
// const selection = window.getSelection().toString()
// expect(selection).to.equal('test')
// wait for the selection to update
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(100)
cy.get<HTMLInputElement>('@input').then(element => {
expect(element.get(0).selectionStart).to.equal(0)
expect(element.get(0).selectionEnd).to.equal(4)
})
})
it('disables the input when in flight', function () {
cy.mount(
<FileTreeCreateNameProvider initialName="test.tex">
<FileTreeCreateNameInput inFlight={false} />
</FileTreeCreateNameProvider>
).then(({ rerender }) => {
cy.findByLabelText('File Name').should('not.be.disabled')
rerender(
<FileTreeCreateNameProvider initialName="test.tex">
<FileTreeCreateNameInput inFlight />
</FileTreeCreateNameProvider>
)
cy.findByLabelText('File Name').should('be.disabled')
rerender(
<FileTreeCreateNameProvider initialName="test.tex">
<FileTreeCreateNameInput inFlight={false} />
</FileTreeCreateNameProvider>
)
cy.findByLabelText('File Name').should('not.be.disabled')
})
})
})

View File

@@ -0,0 +1,497 @@
import { useEffect } from 'react'
import FileTreeModalCreateFile from '../../../../../../frontend/js/features/file-tree/components/modals/file-tree-modal-create-file'
import { useFileTreeActionable } from '../../../../../../frontend/js/features/file-tree/contexts/file-tree-actionable'
import { useFileTreeData } from '../../../../../../frontend/js/shared/context/file-tree-data-context'
import { EditorProviders } from '../../../../helpers/editor-providers'
import { FileTreeProvider } from '../../helpers/file-tree-provider'
import getMeta from '@/utils/meta'
describe('<FileTreeModalCreateFile/>', function () {
it('handles invalid file names', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<OpenWithMode mode="doc" />
</FileTreeProvider>
</EditorProviders>
)
cy.findByLabelText('File Name').as('input')
cy.findByRole('button', { name: 'Create' }).as('submit')
cy.get('@input').should('have.value', 'name.tex')
cy.get('@submit').should('not.be.disabled')
cy.findByRole('alert').should('not.exist')
cy.get('@input').clear()
cy.get('@submit').should('be.disabled')
cy.findByRole('alert').should('contain.text', 'File name is empty')
cy.get('@input').type('test.tex')
cy.get('@submit').should('not.be.disabled')
cy.findByRole('alert').should('not.exist')
cy.get('@input').type('oops/i/did/it/again')
cy.get('@submit').should('be.disabled')
cy.findByRole('alert').should('contain.text', 'contains invalid characters')
})
it('displays an error when the file limit is reached', function () {
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: Array.from({ length: 10 }, (_, index) => ({
_id: `entity-${index}`,
})),
fileRefs: [],
folders: [],
},
]
cy.mount(
<EditorProviders rootFolder={rootFolder as any}>
<FileTreeProvider>
<OpenWithMode mode="doc" />
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('alert')
.invoke('text')
.should('match', /This project has reached the \d+ file limit/)
})
it('displays a warning when the file limit is nearly reached', function () {
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: Array.from({ length: 9 }, (_, index) => ({
_id: `entity-${index}`,
})),
fileRefs: [],
folders: [],
},
]
cy.mount(
<EditorProviders rootFolder={rootFolder as any}>
<FileTreeProvider>
<OpenWithMode mode="doc" />
</FileTreeProvider>
</EditorProviders>
)
cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
})
it('counts files in nested folders', function () {
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: 'doc-1' }],
fileRefs: [],
folders: [
{
docs: [{ _id: 'doc-2' }],
fileRefs: [],
folders: [
{
docs: [
{ _id: 'doc-3' },
{ _id: 'doc-4' },
{ _id: 'doc-5' },
{ _id: 'doc-6' },
{ _id: 'doc-7' },
],
fileRefs: [],
folders: [],
},
],
},
],
},
]
cy.mount(
<EditorProviders rootFolder={rootFolder as any}>
<FileTreeProvider>
<OpenWithMode mode="doc" />
</FileTreeProvider>
</EditorProviders>
)
cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
})
it('counts folders toward the limit', function () {
getMeta('ol-ExposedSettings').maxEntitiesPerProject = 10
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: 'doc-1' }],
fileRefs: [],
folders: [
{ docs: [], fileRefs: [], folders: [] },
{ docs: [], fileRefs: [], folders: [] },
{ docs: [], fileRefs: [], folders: [] },
{ docs: [], fileRefs: [], folders: [] },
{ docs: [], fileRefs: [], folders: [] },
{ docs: [], fileRefs: [], folders: [] },
{ docs: [], fileRefs: [], folders: [] },
{ docs: [], fileRefs: [], folders: [] },
],
},
]
cy.mount(
<EditorProviders rootFolder={rootFolder as any}>
<FileTreeProvider>
<OpenWithMode mode="doc" />
</FileTreeProvider>
</EditorProviders>
)
cy.findByText(/This project is approaching the file limit \(\d+\/\d+\)/)
})
it('creates a new file when the form is submitted', function () {
cy.intercept('post', '/project/*/doc', {
statusCode: 204,
}).as('createDoc')
cy.mount(
<EditorProviders>
<FileTreeProvider>
<OpenWithMode mode="doc" />
</FileTreeProvider>
</EditorProviders>
)
cy.findByLabelText('File Name').type('test')
cy.findByRole('button', { name: 'Create' }).click()
cy.wait('@createDoc')
cy.get('@createDoc').its('request.body').should('deep.equal', {
parent_folder_id: 'root-folder-id',
name: 'test.tex',
})
})
it('imports a new file from a project', function () {
getMeta('ol-ExposedSettings').hasLinkedProjectFileFeature = true
getMeta('ol-ExposedSettings').hasLinkedProjectOutputFileFeature = true
cy.intercept('/user/projects', {
body: {
projects: [
{
_id: 'test-project',
name: 'This Project',
},
{
_id: 'project-1',
name: 'Project One',
},
{
_id: 'project-2',
name: 'Project Two',
},
],
},
})
cy.intercept('/project/*/entities', {
body: {
entities: [
{
path: '/foo.tex',
},
{
path: '/bar.tex',
},
],
},
})
cy.intercept('post', '/project/*/compile', {
body: {
status: 'success',
outputFiles: [
{
build: 'test',
path: 'baz.jpg',
},
{
build: 'test',
path: 'ball.jpg',
},
],
},
})
cy.intercept('post', '/project/*/linked_file', {
statusCode: 204,
}).as('createLinkedFile')
cy.mount(
<EditorProviders>
<FileTreeProvider>
<OpenWithMode mode="project" />
</FileTreeProvider>
</EditorProviders>
)
// initial state, no project selected
cy.findByLabelText('Select a Project').should('not.be.disabled')
// the submit button should be disabled
cy.findByRole('button', { name: 'Create' }).should('be.disabled')
// the source file selector should be disabled
cy.findByLabelText('Select a File').should('be.disabled')
cy.findByLabelText('Select an Output File').should('not.exist')
// TODO: check for options length, excluding current project
// select a project
cy.findByLabelText('Select a Project').select('project-2')
// wait for the source file selector to be enabled
cy.findByLabelText('Select a File').should('not.be.disabled')
cy.findByLabelText('Select an Output File').should('not.exist')
cy.findByRole('button', { name: 'Create' }).should('be.disabled')
// TODO: check for fileInput options length, excluding current project
// click on the button to toggle between source and output files
cy.findByRole('button', {
// NOTE: When changing the label, update the other tests with this label as well.
name: 'select from output files',
}).click()
// wait for the output file selector to be enabled
cy.findByLabelText('Select an Output File').should('not.be.disabled')
cy.findByLabelText('Select a File').should('not.exist')
cy.findByRole('button', { name: 'Create' }).should('be.disabled')
// TODO: check for entityInput options length, excluding current project
cy.findByLabelText('Select an Output File').select('ball.jpg')
cy.findByRole('button', { name: 'Create' }).should('not.be.disabled')
cy.findByRole('button', { name: 'Create' }).click()
cy.get('@createLinkedFile')
.its('request.body')
.should('deep.equal', {
name: 'ball.jpg',
provider: 'project_output_file',
parent_folder_id: 'root-folder-id',
data: {
source_project_id: 'project-2',
source_output_file_path: 'ball.jpg',
build_id: 'test',
},
})
})
describe('when the output files feature is not available', function () {
beforeEach(function () {
getMeta('ol-ExposedSettings').hasLinkedProjectFileFeature = true
getMeta('ol-ExposedSettings').hasLinkedProjectOutputFileFeature = false
})
it('should not show the import from output file mode', function () {
cy.intercept('/user/projects', {
body: {
projects: [
{
_id: 'test-project',
name: 'This Project',
},
{
_id: 'project-1',
name: 'Project One',
},
{
_id: 'project-2',
name: 'Project Two',
},
],
},
})
cy.mount(
<EditorProviders>
<FileTreeProvider>
<OpenWithMode mode="project" />
</FileTreeProvider>
</EditorProviders>
)
cy.findByLabelText('Select a File')
cy.findByRole('button', {
name: 'select from output files',
}).should('not.exist')
})
})
it('import from a URL when the form is submitted', function () {
cy.intercept('/project/*/linked_file', {
statusCode: 204,
}).as('createLinkedFile')
cy.mount(
<EditorProviders>
<FileTreeProvider>
<OpenWithMode mode="url" />
</FileTreeProvider>
</EditorProviders>
)
cy.findByLabelText('URL to fetch the file from').type(
'https://example.com/example.tex'
)
cy.findByLabelText('File Name In This Project').should(
'have.value',
'example.tex'
)
// check that the name can still be edited manually
cy.findByLabelText('File Name In This Project').clear()
cy.findByLabelText('File Name In This Project').type('test.tex')
cy.findByLabelText('File Name In This Project').should(
'have.value',
'test.tex'
)
cy.findByRole('button', { name: 'Create' }).click()
cy.get('@createLinkedFile')
.its('request.body')
.should('deep.equal', {
name: 'test.tex',
provider: 'url',
parent_folder_id: 'root-folder-id',
data: { url: 'https://example.com/example.tex' },
})
})
it('uploads a dropped file', function () {
cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
statusCode: 204,
}).as('uploadFile')
cy.mount(
<EditorProviders>
<FileTreeProvider>
<OpenWithMode mode="upload" />
</FileTreeProvider>
</EditorProviders>
)
// the submit button should not be present
cy.findByRole('button', { name: 'Create' }).should('not.exist')
cy.get('input[type=file]')
.eq(0)
.selectFile(
{
contents: Cypress.Buffer.from('test'),
fileName: 'test.tex',
mimeType: 'text/plain',
lastModified: Date.now(),
},
{
action: 'drag-drop',
force: true, // invisible element
}
)
cy.wait('@uploadFile')
})
it('uploads a pasted file', function () {
cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
statusCode: 204,
}).as('uploadFile')
cy.mount(
<EditorProviders>
<FileTreeProvider>
<OpenWithMode mode="upload" />
</FileTreeProvider>
</EditorProviders>
)
// the submit button should not be present
cy.findByRole('button', { name: 'Create' }).should('not.exist')
cy.wrap(null).then(() => {
const clipboardData = new DataTransfer()
clipboardData.items.add(
new File(['test'], 'test.tex', { type: 'text/plain' })
)
cy.findByLabelText('Uppy Dashboard').trigger('paste', { clipboardData })
})
cy.wait('@uploadFile')
})
it('displays upload errors', function () {
cy.intercept('post', '/project/*/upload?folder_id=root-folder-id', {
statusCode: 422,
body: { success: false, error: 'invalid_filename' },
}).as('uploadFile')
cy.mount(
<EditorProviders>
<FileTreeProvider>
<OpenWithMode mode="upload" />
</FileTreeProvider>
</EditorProviders>
)
// the submit button should not be present
cy.findByRole('button', { name: 'Create' }).should('not.exist')
cy.wrap(null).then(() => {
const clipboardData = new DataTransfer()
clipboardData.items.add(
new File(['test'], 'tes!t.tex', { type: 'text/plain' })
)
cy.findByLabelText('Uppy Dashboard').trigger('paste', { clipboardData })
})
cy.wait('@uploadFile')
cy.findByText(
`Upload failed: check that the file name doesnt contain special characters, trailing/leading whitespace or more than 150 characters`
)
})
})
function OpenWithMode({ mode }: { mode: string }) {
const { newFileCreateMode, startCreatingFile } = useFileTreeActionable()
const { fileCount } = useFileTreeData()
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => startCreatingFile(mode), [])
if (!fileCount || !newFileCreateMode) {
return null
}
return <FileTreeModalCreateFile />
}

View File

@@ -0,0 +1,77 @@
import FileTreeDoc from '../../../../../frontend/js/features/file-tree/components/file-tree-doc'
import { EditorProviders } from '../../../helpers/editor-providers'
import { FileTreeProvider } from '../helpers/file-tree-provider'
describe('<FileTreeDoc/>', function () {
it('renders unselected', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeDoc name="foo.tex" id="123abc" />
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('treeitem', { selected: false })
cy.get('.linked-file-highlight').should('not.exist')
})
it('renders selected', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '123abc' }],
fileRefs: [],
folders: [],
},
]
cy.mount(
<EditorProviders rootFolder={rootFolder as any}>
<FileTreeProvider>
<FileTreeDoc name="foo.tex" id="123abc" />,
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('treeitem', { selected: false }).click()
cy.findByRole('treeitem', { selected: true })
})
it('renders as linked file', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeDoc name="foo.tex" id="123abc" isLinkedFile />
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('treeitem')
cy.get('.linked-file-highlight')
})
it('multi-selects', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '123abc' }],
fileRefs: [],
folders: [],
},
]
cy.mount(
<EditorProviders rootFolder={rootFolder as any}>
<FileTreeProvider>
<FileTreeDoc name="foo.tex" id="123abc" />,
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('treeitem').click({ ctrlKey: true, cmdKey: true })
cy.findByRole('treeitem', { selected: true })
})
})

View File

@@ -0,0 +1,197 @@
import FileTreeFolderList from '../../../../../frontend/js/features/file-tree/components/file-tree-folder-list'
import { EditorProviders } from '../../../helpers/editor-providers'
import { FileTreeProvider } from '../helpers/file-tree-provider'
describe('<FileTreeFolderList/>', function () {
it('renders empty', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeFolderList folders={[]} docs={[]} files={[]} />
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('tree')
cy.findByRole('treeitem').should('not.exist')
})
it('renders docs, files and folders', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeFolderList
folders={[
{
_id: '456def',
name: 'A Folder',
folders: [],
docs: [],
fileRefs: [],
},
]}
docs={[{ _id: '789ghi', name: 'doc.tex' }]}
files={[
{
_id: '987jkl',
name: 'file.bib',
hash: 'some hash',
linkedFileData: {},
},
]}
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('tree')
cy.findByRole('treeitem', { name: 'A Folder' })
cy.findByRole('treeitem', { name: 'doc.tex' })
cy.findByRole('treeitem', { name: 'file.bib' })
})
describe('selection and multi-selection', function () {
it('without write permissions', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '1' }, { _id: '2' }],
fileRefs: [],
folders: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
permissionsLevel="readOnly"
>
<FileTreeProvider>
<FileTreeFolderList
folders={[]}
docs={[
{ _id: '1', name: '1.tex' },
{ _id: '2', name: '2.tex' },
]}
files={[]}
/>
</FileTreeProvider>
</EditorProviders>
)
// click on item 1: it gets selected
cy.findByRole('treeitem', { name: '1.tex' }).click()
cy.findByRole('treeitem', { name: '1.tex', selected: true })
cy.findByRole('treeitem', { name: '2.tex', selected: false })
// meta-click on item 2: no changes
cy.findByRole('treeitem', { name: '2.tex' }).click({
ctrlKey: true,
cmdKey: true,
})
cy.findByRole('treeitem', { name: '1.tex', selected: true })
cy.findByRole('treeitem', { name: '2.tex', selected: false })
})
it('with write permissions', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '1' }, { _id: '2' }, { _id: '3' }],
fileRefs: [],
folders: [],
},
]
cy.mount(
<EditorProviders rootFolder={rootFolder as any}>
<FileTreeProvider>
<FileTreeFolderList
folders={[]}
docs={[
{ _id: '1', name: '1.tex' },
{ _id: '2', name: '2.tex' },
{ _id: '3', name: '3.tex' },
]}
files={[]}
/>
</FileTreeProvider>
</EditorProviders>
)
// click item 1: it gets selected
cy.findByRole('treeitem', { name: '1.tex' }).click()
cy.findByRole('treeitem', { name: '1.tex', selected: true })
cy.findByRole('treeitem', { name: '2.tex', selected: false })
cy.findByRole('treeitem', { name: '3.tex', selected: false })
// click on item 2: it gets selected and item 1 is not selected anymore
cy.findByRole('treeitem', { name: '2.tex' }).click()
cy.findByRole('treeitem', { name: '1.tex', selected: false })
cy.findByRole('treeitem', { name: '2.tex', selected: true })
cy.findByRole('treeitem', { name: '3.tex', selected: false })
// meta-click on item 3: it gets selected and item 2 as well
cy.findByRole('treeitem', { name: '3.tex' }).click({
ctrlKey: true,
cmdKey: true,
})
cy.findByRole('treeitem', { name: '1.tex', selected: false })
cy.findByRole('treeitem', { name: '2.tex', selected: true })
cy.findByRole('treeitem', { name: '3.tex', selected: true })
// meta-click on item 1: add to selection
cy.findByRole('treeitem', { name: '1.tex' }).click({
ctrlKey: true,
cmdKey: true,
})
cy.findByRole('treeitem', { name: '1.tex', selected: true })
cy.findByRole('treeitem', { name: '2.tex', selected: true })
cy.findByRole('treeitem', { name: '3.tex', selected: true })
// meta-click on item 1: remove from selection
cy.findByRole('treeitem', { name: '1.tex' }).click({
ctrlKey: true,
cmdKey: true,
})
cy.findByRole('treeitem', { name: '1.tex', selected: false })
cy.findByRole('treeitem', { name: '2.tex', selected: true })
cy.findByRole('treeitem', { name: '3.tex', selected: true })
// meta-click on item 3: remove from selection
cy.findByRole('treeitem', { name: '3.tex' }).click({
ctrlKey: true,
cmdKey: true,
})
cy.findByRole('treeitem', { name: '1.tex', selected: false })
cy.findByRole('treeitem', { name: '2.tex', selected: true })
cy.findByRole('treeitem', { name: '3.tex', selected: false })
// meta-click on item 2: cannot unselect
cy.findByRole('treeitem', { name: '2.tex' }).click({
ctrlKey: true,
cmdKey: true,
})
cy.findByRole('treeitem', { name: '1.tex', selected: false })
cy.findByRole('treeitem', { name: '2.tex', selected: true })
cy.findByRole('treeitem', { name: '3.tex', selected: false })
// meta-click on item 3: add back to selection
cy.findByRole('treeitem', { name: '3.tex' }).click({
ctrlKey: true,
cmdKey: true,
})
cy.findByRole('treeitem', { name: '1.tex', selected: false })
cy.findByRole('treeitem', { name: '2.tex', selected: true })
cy.findByRole('treeitem', { name: '3.tex', selected: true })
// click on item 3: unselect other items
cy.findByRole('treeitem', { name: '3.tex' }).click()
cy.findByRole('treeitem', { name: '1.tex', selected: false })
cy.findByRole('treeitem', { name: '2.tex', selected: false })
cy.findByRole('treeitem', { name: '3.tex', selected: true })
})
})
})

View File

@@ -0,0 +1,134 @@
import FileTreeFolder from '../../../../../frontend/js/features/file-tree/components/file-tree-folder'
import { EditorProviders } from '../../../helpers/editor-providers'
import { FileTreeProvider } from '../helpers/file-tree-provider'
import { getContainerEl } from 'cypress/react'
import ReactDom from 'react-dom'
describe('<FileTreeFolder/>', function () {
it('renders unselected', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeFolder
name="foo"
id="123abc"
folders={[]}
docs={[]}
files={[]}
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('treeitem', { selected: false })
cy.findByRole('tree').should('not.exist')
})
it('renders selected', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '123abc' }],
fileRefs: [],
folders: [],
},
]
cy.mount(
<EditorProviders rootFolder={rootFolder as any}>
<FileTreeProvider>
<FileTreeFolder
name="foo"
id="123abc"
folders={[]}
docs={[]}
files={[]}
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('treeitem', { selected: false }).click()
cy.findByRole('treeitem', { selected: true })
cy.findByRole('tree').should('not.exist')
})
it('expands', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '123abc' }],
fileRefs: [],
folders: [],
},
]
cy.mount(
<EditorProviders rootFolder={rootFolder as any}>
<FileTreeProvider>
<FileTreeFolder
name="foo"
id="123abc"
folders={[]}
docs={[]}
files={[]}
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('treeitem')
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByRole('tree')
})
it('saves the expanded state for the next render', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '123abc' }],
fileRefs: [],
folders: [],
},
]
cy.mount(
<EditorProviders rootFolder={rootFolder as any}>
<FileTreeProvider>
<FileTreeFolder
name="foo"
id="123abc"
folders={[]}
docs={[]}
files={[]}
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('tree').should('not.exist')
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByRole('tree')
cy.then(() => ReactDom.unmountComponentAtNode(getContainerEl()))
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeFolder
name="foo"
id="123abc"
folders={[]}
docs={[]}
files={[]}
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('tree')
})
})

View File

@@ -0,0 +1,124 @@
import FileTreeitemInner from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-inner'
import FileTreeContextMenu from '../../../../../../frontend/js/features/file-tree/components/file-tree-context-menu'
import { EditorProviders } from '../../../../helpers/editor-providers'
import { FileTreeProvider } from '../../helpers/file-tree-provider'
describe('<FileTreeitemInner />', function () {
describe('menu', function () {
it('does not display if file is not selected', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeitemInner
id="123abc"
name="bar.tex"
isSelected={false}
type="doc"
/>
,
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('menu', { hidden: true }).should('not.exist')
})
})
describe('context menu', function () {
it('does not display without write permissions', function () {
cy.mount(
<EditorProviders permissionsLevel="readOnly">
<FileTreeProvider>
<FileTreeitemInner
id="123abc"
name="bar.tex"
isSelected
type="doc"
/>
<FileTreeContextMenu />
</FileTreeProvider>
</EditorProviders>
)
cy.get('div.entity').trigger('contextmenu')
cy.findByRole('menu', { hidden: true }).should('not.exist')
})
it('open / close', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeitemInner
id="123abc"
name="bar.tex"
isSelected
type="doc"
/>
<FileTreeContextMenu />
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('menu', { hidden: true }).should('not.exist')
// open the context menu
cy.get('div.entity').trigger('contextmenu')
cy.findByRole('menu')
// close the context menu
cy.get('div.entity').click()
cy.findByRole('menu').should('not.exist')
})
})
describe('name', function () {
it('renders name', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeitemInner
id="123abc"
name="bar.tex"
isSelected
type="doc"
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('button', { name: 'bar.tex' })
cy.findByRole('textbox').should('not.exist')
})
it('starts rename on menu item click', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '123abc', name: 'bar.tex' }],
folders: [],
fileRefs: [],
},
]
cy.mount(
<EditorProviders rootDocId="123abc" rootFolder={rootFolder as any}>
<FileTreeProvider>
<FileTreeitemInner
id="123abc"
name="bar.tex"
isSelected
type="doc"
/>
<FileTreeContextMenu />
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('button', { name: 'Open bar.tex action menu' }).click()
cy.findByRole('menuitem', { name: 'Rename' }).click()
cy.findByRole('button', { name: 'bar.tex' }).should('not.exist')
cy.findByRole('textbox')
})
})
})

View File

@@ -0,0 +1,108 @@
import FileTreeItemName from '../../../../../../frontend/js/features/file-tree/components/file-tree-item/file-tree-item-name'
import { EditorProviders } from '../../../../helpers/editor-providers'
import { FileTreeProvider } from '../../helpers/file-tree-provider'
describe('<FileTreeItemName />', function () {
it('renders name as button', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeItemName
name="foo.tex"
isSelected
setIsDraggable={cy.stub()}
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('button', { name: 'foo.tex' })
cy.findByRole('textbox').should('not.exist')
})
it("doesn't start renaming on unselected component", function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeItemName
name="foo.tex"
isSelected={false}
setIsDraggable={cy.stub()}
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('button').click()
cy.findByRole('button').click()
cy.findByRole('button').dblclick()
cy.findByRole('textbox').should('not.exist')
})
it('start renaming on double-click', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeItemName
name="foo.tex"
isSelected
setIsDraggable={cy.stub().as('setIsDraggable')}
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('button').click()
cy.findByRole('button').click()
cy.findByRole('button').dblclick()
cy.findByRole('textbox')
cy.findByRole('button').should('not.exist')
cy.get('@setIsDraggable').should('have.been.calledWith', false)
})
it('cannot start renaming in read-only', function () {
cy.mount(
<EditorProviders permissionsLevel="readOnly">
<FileTreeProvider>
<FileTreeItemName
name="foo.tex"
isSelected
setIsDraggable={cy.stub()}
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('button').click()
cy.findByRole('button').click()
cy.findByRole('button').dblclick()
cy.findByRole('textbox').should('not.exist')
})
describe('stop renaming', function () {
it('on Escape', function () {
cy.mount(
<EditorProviders>
<FileTreeProvider>
<FileTreeItemName
name="foo.tex"
isSelected
setIsDraggable={cy.stub().as('setIsDraggable')}
/>
</FileTreeProvider>
</EditorProviders>
)
cy.findByRole('button').click()
cy.findByRole('button').click()
cy.findByRole('button').dblclick()
cy.findByRole('textbox').clear()
cy.findByRole('textbox').type('bar.tex{esc}')
cy.findByRole('button', { name: 'foo.tex' })
cy.get('@setIsDraggable').should('have.been.calledWith', true)
})
})
})

View File

@@ -0,0 +1,354 @@
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
import { EditorProviders } from '../../../helpers/editor-providers'
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
describe('<FileTreeRoot/>', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-user', { id: 'user1' })
})
})
it('renders', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="456def"
features={{} as any}
permissionsLevel="owner"
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
)
cy.findByRole('tree')
cy.findByRole('treeitem')
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
cy.get('.disconnected-overlay').should('not.exist')
})
it('renders with invalid selected doc in local storage', function () {
global.localStorage.setItem(
'doc.open_id.123abc',
JSON.stringify('not-a-valid-id')
)
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: [],
},
]
cy.mount(
<div style={{ width: 400 }}>
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="456def"
features={{} as any}
permissionsLevel="owner"
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
</div>
)
// as a proxy to check that the invalid entity has not been select we start
// a delete and ensure the modal is displayed (the cancel button can be
// selected) This is needed to make sure the test fail.
cy.findByRole('treeitem', { name: 'main.tex' }).click({
ctrlKey: true,
cmdKey: true,
})
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByRole('button', { name: 'Cancel' })
})
it('renders disconnected overlay', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="456def"
features={{} as any}
permissionsLevel="owner"
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected={false}
/>
</EditorProviders>
)
cy.get('.disconnected-overlay')
})
it('fire onSelect', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{ _id: '456def', name: 'main.tex' },
{ _id: '789ghi', name: 'other.tex' },
],
folders: [],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="456def"
features={{} as any}
permissionsLevel="readOnly"
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub().as('onSelect')}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
)
cy.get('@onSelect').should('have.been.calledOnceWith', [
Cypress.sinon.match({
entity: Cypress.sinon.match({ _id: '456def', name: 'main.tex' }),
}),
])
cy.findByRole('tree')
cy.findByRole('treeitem', { name: 'other.tex' }).click()
cy.get('@onSelect').should('have.been.calledWith', [
Cypress.sinon.match({
entity: Cypress.sinon.match({ _id: '789ghi', name: 'other.tex' }),
}),
])
})
it('only shows a menu button when a single item is selected', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{ _id: '456def', name: 'main.tex' },
{ _id: '789ghi', name: 'other.tex' },
],
folders: [],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="456def"
features={{} as any}
permissionsLevel="owner"
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
)
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
cy.findByRole('treeitem', { name: 'other.tex', selected: false })
// single item selected: menu button is visible
cy.findAllByRole('button', { name: 'Open main.tex action menu' }).should(
'have.length',
1
)
// select the other item
cy.findByRole('treeitem', { name: 'other.tex' }).click()
cy.findByRole('treeitem', { name: 'main.tex', selected: false })
cy.findByRole('treeitem', { name: 'other.tex', selected: true })
// single item selected: menu button is visible
cy.findAllByRole('button', { name: 'Open other.tex action menu' }).should(
'have.length',
1
)
// multi-select the main item
cy.findByRole('treeitem', { name: 'main.tex' }).click({
ctrlKey: true,
cmdKey: true,
})
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
cy.findByRole('treeitem', { name: 'other.tex', selected: true })
// multiple items selected: no menu button is visible
cy.findAllByRole('button', { name: 'Open main.tex action menu' }).should(
'have.length',
0
)
})
describe('when deselecting files', function () {
let socket: SocketIOMock
beforeEach(function () {
socket = new SocketIOMock()
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '123abc', name: 'main.tex' }],
folders: [
{
_id: '789ghi',
name: 'thefolder',
docs: [{ _id: '456def', name: 'sub.tex' }],
fileRefs: [],
folders: [],
},
],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="456def"
features={{} as any}
permissionsLevel="owner"
socket={socket}
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
)
// select the sub file
cy.findByRole('treeitem', { name: 'sub.tex' }).click()
cy.findByRole('treeitem', { name: 'sub.tex' }).should(
'have.attr',
'aria-selected',
'true'
)
// click on empty area (after giving it extra height below the tree)
cy.findByTestId('file-tree-inner')
.invoke('attr', 'style', 'height: 400px')
.click()
})
it('removes the selected indicator', function () {
cy.findByRole('treeitem', { selected: true }).should('not.exist')
})
it('disables the "rename" and "delete" buttons', function () {
cy.findByRole('button', { name: 'Rename' }).should('not.exist')
cy.findByRole('button', { name: 'Delete' }).should('not.exist')
})
it('creates new file in the root folder', function () {
cy.intercept('project/*/doc', { statusCode: 200 })
cy.findByRole('button', { name: /new file/i }).click()
cy.findByRole('button', { name: /create/i }).click()
cy.then(() => {
socket.emitToClient('reciveNewDoc', 'root-folder-id', {
_id: '12345',
name: 'abcdef.tex',
docs: [],
fileRefs: [],
folders: [],
})
})
cy.findByRole('treeitem', { name: 'abcdef.tex' }).then($itemEl => {
cy.findByTestId('file-tree-list-root').then($rootEl => {
expect($itemEl.get(0).parentNode?.parentNode).to.equal($rootEl.get(0))
})
})
})
it('starts a new selection', function () {
cy.findByRole('treeitem', { name: 'sub.tex' }).should(
'have.attr',
'aria-selected',
'false'
)
cy.findByRole('treeitem', { name: 'main.tex' }).click({
ctrlKey: true,
cmdKey: true,
})
cy.findByRole('treeitem', { name: 'main.tex' }).should(
'have.attr',
'aria-selected',
'true'
)
})
})
})

View File

@@ -0,0 +1,59 @@
import FileTreeToolbar from '../../../../../frontend/js/features/file-tree/components/file-tree-toolbar'
import { EditorProviders } from '../../../helpers/editor-providers'
import { FileTreeProvider } from '../helpers/file-tree-provider'
describe('<FileTreeToolbar/>', function () {
it('without selected files', function () {
cy.mount(
<EditorProviders rootDocId="">
<FileTreeProvider>
<FileTreeToolbar />
</FileTreeProvider>
</EditorProviders>
)
cy.findAllByRole('button', { name: 'New file' })
cy.findAllByRole('button', { name: 'New folder' })
cy.findAllByRole('button', { name: 'Upload' })
cy.findAllByRole('button', { name: 'Rename' }).should('not.exist')
cy.findAllByRole('button', { name: 'Delete' }).should('not.exist')
})
it('read-only', function () {
cy.mount(
<EditorProviders permissionsLevel="readOnly">
<FileTreeProvider>
<FileTreeToolbar />
</FileTreeProvider>
</EditorProviders>
)
cy.findAllByRole('button').should('not.exist')
})
it('with one selected file', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: [],
},
]
cy.mount(
<EditorProviders rootDocId="456def" rootFolder={rootFolder as any}>
<FileTreeProvider>
<FileTreeToolbar />
</FileTreeProvider>
</EditorProviders>
)
cy.findAllByRole('button', { name: 'New file' })
cy.findAllByRole('button', { name: 'New folder' })
cy.findAllByRole('button', { name: 'Upload' })
cy.findAllByRole('button', { name: 'Rename' })
cy.findAllByRole('button', { name: 'Delete' })
})
})

View File

@@ -0,0 +1,114 @@
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
import { EditorProviders } from '../../../helpers/editor-providers'
describe('FileTree Context Menu Flow', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-user', { id: 'user1' })
})
})
it('opens on contextMenu event', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="456def"
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
)
cy.findByRole('menu').should('not.exist')
cy.findByRole('button', { name: 'main.tex' }).trigger('contextmenu')
cy.findByRole('menu')
})
it('closes when a new selection is started', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{ _id: '456def', name: 'main.tex' },
{ _id: '456def', name: 'foo.tex' },
],
folders: [],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="456def"
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
)
cy.findByRole('menu').should('not.exist')
cy.findByRole('button', { name: 'main.tex' }).trigger('contextmenu')
cy.findByRole('menu')
cy.findAllByRole('button', { name: 'foo.tex' }).click()
cy.findByRole('menu').should('not.exist')
})
it("doesn't open in read only mode", function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="456def"
permissionsLevel="readOnly"
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
)
cy.findAllByRole('button', { name: 'main.tex' }).trigger('contextmenu')
cy.findByRole('menu').should('not.exist')
})
})

View File

@@ -0,0 +1,281 @@
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
import { EditorProviders } from '../../../helpers/editor-providers'
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
describe('FileTree Create Folder Flow', function () {
let socket: SocketIOMock
beforeEach(function () {
socket = new SocketIOMock()
cy.window().then(win => {
win.metaAttributesCache.set('ol-user', { id: 'user1' })
})
})
it('add to root when no files are selected', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
socket={socket}
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
)
const name = 'Foo Bar In Root'
cy.intercept('post', '/project/*/folder', {
body: {
folders: [],
fileRefs: [],
docs: [],
_id: fakeId(),
name,
},
}).as('createFolder')
createFolder(name)
cy.get('@createFolder').its('request.body').should('deep.equal', {
parent_folder_id: 'root-folder-id',
name,
})
cy.then(() => {
socket.emitToClient('reciveNewFolder', 'root-folder-id', {
_id: fakeId(),
name,
docs: [],
fileRefs: [],
folders: [],
})
})
cy.findByRole('treeitem', { name })
})
it('add to folder from folder', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [],
folders: [
{
_id: '789ghi',
name: 'thefolder',
docs: [],
fileRefs: [],
folders: [],
},
],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="789ghi"
socket={socket}
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
)
cy.findByRole('button', { name: 'Expand' }).click()
const name = 'Foo Bar In thefolder'
cy.intercept('post', '/project/*/folder', {
body: {
folders: [],
fileRefs: [],
docs: [],
_id: fakeId(),
name,
},
}).as('createFolder')
createFolder(name)
cy.get('@createFolder').its('request.body').should('deep.equal', {
parent_folder_id: '789ghi',
name,
})
cy.then(() => {
socket.emitToClient('reciveNewFolder', '789ghi', {
_id: fakeId(),
name,
docs: [],
fileRefs: [],
folders: [],
})
})
// find the created folder
cy.findByRole('treeitem', { name })
// collapse the parent folder; created folder should not be rendered anymore
cy.findByRole('button', { name: 'Collapse' }).click()
cy.findByRole('treeitem', { name }).should('not.exist')
})
it('add to folder from child', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [],
folders: [
{
_id: '789ghi',
name: 'thefolder',
docs: [],
fileRefs: [{ _id: '456def', name: 'sub.tex' }],
folders: [],
},
],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="456def"
socket={socket}
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
)
const name = 'Foo Bar In thefolder'
cy.intercept('post', '/project/*/folder', {
body: {
folders: [],
fileRefs: [],
docs: [],
_id: fakeId(),
name,
},
}).as('createFolder')
createFolder(name)
cy.get('@createFolder').its('request.body').should('deep.equal', {
parent_folder_id: '789ghi',
name,
})
cy.then(() => {
socket.emitToClient('reciveNewFolder', '789ghi', {
_id: fakeId(),
name,
docs: [],
fileRefs: [],
folders: [],
})
})
// find the created folder
cy.findByRole('treeitem', { name })
// collapse the parent folder; created folder should not be rendered anymore
cy.findByRole('button', { name: 'Collapse' }).click()
cy.findByRole('treeitem', { name }).should('not.exist')
})
it('prevents adding duplicate or invalid names', function () {
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '456def', name: 'existingFile' }],
folders: [],
fileRefs: [],
},
]
cy.mount(
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
rootDocId="456def"
socket={socket}
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
)
const name = 'existingFile'
cy.intercept('post', '/project/*/folder', cy.spy().as('createFolder'))
createFolder(name)
cy.get('@createFolder').should('not.have.been.called')
cy.findByRole('alert', {
name: 'A file or folder with this name already exists',
})
cy.findByRole('textbox').type('in/valid ')
cy.findByRole('alert', {
name: 'File name is empty or contains invalid characters',
})
})
function createFolder(name: string) {
cy.findByRole('button', { name: 'New folder' }).click()
cy.findByRole('textbox').type(name)
cy.findByRole('button', { name: 'Create' }).click()
}
function fakeId() {
return Math.random().toString(16).replace(/0\./, 'random-test-id-')
}
})

View File

@@ -0,0 +1,291 @@
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
import { EditorProviders } from '../../../helpers/editor-providers'
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
describe('FileTree Delete Entity Flow', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-user', { id: 'user1' })
})
})
describe('single entity', function () {
let socket: SocketIOMock
beforeEach(function () {
socket = new SocketIOMock()
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{ _id: '123abc', name: 'foo.tex' },
{ _id: '456def', name: 'main.tex' },
],
folders: [],
fileRefs: [],
},
]
cy.mount(
<div style={{ width: 400 }}>
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
socket={socket}
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
</div>
)
cy.findByRole('treeitem', { name: 'main.tex' }).click()
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
})
it('removes item', function () {
cy.intercept('delete', '/project/*/doc/*', { statusCode: 204 }).as(
'deleteDoc'
)
cy.findByRole('dialog').within(() => {
// check that the confirmation modal is open
cy.findByText(
'Are you sure you want to permanently delete the following files?'
)
cy.findByRole('button', { name: 'Delete' }).click()
})
cy.wait('@deleteDoc')
cy.then(() => {
socket.emitToClient('removeEntity', '456def')
})
cy.findByRole('treeitem', {
name: 'main.tex',
hidden: true, // treeitem might be hidden behind the modal
}).should('not.exist')
cy.findByRole('treeitem', {
name: 'main.tex',
}).should('not.exist')
// check that the confirmation modal is closed
cy.findByText(
'Are you sure you want to permanently delete the following files?'
).should('not.exist')
cy.get('@deleteDoc.all').should('have.length', 1)
})
it('continues delete on 404s', function () {
cy.intercept('delete', '/project/*/doc/*', { statusCode: 404 }).as(
'deleteDoc'
)
cy.findByRole('dialog').within(() => {
// check that the confirmation modal is open
cy.findByText(
'Are you sure you want to permanently delete the following files?'
)
cy.findByRole('button', { name: 'Delete' }).click()
})
cy.then(() => {
socket.emitToClient('removeEntity', '456def')
})
cy.findByRole('treeitem', {
name: 'main.tex',
hidden: true, // treeitem might be hidden behind the modal
}).should('not.exist')
cy.findByRole('treeitem', {
name: 'main.tex',
}).should('not.exist')
// check that the confirmation modal is closed
// is not, the 404 probably triggered a bug
cy.findByText(
'Are you sure you want to permanently delete the following files?'
).should('not.exist')
})
it('aborts delete on error', function () {
cy.intercept('delete', '/project/*/doc/*', { statusCode: 500 }).as(
'deleteDoc'
)
cy.findByRole('dialog').within(() => {
cy.findByRole('button', { name: 'Delete' }).click()
})
// The modal should still be open, but the file should not be deleted
cy.findByRole('treeitem', { name: 'main.tex', hidden: true })
})
})
describe('folders', function () {
let socket: SocketIOMock
beforeEach(function () {
socket = new SocketIOMock()
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [
{
_id: '123abc',
name: 'folder',
docs: [],
folders: [],
fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
},
],
fileRefs: [],
},
]
cy.mount(
<div style={{ width: 400 }}>
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
socket={socket}
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
</div>
)
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByRole('treeitem', { name: 'main.tex' }).click()
cy.findByRole('treeitem', { name: 'my.bib' }).click({
ctrlKey: true,
cmdKey: true,
})
cy.then(() => {
socket.emitToClient('removeEntity', '123abc')
})
})
it('removes the folder', function () {
cy.findByRole('treeitem', { name: 'folder' }).should('not.exist')
})
it('leaves the main file selected', function () {
cy.findByRole('treeitem', { name: 'main.tex', selected: true })
})
it('unselect the child entity', function () {
// as a proxy to check that the child entity has been unselect we start
// a delete and ensure the modal is displayed (the cancel button can be
// selected) This is needed to make sure the test fail.
cy.findByRole('button', { name: 'Open main.tex action menu' }).click()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByRole('button', { name: 'Cancel' })
})
})
describe('multiple entities', function () {
let socket: SocketIOMock
beforeEach(function () {
socket = new SocketIOMock()
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '456def', name: 'main.tex' }],
folders: [],
fileRefs: [{ _id: '789ghi', name: 'my.bib' }],
},
]
cy.mount(
<div style={{ width: 400 }}>
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
socket={socket}
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub()}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
</div>
)
// select two files
cy.findByRole('treeitem', { name: 'main.tex' }).click()
cy.findByRole('treeitem', { name: 'my.bib' }).click({
ctrlKey: true,
cmdKey: true,
})
// open the context menu
cy.findByRole('button', { name: 'my.bib' }).trigger('contextmenu')
// make sure the menu has opened, with only a "Delete" item (as multiple files are selected)
cy.findByRole('menu')
cy.findAllByRole('menuitem').should('have.length', 1)
// select the Delete menu item
cy.findByRole('menuitem', { name: 'Delete' }).click()
})
it('removes all items and reindexes references after deleting .bib file', function () {
cy.intercept('delete', '/project/123abc/doc/456def', {
statusCode: 204,
}).as('deleteDoc')
cy.intercept('delete', '/project/123abc/file/789ghi', {
statusCode: 204,
}).as('deleteFile')
cy.findByRole('dialog').within(() => {
cy.findByRole('button', { name: 'Delete' }).click()
})
cy.then(() => {
socket.emitToClient('removeEntity', '456def')
socket.emitToClient('removeEntity', '789ghi')
})
for (const name of ['main.tex', 'my.bib']) {
for (const hidden of [true, false]) {
cy.findByRole('treeitem', { name, hidden }).should('not.exist')
}
}
// check that the confirmation modal is closed
cy.findByText('Are you sure').should('not.exist')
cy.get('@deleteDoc.all').should('have.length', 1)
cy.get('@deleteFile.all').should('have.length', 1)
})
})
})

View File

@@ -0,0 +1,164 @@
import FileTreeRoot from '../../../../../frontend/js/features/file-tree/components/file-tree-root'
import { EditorProviders } from '../../../helpers/editor-providers'
import { SocketIOMock } from '@/ide/connection/SocketIoShim'
describe('FileTree Rename Entity Flow', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-user', { id: 'user1' })
})
})
let socket: SocketIOMock
beforeEach(function () {
socket = new SocketIOMock()
const rootFolder = [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [{ _id: '456def', name: 'a.tex' }],
folders: [
{
_id: '987jkl',
name: 'folder',
docs: [],
fileRefs: [
{ _id: '789ghi', name: 'c.tex' },
{ _id: '981gkp', name: 'e.tex' },
],
folders: [],
},
],
fileRefs: [],
},
]
cy.mount(
<div style={{ width: 400 }}>
<EditorProviders
rootFolder={rootFolder as any}
projectId="123abc"
socket={socket}
>
<FileTreeRoot
refProviders={{}}
setRefProviderEnabled={cy.stub()}
setStartedFreeTrial={cy.stub()}
onSelect={cy.stub().as('onSelect')}
onInit={cy.stub()}
isConnected
/>
</EditorProviders>
</div>
)
})
it('renames doc', function () {
cy.intercept('/project/*/doc/*/rename', { statusCode: 204 }).as('renameDoc')
renameItem('a.tex', 'b.tex')
cy.findByRole('treeitem', { name: 'b.tex' })
cy.get('@renameDoc').its('request.body').should('deep.equal', {
name: 'b.tex',
})
})
it('renames folder', function () {
cy.intercept('/project/*/folder/*/rename', { statusCode: 204 }).as(
'renameFolder'
)
renameItem('folder', 'new folder name')
cy.findByRole('treeitem', { name: 'new folder name' })
cy.get('@renameFolder').its('request.body').should('deep.equal', {
name: 'new folder name',
})
})
it('renames file in subfolder', function () {
cy.intercept('/project/*/file/*/rename', { statusCode: 204 }).as(
'renameFile'
)
cy.findByRole('button', { name: 'Expand' }).click()
renameItem('c.tex', 'd.tex')
cy.findByRole('treeitem', { name: 'folder' })
cy.findByRole('treeitem', { name: 'd.tex' })
cy.get('@renameFile').its('request.body').should('deep.equal', {
name: 'd.tex',
})
})
it('reverts rename on error', function () {
cy.intercept('/project/*/doc/*/rename', { statusCode: 500 })
renameItem('a.tex', 'b.tex')
cy.findByRole('treeitem', { name: 'a.tex' })
})
it('shows error modal on invalid filename', function () {
renameItem('a.tex', '///')
cy.findByRole('alert', {
name: 'File name is empty or contains invalid characters',
hidden: true,
})
})
it('shows error modal on duplicate filename', function () {
renameItem('a.tex', 'folder')
cy.findByRole('alert', {
name: 'A file or folder with this name already exists',
hidden: true,
})
})
it('shows error modal on duplicate filename in subfolder', function () {
cy.findByRole('button', { name: 'Expand' }).click()
renameItem('c.tex', 'e.tex')
cy.findByRole('alert', {
name: 'A file or folder with this name already exists',
hidden: true,
})
})
it('shows error modal on blocked filename', function () {
renameItem('a.tex', 'prototype')
cy.findByRole('alert', {
name: 'This file name is blocked.',
hidden: true,
})
})
describe('via socket event', function () {
it('renames doc', function () {
cy.findByRole('treeitem', { name: 'a.tex' })
cy.then(() => {
socket.emitToClient('reciveEntityRename', '456def', 'socket.tex')
})
cy.findByRole('treeitem', { name: 'socket.tex' })
})
})
function renameItem(from: string, to: string) {
cy.findByRole('treeitem', { name: from }).click()
cy.findByRole('button', { name: `Open ${from} action menu` }).click()
cy.findByRole('menuitem', { name: 'Rename' }).click()
cy.findByRole('textbox').clear()
cy.findByRole('textbox').type(to + '{enter}')
}
})

View File

@@ -0,0 +1,34 @@
import { ComponentProps, FC, useRef, useState } from 'react'
import FileTreeContext from '@/features/file-tree/components/file-tree-context'
export const FileTreeProvider: FC<{
refProviders?: Record<string, boolean>
}> = ({ children, refProviders = {} }) => {
const [fileTreeContainer, setFileTreeContainer] =
useState<HTMLDivElement | null>(null)
const propsRef =
useRef<Omit<ComponentProps<typeof FileTreeContext>, 'refProviders'>>()
if (propsRef.current === undefined) {
propsRef.current = {
setRefProviderEnabled: cy.stub().as('setRefProviderEnabled'),
setStartedFreeTrial: cy.stub().as('setStartedFreeTrial'),
onSelect: cy.stub(),
}
}
return (
<div ref={setFileTreeContainer}>
{fileTreeContainer && (
<FileTreeContext
refProviders={refProviders}
fileTreeContainer={fileTreeContainer}
{...propsRef.current}
>
<>{children}</>
</FileTreeContext>
)}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { expect } from 'chai'
import iconTypeFromName from '../../../../../frontend/js/features/file-tree/util/icon-type-from-name'
describe('iconTypeFromName', function () {
it('returns correct icon type', function () {
expect(iconTypeFromName('main.tex')).to.equal('description')
expect(iconTypeFromName('main.png')).to.equal('image')
expect(iconTypeFromName('main.csv')).to.equal('table_chart')
expect(iconTypeFromName('main.py')).to.equal('code')
expect(iconTypeFromName('main.bib')).to.equal('menu_book')
})
it('handles missing extensions', function () {
expect(iconTypeFromName('main')).to.equal('description')
})
it('lowercases extension', function () {
expect(iconTypeFromName('ZOTERO.BIB')).to.equal('menu_book')
})
})

View File

@@ -0,0 +1,162 @@
import { expect } from 'chai'
import { Folder } from '../../../../../types/folder'
import { docId } from '../../source-editor/helpers/mock-doc'
import {
findEntityByPath,
pathInFolder,
previewByPath,
} from '@/features/file-tree/util/path'
describe('Path utils', function () {
let rootFolder: Folder
beforeEach(function () {
rootFolder = {
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: docId,
name: 'main.tex',
},
],
folders: [
{
_id: 'test-folder-id',
name: 'test-folder',
docs: [
{
_id: 'test-doc-in-folder',
name: 'example.tex',
},
],
fileRefs: [
{
_id: 'test-file-in-folder',
name: 'example.png',
hash: '42',
},
],
folders: [
{
_id: 'test-subfolder-id',
name: 'test-subfolder',
docs: [
{
_id: 'test-doc-in-subfolder',
name: 'nested-example.tex',
},
],
fileRefs: [
{
_id: 'test-file-in-subfolder',
name: 'nested-example.png',
hash: '43',
},
],
folders: [],
},
],
},
],
fileRefs: [
{
_id: 'test-image-file',
name: 'frog.jpg',
hash: '21',
},
{
_id: 'uppercase-extension-image-file',
name: 'frog.JPG',
hash: '22',
},
],
}
})
describe('pathInFolder', function () {
it('gets null path for non-existent entity', function () {
const retrieved = pathInFolder(rootFolder, 'non-existent.tex')
expect(retrieved).to.be.null
})
it('gets correct path for document in the root', function () {
const retrieved = pathInFolder(rootFolder, docId)
expect(retrieved).to.equal('main.tex')
})
it('gets correct path for document in a folder', function () {
const retrieved = pathInFolder(rootFolder, 'test-doc-in-folder')
expect(retrieved).to.equal('test-folder/example.tex')
})
it('gets correct path for document in a nested folder', function () {
const retrieved = pathInFolder(rootFolder, 'test-doc-in-subfolder')
expect(retrieved).to.equal(
'test-folder/test-subfolder/nested-example.tex'
)
})
it('gets correct path for file in a nested folder', function () {
const retrieved = pathInFolder(rootFolder, 'test-file-in-subfolder')
expect(retrieved).to.equal(
'test-folder/test-subfolder/nested-example.png'
)
})
it('gets correct path for file in a nested folder relative to folder', function () {
const retrieved = pathInFolder(
rootFolder.folders[0],
'test-file-in-subfolder'
)
expect(retrieved).to.equal('test-subfolder/nested-example.png')
})
})
describe('findEntityByPath', function () {
it('returns null for a non-existent path', function () {
const retrieved = findEntityByPath(rootFolder, 'not-a-real-document.tex')
expect(retrieved).to.be.null
})
it('finds a document in the root', function () {
const retrieved = findEntityByPath(rootFolder, 'main.tex')
expect(retrieved?.entity._id).to.equal(docId)
})
it('finds a document in a folder', function () {
const retrieved = findEntityByPath(rootFolder, 'test-folder/example.tex')
expect(retrieved?.entity._id).to.equal('test-doc-in-folder')
})
it('finds a document in a nested folder', function () {
const retrieved = findEntityByPath(
rootFolder,
'test-folder/test-subfolder/nested-example.tex'
)
expect(retrieved?.entity._id).to.equal('test-doc-in-subfolder')
})
it('finds a file in a nested folder', function () {
const retrieved = findEntityByPath(
rootFolder,
'test-folder/test-subfolder/nested-example.png'
)
expect(retrieved?.entity._id).to.equal('test-file-in-subfolder')
})
})
describe('previewByPath', function () {
it('returns extension without preceding dot', function () {
const preview = previewByPath(
rootFolder,
'test-project-id',
'test-folder/example.png'
)
expect(preview).to.deep.equal({
url: '/project/test-project-id/blob/42?fallback=test-file-in-folder',
extension: 'png',
})
})
})
})

View File

@@ -0,0 +1,108 @@
import { screen } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import FileViewHeader from '../../../../../frontend/js/features/file-view/components/file-view-header'
import { USER_ID } from '../../../helpers/editor-providers'
import { fileViewFile } from '@/features/ide-react/util/file-view'
describe('<FileViewHeader/>', function () {
const urlFile = {
name: 'example.tex',
linkedFileData: {
url: 'https://overleaf.com',
provider: 'url',
},
created: new Date(2021, 1, 17, 3, 24).toISOString(),
}
const projectFile = {
name: 'example.tex',
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_project_id: 'source-project-id',
source_entity_path: '/source-entity-path.ext',
provider: 'project_file',
importer_id: USER_ID,
},
created: new Date(2021, 1, 17, 3, 24).toISOString(),
}
const projectOutputFile = {
name: 'example.pdf',
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_output_file_path: '/source-entity-path.ext',
provider: 'project_output_file',
},
created: new Date(2021, 1, 17, 3, 24).toISOString(),
}
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
})
describe('header text', function () {
it('Renders the correct text for a file with the url provider', function () {
renderWithEditorContext(<FileViewHeader file={urlFile} />)
screen.getByText('Imported from', { exact: false })
screen.getByText('at 3:24 am Wed, 17th Feb 21', {
exact: false,
})
})
it('Renders the correct text for a file with the project_file provider', function () {
renderWithEditorContext(<FileViewHeader file={projectFile} />)
screen.getByText('Imported from', { exact: false })
screen.getByText('Another project', { exact: false })
screen.getByText('/source-entity-path.ext, at 3:24 am Wed, 17th Feb 21', {
exact: false,
})
})
it('Renders the correct text for a file with the project_output_file provider', function () {
renderWithEditorContext(
<FileViewHeader
file={projectOutputFile}
storeReferencesKeys={() => {}}
/>
)
screen.getByText('Imported from the output of', { exact: false })
screen.getByText('Another project', { exact: false })
screen.getByText('/source-entity-path.ext, at 3:24 am Wed, 17th Feb 21', {
exact: false,
})
})
})
describe('The download button', function () {
it('exists', function () {
renderWithEditorContext(<FileViewHeader file={urlFile} />)
screen.getByText('Download')
})
})
it('should use importedAt as timestamp when present in the linked file data', function () {
const fileFromServer = {
name: 'example.tex',
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_project_id: 'source-project-id',
source_entity_path: '/source-entity-path.ext',
provider: 'project_file',
importer_id: USER_ID,
importedAt: new Date(2024, 8, 16, 1, 30).getTime(),
},
created: new Date(2021, 1, 17, 3, 24).toISOString(),
}
// FIXME: This should be tested through the <EditorAndPdf /> component instead
const fileShown = fileViewFile(fileFromServer)
renderWithEditorContext(<FileViewHeader file={fileShown} />)
screen.getByText('Imported from', { exact: false })
screen.getByText('Another project', { exact: false })
screen.getByText('/source-entity-path.ext, at 1:30 am Mon, 16th Sep 24', {
exact: false,
})
})
})

View File

@@ -0,0 +1,23 @@
import { screen } from '@testing-library/react'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import FileViewImage from '../../../../../frontend/js/features/file-view/components/file-view-image'
describe('<FileViewImage />', function () {
const file = {
id: '60097ca20454610027c442a8',
name: 'file.jpg',
hash: 'hash',
linkedFileData: {
source_entity_path: '/source-entity-path',
provider: 'project_file',
},
}
it('renders an image', function () {
renderWithEditorContext(
<FileViewImage file={file} onError={() => {}} onLoad={() => {}} />
)
screen.getByRole('img')
})
})

View File

@@ -0,0 +1,53 @@
import {
screen,
fireEvent,
waitForElementToBeRemoved,
} from '@testing-library/react'
import fetchMock from 'fetch-mock'
import sinon from 'sinon'
import FileViewRefreshButton from '@/features/file-view/components/file-view-refresh-button'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import { USER_ID } from '../../../helpers/editor-providers'
describe('<FileViewRefreshButton />', function () {
const projectFile = {
name: 'example.tex',
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_project_id: 'source-project-id',
source_entity_path: '/source-entity-path.ext',
provider: 'project_file',
importer_id: USER_ID,
},
created: new Date(2021, 1, 17, 3, 24).toISOString(),
}
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
})
// eslint-disable-next-line mocha/no-skipped-tests
it.skip('Changes text when the file is refreshing', async function () {
fetchMock.post(
'express:/project/:project_id/linked_file/:file_id/refresh',
{
new_file_id: '5ff7418157b4e144321df5c4',
}
)
renderWithEditorContext(
<FileViewRefreshButton
file={projectFile}
setRefreshError={sinon.stub()}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
await waitForElementToBeRemoved(() =>
screen.getByText('Refreshing', { exact: false })
)
await screen.findByRole('button', { name: 'Refresh' })
})
})

View File

@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import FileViewRefreshError from '@/features/file-view/components/file-view-refresh-error'
import type { BinaryFile } from '@/features/file-view/types/binary-file'
describe('<FileViewRefreshError />', function () {
it('shows correct error message', function () {
const anotherProjectFile: BinaryFile<'project_file'> = {
id: '123abc',
_id: '123abc',
linkedFileData: {
provider: 'project_file',
source_project_id: 'some-id',
source_entity_path: '/path/',
},
created: new Date(2023, 1, 17, 3, 24),
name: 'frog.jpg',
type: 'file',
selected: true,
hash: '42',
}
render(
<FileViewRefreshError
file={anotherProjectFile}
refreshError="An error message"
/>
)
screen.getByText('Access Denied: An error message')
})
})

View File

@@ -0,0 +1,41 @@
import { screen } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import FileViewText from '../../../../../frontend/js/features/file-view/components/file-view-text'
describe('<FileViewText/>', function () {
const file = {
id: '123',
hash: '1234',
name: 'example.tex',
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_project_id: 'source-project-id',
source_entity_path: '/source-entity-path.ext',
provider: 'project_file',
},
created: new Date(2021, 1, 17, 3, 24).toISOString(),
}
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('renders a text view', async function () {
fetchMock.head('express:/project/:project_id/blob/:hash', {
status: 201,
headers: { 'Content-Length': 10000 },
})
fetchMock.get(
'express:/project/:project_id/blob/:hash',
'Text file content'
)
renderWithEditorContext(
<FileViewText file={file} onError={() => {}} onLoad={() => {}} />
)
await screen.findByText('Text file content', { exact: false })
})
})

View File

@@ -0,0 +1,88 @@
import {
screen,
waitForElementToBeRemoved,
fireEvent,
} from '@testing-library/react'
import fetchMock from 'fetch-mock'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import FileView from '../../../../../frontend/js/features/file-view/components/file-view'
describe('<FileView/>', function () {
const textFile = {
id: 'text-file',
name: 'example.tex',
linkedFileData: {
v1_source_doc_id: 'v1-source-id',
source_project_id: 'source-project-id',
source_entity_path: '/source-entity-path.ext',
provider: 'project_file',
},
hash: '012345678901234567890123',
created: new Date(2021, 1, 17, 3, 24).toISOString(),
}
const imageFile = {
id: '60097ca20454610027c442a8',
name: 'file.jpg',
linkedFileData: {
source_entity_path: '/source-entity-path',
provider: 'project_file',
},
}
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
})
describe('for a text file', function () {
it('shows a loading indicator while the file is loading', async function () {
fetchMock.head('express:/project/:project_id/blob/:hash', {
status: 201,
headers: { 'Content-Length': 10000 },
})
fetchMock.get(
'express:/project/:project_id/blob/:hash',
'Text file content'
)
renderWithEditorContext(<FileView file={textFile} />)
await waitForElementToBeRemoved(() =>
screen.getByTestId('loading-panel-file-view')
)
})
it('shows messaging if the text view could not be loaded', async function () {
const unpreviewableTextFile = {
...textFile,
name: 'example.not-tex',
}
renderWithEditorContext(<FileView file={unpreviewableTextFile} />)
await screen.findByText('Sorry, no preview is available', {
exact: false,
})
})
})
describe('for an image file', function () {
it('shows a loading indicator while the file is loading', async function () {
renderWithEditorContext(<FileView file={imageFile} />)
screen.getByTestId('loading-panel-file-view')
})
it('shows messaging if the image could not be loaded', async function () {
renderWithEditorContext(<FileView file={imageFile} />)
// Fake the image request failing as the request is handled by the browser
fireEvent.error(screen.getByRole('img'))
await screen.findByText('Sorry, no preview is available', {
exact: false,
})
})
})
})

View File

@@ -0,0 +1,160 @@
import { EditorProviders } from '../../../helpers/editor-providers'
import FullProjectSearch from '../../../../../modules/full-project-search/frontend/js/components/full-project-search'
import {
LayoutContext,
LayoutContextValue,
} from '@/shared/context/layout-context'
import { FC, useState } from 'react'
describe('<FullProjectSearch/>', function () {
beforeEach(function () {
cy.interceptCompile()
cy.intercept('/project/*/flush', {
statusCode: 204,
}).as('project-history-flush')
cy.intercept('/project/*/changes?*', {
body: [],
}).as('project-history-changes')
cy.intercept('/project/*/latest/history', {
body: { chunk: mockHistoryChunk },
}).as('project-history-snapshot')
cy.intercept('get', '/project/*/blob/*', req => {
const blobId = req.url.split('/').pop() as string
req.reply({
fixture: `blobs/${blobId}`,
})
}).as('project-history-blob')
})
it('displays the search form', function () {
cy.mount(
<EditorProviders providers={{ LayoutProvider }}>
<FullProjectSearch />
</EditorProviders>
)
cy.findByRole('button', { name: 'Search' })
})
it('displays a close button', function () {
cy.mount(
<EditorProviders providers={{ LayoutProvider }}>
<FullProjectSearch />
</EditorProviders>
)
cy.findByRole('button', { name: 'Close' })
})
it('displays matched content', function () {
cy.mount(
<EditorProviders providers={{ LayoutProvider }}>
<FullProjectSearch />
</EditorProviders>
)
cy.findByRole('searchbox', { name: 'Search' }).type('and{enter}')
cy.findByRole('button', { name: 'main.tex 5' }) // TODO: remove count from name?
cy.get('.matched-file-hit').as('matches')
cy.get('@matches').should('have.length', 5)
cy.get('@matches').first().click()
cy.get('@matches').first().should('have.class', 'matched-file-hit-selected')
})
})
const createInitialValue = () =>
({
reattach: cy.stub(),
detach: cy.stub(),
detachIsLinked: false,
detachRole: null,
changeLayout: cy.stub(),
view: 'editor',
setView: cy.stub(),
chatIsOpen: false,
setChatIsOpen: cy.stub(),
reviewPanelOpen: false,
setReviewPanelOpen: cy.stub(),
miniReviewPanelVisible: false,
setMiniReviewPanelVisible: cy.stub(),
leftMenuShown: false,
setLeftMenuShown: cy.stub(),
loadingStyleSheet: false,
setLoadingStyleSheet: cy.stub(),
pdfLayout: 'flat',
pdfPreviewOpen: false,
projectSearchIsOpen: true,
setProjectSearchIsOpen: cy.stub(),
}) satisfies LayoutContextValue
const LayoutProvider: FC = ({ children }) => {
const [value] = useState(createInitialValue)
return (
<LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
)
}
const mockHistoryChunk = {
history: {
snapshot: {
files: {},
},
changes: [
{
operations: [
{
pathname: 'main.tex',
file: {
hash: '5199b66d9d1226551be436c66bad9d962cc05537',
stringLength: 7066,
},
},
],
timestamp: '2025-01-03T10:10:40.840Z',
authors: [],
v2Authors: ['66e040e0da7136ec75ffe8a3'],
projectVersion: '1.0',
},
{
operations: [
{
pathname: 'sample.bib',
file: {
hash: 'a0e21c740cf81e868f158e30e88985b5ea1d6c19',
stringLength: 244,
},
},
],
timestamp: '2025-01-03T10:10:40.856Z',
authors: [],
v2Authors: ['66e040e0da7136ec75ffe8a3'],
projectVersion: '2.0',
},
{
operations: [
{
pathname: 'frog.jpg',
file: {
hash: '5b889ef3cf71c83a4c027c4e4dc3d1a106b27809',
byteLength: 97080,
},
},
],
timestamp: '2025-01-03T10:10:40.890Z',
authors: [],
v2Authors: ['66e040e0da7136ec75ffe8a3'],
projectVersion: '3.0',
},
],
},
startVersion: 0,
}

View File

@@ -0,0 +1,374 @@
import AddSeats, {
MAX_NUMBER_OF_USERS,
} from '@/features/group-management/components/add-seats/add-seats'
describe('<AddSeats />', function () {
beforeEach(function () {
this.totalLicenses = 5
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-subscriptionId', '123')
win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses)
win.metaAttributesCache.set('ol-isProfessional', false)
})
cy.mount(<AddSeats />)
cy.findByRole('button', { name: /buy licenses/i })
cy.findByTestId('add-more-users-group-form')
})
it('renders the back button', function () {
cy.findByTestId('group-heading').within(() => {
cy.findByRole('button', { name: /back to subscription/i }).should(
'have.attr',
'href',
'/user/subscription'
)
})
})
it('shows the group name', function () {
cy.findByTestId('group-heading').within(() => {
cy.findByRole('heading', { name: 'My Awesome Team' })
})
})
it('shows the "Buy more licenses" label', function () {
cy.findByText(/buy more licenses/i)
})
it('shows the maximum supported users', function () {
cy.findByText(
new RegExp(
`your current plan supports up to ${this.totalLicenses} licenses`,
'i'
)
)
})
it('shows instructions on how to reduce licenses on a plan', function () {
cy.contains(
/if you want to reduce the number of licenses on your plan, please contact customer support/i
).within(() => {
cy.findByRole('link', { name: /contact customer support/i }).should(
'have.attr',
'href',
'/contact'
)
})
})
it('renders the cancel button', function () {
cy.findByRole('button', { name: /cancel/i }).should(
'have.attr',
'href',
'/user/subscription'
)
})
describe('"Upgrade my plan" link', function () {
it('shows the link', function () {
cy.findByRole('link', { name: /upgrade my plan/i }).should(
'have.attr',
'href',
'/user/subscription/group/upgrade-subscription'
)
})
it('hides the link', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-isProfessional', true)
})
cy.mount(<AddSeats />)
cy.findByRole('link', { name: /upgrade my plan/i }).should('not.exist')
})
})
describe('cost summary', function () {
beforeEach(function () {
cy.findByLabelText(/how many licenses do you want to buy/i).as('input')
})
it('shows the title', function () {
cy.findByTestId('cost-summary').within(() => {
cy.findByText(/cost summary/i)
})
})
describe('shows default content when', function () {
afterEach(function () {
cy.findByTestId('cost-summary').within(() => {
cy.findByText(
/enter the number of licenses youd like to add to see the cost breakdown/i
)
})
})
it('leaves input empty', function () {
cy.get('@input').should('have.value', '')
})
it('fills in a non-numeric value', function () {
cy.get('@input').type('ab')
cy.findByText(/value must be a number/i)
})
it('fills in a decimal value', function () {
cy.get('@input').type('1.5')
cy.findByText(/value must be a whole number/i)
})
it('fills in a "0" value', function () {
cy.get('@input').type('0')
cy.findByText(/value must be at least 1/i)
})
it('fills in a value and clears the input', function () {
cy.get('@input').type('a{backspace}')
cy.get('@input').should('have.text', '')
cy.findByText(/this field is required/i)
})
})
describe('entered more than the maximum allowed number of users', function () {
beforeEach(function () {
this.numberOfUsersExceedingMaxLimit = MAX_NUMBER_OF_USERS + 1
cy.get('@input').type(this.numberOfUsersExceedingMaxLimit.toString())
cy.findByRole('button', { name: /buy licenses/i }).should('not.exist')
cy.findByRole('button', { name: /send request/i }).as('sendRequestBtn')
})
it('renders a notification', function () {
cy.findByTestId('cost-summary').should('not.exist')
cy.findByRole('alert').should(
'contain.text',
`If you want more than ${MAX_NUMBER_OF_USERS} licenses on your plan, we need to add them for you. Just click Send request below and well be happy to help.`
)
})
describe('request', function () {
afterEach(function () {
cy.findByRole('button', { name: /go to subscriptions/i }).should(
'have.attr',
'href',
'/user/subscription'
)
})
function makeRequest(statusCode: number, adding: string) {
cy.intercept(
'POST',
'/user/subscription/group/add-users/sales-contact-form',
{
statusCode,
}
).as('addUsersRequest')
cy.get('@sendRequestBtn').click()
cy.get('@addUsersRequest').its('request.body').should('deep.equal', {
adding,
})
cy.findByTestId('add-more-users-group-form').should('not.exist')
}
it('sends a request that succeeds', function () {
makeRequest(204, this.numberOfUsersExceedingMaxLimit.toString())
cy.findByTestId('title').should(
'contain.text',
'Weve got your request'
)
cy.findByText(/our team will get back to you shortly/i)
})
it('sends a request that fails', function () {
makeRequest(400, this.numberOfUsersExceedingMaxLimit.toString())
cy.findByTestId('title').should(
'contain.text',
'Something went wrong'
)
cy.contains(
/it looks like that didnt work. You can try again or get in touch with our Support team for more help/i
).within(() => {
cy.findByRole('link', { name: /get in touch/i }).should(
'have.attr',
'href',
'/contact'
)
})
})
})
})
describe('entered less than the maximum allowed number of users', function () {
beforeEach(function () {
this.adding = 1
this.body = {
change: {
type: 'add-on-update',
addOn: {
code: 'additional-license',
quantity: this.totalLicenses + this.adding,
prevQuantity: this.totalLicenses,
},
},
currency: 'USD',
immediateCharge: {
subtotal: 100,
tax: 20,
total: 120,
discount: 0,
},
nextInvoice: {
date: '2025-12-01T00:00:00.000Z',
plan: {
name: 'Overleaf Standard Group',
amount: 0,
},
subtotal: 895,
tax: {
rate: 0.2,
amount: 105,
},
total: 1000,
},
}
cy.findByRole('button', { name: /buy licenses/i }).as('addUsersBtn')
cy.findByRole('button', { name: /send request/i }).should('not.exist')
})
it('renders the preview data', function () {
cy.intercept('POST', '/user/subscription/group/add-users/preview', {
statusCode: 200,
body: this.body,
}).as('addUsersRequest')
cy.get('@input').type(this.adding.toString())
cy.findByTestId('cost-summary').within(() => {
cy.contains(
new RegExp(
`youre adding ${this.adding} licenses to your plan giving you a total of ${this.body.change.addOn.quantity} licenses`,
'i'
)
)
cy.findByTestId('plan').within(() => {
cy.findByText(
`${this.body.nextInvoice.plan.name} x ${this.adding} Licenses`
)
cy.findByTestId('price').should(
'have.text',
`$${this.body.immediateCharge.subtotal}.00`
)
})
cy.findByTestId('tax').within(() => {
cy.findByText(
new RegExp(`VAT · ${this.body.nextInvoice.tax.rate * 100}%`, 'i')
)
cy.findByTestId('price').should(
'have.text',
`$${this.body.immediateCharge.tax}.00`
)
})
cy.findByTestId('discount').should('not.exist')
cy.findByTestId('total').within(() => {
cy.findByText(/total due today/i)
cy.findByTestId('price').should(
'have.text',
`$${this.body.immediateCharge.total}.00`
)
})
cy.findByText(
/well charge you now for the cost of your additional licenses based on the remaining months of your current subscription/i
)
cy.findByText(
/after that, well bill you \$1,000\.00 \(\$895\.00 \+ \$105\.00 tax\) annually on December 1, unless you cancel/i
)
})
})
it('renders the preview data with discount', function () {
this.body.immediateCharge.discount = 50
cy.intercept('POST', '/user/subscription/group/add-users/preview', {
statusCode: 200,
body: this.body,
}).as('addUsersRequest')
cy.get('@input').type(this.adding.toString())
cy.findByTestId('cost-summary').within(() => {
cy.findByTestId('discount').within(() => {
cy.findByText(`($${this.body.immediateCharge.discount}.00)`)
})
cy.findByText(
/This does not include your current discounts, which will be applied automatically before your next payment/i
)
})
})
describe('request', function () {
afterEach(function () {
cy.findByRole('button', { name: /go to subscriptions/i }).should(
'have.attr',
'href',
'/user/subscription'
)
})
function makeRequest(statusCode: number, adding: string) {
cy.intercept('POST', '/user/subscription/group/add-users/create', {
statusCode,
}).as('addUsersRequest')
cy.get('@input').type(adding)
cy.get('@addUsersBtn').click()
cy.get('@addUsersRequest')
.its('request.body')
.should('deep.equal', {
adding: Number(adding),
})
cy.findByTestId('add-more-users-group-form').should('not.exist')
}
it('sends a request that succeeds', function () {
makeRequest(204, this.adding.toString())
cy.findByTestId('title').should(
'contain.text',
'Youve added more license(s)'
)
cy.findByText(/youve added more license\(s\) to your subscription/i)
cy.findByRole('link', { name: /invite people/i }).should(
'have.attr',
'href',
'/manage/groups/123/members'
)
})
it('sends a request that fails', function () {
makeRequest(400, this.adding.toString())
cy.findByTestId('title').should(
'contain.text',
'Something went wrong'
)
cy.contains(
/it looks like that didnt work. You can try again or get in touch with our Support team for more help/i
).within(() => {
cy.findByRole('link', { name: /get in touch/i }).should(
'have.attr',
'href',
'/contact'
)
})
})
})
})
})
})

View File

@@ -0,0 +1,175 @@
import GroupManagers from '@/features/group-management/components/group-managers'
const JOHN_DOE = {
_id: 'abc123def456',
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@test.com',
last_active_at: new Date('2023-01-15'),
invite: true,
}
const BOBBY_LAPOINTE = {
_id: 'bcd234efa567',
first_name: 'Bobby',
last_name: 'Lapointe',
email: 'bobby.lapointe@test.com',
last_active_at: new Date('2023-01-02'),
invite: false,
}
const GROUP_ID = '888fff888fff'
const PATHS = {
addMember: `/manage/groups/${GROUP_ID}/managers`,
removeMember: `/manage/groups/${GROUP_ID}/managers`,
}
describe('group managers', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
})
cy.mount(<GroupManagers />)
})
it('renders the group management page', function () {
cy.findByRole('heading', { name: /my awesome team/i, level: 1 })
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByText('john.doe@test.com')
cy.findByText('John Doe')
cy.findByText('15th Jan 2023')
cy.findByText('Invite not yet accepted')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByText('bobby.lapointe@test.com')
cy.findByText('Bobby Lapointe')
cy.findByText('2nd Jan 2023')
cy.findByText('Accepted invite')
})
})
})
it('sends an invite', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 201,
body: {
user: {
email: 'someone.else@test.com',
invite: true,
},
},
})
cy.findByTestId('add-members-form').within(() => {
cy.findByRole('textbox').type('someone.else@test.com')
cy.findByRole('button').click()
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(3)').within(() => {
cy.findByText('someone.else@test.com')
cy.findByText('N/A')
cy.findByText('Invite not yet accepted')
})
})
})
it('tries to send an invite and displays the error', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 500,
body: {
error: {
message: 'User already added',
},
},
})
cy.findByTestId('add-members-form').within(() => {
cy.findByRole('textbox').type('someone.else@test.com')
cy.findByRole('button').click()
})
cy.findByRole('alert').should('contain.text', 'Error: User already added')
})
it('checks the select all checkbox', function () {
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).should('not.be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByLabelText(/select user/i).should('not.be.checked')
})
})
cy.findByTestId('managed-entities-table')
.find('thead')
.within(() => {
cy.findByLabelText(/select all/i).check()
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).should('be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByLabelText(/select user/i).should('be.checked')
})
})
})
it('remove a member', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 200,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).check()
})
})
cy.findByRole('button', { name: /remove manager/i }).click()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByText('bobby.lapointe@test.com')
cy.findByText('Bobby Lapointe')
cy.findByText('2nd Jan 2023')
cy.findByText('Accepted invite')
})
})
})
it('tries to remove a manager and displays the error', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 500,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).check()
})
})
cy.findByRole('button', { name: /remove manager/i }).click()
cy.findByRole('alert').should('contain.text', 'Sorry, something went wrong')
})
})

View File

@@ -0,0 +1,577 @@
import GroupMembers from '@/features/group-management/components/group-members'
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
import { User } from '../../../../../types/group-management/user'
const GROUP_ID = '777fff777fff'
const PATHS = {
addMember: `/manage/groups/${GROUP_ID}/invites`,
removeMember: `/manage/groups/${GROUP_ID}/user`,
removeInvite: `/manage/groups/${GROUP_ID}/invites`,
exportMembers: `/manage/groups/${GROUP_ID}/members/export`,
}
describe('GroupMembers', function () {
function mountGroupMembersProvider() {
cy.mount(
<GroupMembersProvider>
<GroupMembers />
</GroupMembersProvider>
)
}
describe('with Managed Users and Group SSO disabled', function () {
const JOHN_DOE = {
_id: 'abc123def456',
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@test.com',
last_active_at: new Date('2023-01-15'),
invite: true,
}
const BOBBY_LAPOINTE = {
_id: 'bcd234efa567',
first_name: 'Bobby',
last_name: 'Lapointe',
email: 'bobby.lapointe@test.com',
last_active_at: new Date('2023-01-02'),
invite: false,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-groupSize', 10)
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
})
cy.mount(
<GroupMembersProvider>
<GroupMembers />
</GroupMembersProvider>
)
})
it('renders the group members page', function () {
cy.findByRole('heading', { name: /my awesome team/i, level: 1 })
cy.findByTestId('page-header-members-details').contains(
'You have added 2 of 10 available members'
)
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.contains('john.doe@test.com')
cy.contains('John Doe')
cy.contains('15th Jan 2023')
cy.findByTestId('badge-pending-invite').should(
'have.text',
'Pending invite'
)
})
cy.get('tr:nth-child(2)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.contains('Bobby Lapointe')
cy.contains('2nd Jan 2023')
cy.findByTestId('badge-pending-invite').should('not.exist')
})
})
})
it('sends an invite', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 201,
body: {
user: {
email: 'someone.else@test.com',
invite: true,
},
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(3)').within(() => {
cy.contains('someone.else@test.com')
cy.contains('N/A')
cy.findByTestId('badge-pending-invite').should(
'have.text',
'Pending invite'
)
})
})
})
it('tries to send an invite and displays the error', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 500,
body: {
error: {
message: 'User already added',
},
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByRole('alert').contains('Error: User already added')
})
it('checks the select all checkbox', function () {
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').should('not.be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByTestId('select-single-checkbox').should('not.be.checked')
})
})
cy.findByTestId('select-all-checkbox').click()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').should('be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByTestId('select-single-checkbox').should('be.checked')
})
})
})
it('remove a member', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 200,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').check()
})
})
cy.get('button').contains('Remove from group').click()
cy.get('small').contains('You have added 1 of 10 available members')
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.contains('Bobby Lapointe')
cy.contains('2nd Jan 2023')
cy.contains('Pending invite').should('not.exist')
})
})
})
it('tries to remove a user and displays the error', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 500,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').check()
})
})
cy.get('button').contains('Remove from group').click()
cy.findByRole('alert').contains('Sorry, something went wrong')
})
})
describe('with Managed Users enabled', function () {
const JOHN_DOE: User = {
_id: 'abc123def456',
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@test.com',
last_active_at: new Date('2023-01-15'),
invite: true,
}
const BOBBY_LAPOINTE: User = {
_id: 'bcd234efa567',
first_name: 'Bobby',
last_name: 'Lapointe',
email: 'bobby.lapointe@test.com',
last_active_at: new Date('2023-01-02'),
invite: false,
}
const CLAIRE_JENNINGS: User = {
_id: 'defabc231453',
first_name: 'Claire',
last_name: 'Jennings',
email: 'claire.jennings@test.com',
last_active_at: new Date('2023-01-03'),
invite: false,
enrollment: {
managedBy: GROUP_ID,
enrolledAt: new Date('2023-01-03'),
sso: [
{
groupId: GROUP_ID,
linkedAt: new Date(),
primary: true,
},
],
},
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [
JOHN_DOE,
BOBBY_LAPOINTE,
CLAIRE_JENNINGS,
])
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-groupSize', 10)
win.metaAttributesCache.set('ol-managedUsersActive', true)
})
mountGroupMembersProvider()
})
it('renders the group members page', function () {
cy.get('h1').contains('My Awesome Team')
cy.get('small').contains('You have added 3 of 10 available members')
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.contains('john.doe@test.com')
cy.contains('John Doe')
cy.contains('15th Jan 2023')
cy.get('.visually-hidden').contains('Pending invite')
cy.findByTestId('badge-pending-invite').should(
'have.text',
'Pending invite'
)
cy.get(`.security-state-invite-pending`).should('exist')
})
cy.get('tr:nth-child(2)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.contains('Bobby Lapointe')
cy.contains('2nd Jan 2023')
cy.findByTestId('badge-pending-invite').should('not.exist')
cy.get('.visually-hidden').contains('Not managed')
})
cy.get('tr:nth-child(3)').within(() => {
cy.contains('claire.jennings@test.com')
cy.contains('Claire Jennings')
cy.contains('3rd Jan 2023')
cy.findByTestId('badge-pending-invite').should('not.exist')
cy.get('.visually-hidden').contains('Managed')
})
})
})
it('sends an invite', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 201,
body: {
user: {
email: 'someone.else@test.com',
invite: true,
},
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(4)').within(() => {
cy.contains('someone.else@test.com')
cy.contains('N/A')
cy.get('.visually-hidden').contains('Pending invite')
cy.findByTestId('badge-pending-invite').should(
'have.text',
'Pending invite'
)
cy.get(`.security-state-invite-pending`).should('exist')
})
})
})
it('tries to send an invite and displays the error', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 500,
body: {
error: {
message: 'User already added',
},
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByRole('alert').contains('Error: User already added')
})
it('checks the select all checkbox', function () {
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').should('not.be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByTestId('select-single-checkbox').should('not.be.checked')
})
})
cy.findByTestId('select-all-checkbox').click()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').should('be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByTestId('select-single-checkbox').should('be.checked')
})
})
cy.get('button').contains('Remove from group').click()
})
it('remove a member', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 200,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').check()
})
})
cy.get('button').contains('Remove from group').click()
cy.get('small').contains('You have added 2 of 10 available members')
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.contains('Bobby Lapointe')
cy.contains('2nd Jan 2023')
})
})
})
it('cannot remove a managed member', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 200,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
// no checkbox should be shown for 'Claire Jennings', a managed user
cy.get('tr:nth-child(3)').within(() => {
cy.findByTestId('select-single-checkbox').should('not.exist')
})
})
})
it('tries to remove a user and displays the error', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 500,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').check()
})
})
cy.get('.page-header').within(() => {
cy.get('button').contains('Remove from group').click()
})
cy.findByRole('alert').contains('Sorry, something went wrong')
})
})
describe('with Group SSO enabled', function () {
const JOHN_DOE: User = {
_id: 'abc123def456',
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@test.com',
last_active_at: new Date('2023-01-15'),
invite: true,
}
const BOBBY_LAPOINTE: User = {
_id: 'bcd234efa567',
first_name: 'Bobby',
last_name: 'Lapointe',
email: 'bobby.lapointe@test.com',
last_active_at: new Date('2023-01-02'),
invite: false,
}
const CLAIRE_JENNINGS: User = {
_id: 'defabc231453',
first_name: 'Claire',
last_name: 'Jennings',
email: 'claire.jennings@test.com',
last_active_at: new Date('2023-01-03'),
invite: false,
enrollment: {
managedBy: GROUP_ID,
enrolledAt: new Date('2023-01-03'),
sso: [
{
groupId: GROUP_ID,
linkedAt: new Date(),
primary: true,
},
],
},
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [
JOHN_DOE,
BOBBY_LAPOINTE,
CLAIRE_JENNINGS,
])
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-groupSize', 10)
win.metaAttributesCache.set('ol-managedUsersActive', false)
win.metaAttributesCache.set('ol-groupSSOActive', true)
})
mountGroupMembersProvider()
})
it('should display the Security column', function () {
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(2)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.get('.visually-hidden').contains('SSO not active')
})
cy.get('tr:nth-child(3)').within(() => {
cy.contains('claire.jennings@test.com')
cy.get('.visually-hidden').contains('SSO active')
})
})
})
})
describe('with flexible group licensing enabled', function () {
beforeEach(function () {
this.JOHN_DOE = {
_id: 'abc123def456',
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@test.com',
last_active_at: new Date('2023-01-15'),
invite: false,
}
this.BOBBY_LAPOINTE = {
_id: 'bcd234efa567',
first_name: 'Bobby',
last_name: 'Lapointe',
email: 'bobby.lapointe@test.com',
last_active_at: new Date('2023-01-02'),
invite: false,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-groupSize', 10)
win.metaAttributesCache.set('ol-canUseFlexibleLicensing', true)
win.metaAttributesCache.set('ol-canUseAddSeatsFeature', true)
})
})
it('renders the group members page with the new text', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [
this.JOHN_DOE,
this.BOBBY_LAPOINTE,
])
})
cy.mount(
<GroupMembersProvider>
<GroupMembers />
</GroupMembersProvider>
)
cy.findByTestId('group-size-details').contains(
'You have 2 licenses and your plan supports up to 10. Buy more licenses.'
)
cy.findByTestId('add-more-members-form').within(() => {
cy.contains('Invite more members')
cy.get('button').contains('Invite')
})
})
it('renders the group members page with new text when only has one group member', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [this.JOHN_DOE])
})
cy.mount(
<GroupMembersProvider>
<GroupMembers />
</GroupMembersProvider>
)
cy.findByTestId('group-size-details').contains(
'You have 1 license and your plan supports up to 10. Buy more licenses.'
)
})
it('renders the group members page without "buy more licenses" link when not admin', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [this.JOHN_DOE])
win.metaAttributesCache.set('ol-canUseAddSeatsFeature', false)
})
cy.mount(
<GroupMembersProvider>
<GroupMembers />
</GroupMembersProvider>
)
cy.findByTestId('group-size-details').within(() => {
cy.findByText(/you have \d+ license and your plan supports up to \d+/i)
cy.findByText(/buy more licenses/i).should('not.exist')
})
})
})
})

View File

@@ -0,0 +1,175 @@
import InstitutionManagers from '@/features/group-management/components/institution-managers'
const JOHN_DOE = {
_id: 'abc123def456',
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@test.com',
last_active_at: new Date('2023-01-15'),
invite: true,
}
const BOBBY_LAPOINTE = {
_id: 'bcd234efa567',
first_name: 'Bobby',
last_name: 'Lapointe',
email: 'bobby.lapointe@test.com',
last_active_at: new Date('2023-01-02'),
invite: false,
}
const GROUP_ID = '999fff999fff'
const PATHS = {
addMember: `/manage/institutions/${GROUP_ID}/managers`,
removeMember: `/manage/institutions/${GROUP_ID}/managers`,
}
describe('institution managers', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
win.metaAttributesCache.set('ol-groupName', 'My Awesome Institution')
})
cy.mount(<InstitutionManagers />)
})
it('renders the institution management page', function () {
cy.findByRole('heading', { name: /my awesome institution/i, level: 1 })
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByText('john.doe@test.com')
cy.findByText('John Doe')
cy.findByText('15th Jan 2023')
cy.findByText('Invite not yet accepted')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByText('bobby.lapointe@test.com')
cy.findByText('Bobby Lapointe')
cy.findByText('2nd Jan 2023')
cy.findByText('Accepted invite')
})
})
})
it('sends an invite', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 201,
body: {
user: {
email: 'someone.else@test.com',
invite: true,
},
},
})
cy.findByTestId('add-members-form').within(() => {
cy.findByRole('textbox').type('someone.else@test.com')
cy.findByRole('button').click()
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(3)').within(() => {
cy.findByText('someone.else@test.com')
cy.findByText('N/A')
cy.findByText('Invite not yet accepted')
})
})
})
it('tries to send an invite and displays the error', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 500,
body: {
error: {
message: 'User already added',
},
},
})
cy.findByTestId('add-members-form').within(() => {
cy.findByRole('textbox').type('someone.else@test.com')
cy.findByRole('button').click()
})
cy.findByRole('alert').should('contain.text', 'Error: User already added')
})
it('checks the select all checkbox', function () {
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).should('not.be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByLabelText(/select user/i).should('not.be.checked')
})
})
cy.findByTestId('managed-entities-table')
.find('thead')
.within(() => {
cy.findByLabelText(/select all/i).check()
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).should('be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByLabelText(/select user/i).should('be.checked')
})
})
})
it('remove a member', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 200,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).check()
})
})
cy.findByRole('button', { name: /remove manager/i }).click()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByText('bobby.lapointe@test.com')
cy.findByText('Bobby Lapointe')
cy.findByText('2nd Jan 2023')
cy.findByText('Accepted invite')
})
})
})
it('tries to remove a manager and displays the error', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 500,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).check()
})
})
cy.findByRole('button', { name: /remove manager/i }).click()
cy.findByRole('alert').should('contain.text', 'Sorry, something went wrong')
})
})

View File

@@ -0,0 +1,313 @@
import GroupMembers from '@/features/group-management/components/group-members'
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
import { User } from '../../../../../types/group-management/user'
import { SplitTestProvider } from '@/shared/context/split-test-context'
const GROUP_ID = '777fff777fff'
const JOHN_DOE: User = {
_id: 'abc123def456',
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@test.com',
last_active_at: new Date('2023-01-15'),
invite: true,
}
const BOBBY_LAPOINTE: User = {
_id: 'bcd234efa567',
first_name: 'Bobby',
last_name: 'Lapointe',
email: 'bobby.lapointe@test.com',
last_active_at: new Date('2023-01-02'),
invite: false,
enrollment: {
sso: [
{
groupId: 'another',
linkedAt: new Date(),
primary: true,
},
],
},
}
const CLAIRE_JENNINGS: User = {
_id: 'defabc231453',
first_name: 'Claire',
last_name: 'Jennings',
email: 'claire.jennings@test.com',
last_active_at: new Date('2023-01-03'),
invite: false,
enrollment: {
managedBy: GROUP_ID,
enrolledAt: new Date('2023-01-03'),
sso: [
{
groupId: GROUP_ID,
linkedAt: new Date(),
primary: true,
},
],
},
}
const PATHS = {
addMember: `/manage/groups/${GROUP_ID}/invites`,
removeMember: `/manage/groups/${GROUP_ID}/user`,
removeInvite: `/manage/groups/${GROUP_ID}/invites`,
exportMembers: `/manage/groups/${GROUP_ID}/members/export`,
}
function mountGroupMembersProvider() {
cy.mount(
<SplitTestProvider>
<GroupMembersProvider>
<GroupMembers />
</GroupMembersProvider>
</SplitTestProvider>
)
}
describe('group members, with managed users', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [
JOHN_DOE,
BOBBY_LAPOINTE,
CLAIRE_JENNINGS,
])
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-groupSize', 10)
win.metaAttributesCache.set('ol-managedUsersActive', true)
})
mountGroupMembersProvider()
})
it('renders the group members page', function () {
cy.get('h1').contains('My Awesome Team')
cy.get('small').contains('You have added 3 of 10 available members')
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.contains('john.doe@test.com')
cy.contains('John Doe')
cy.contains('15th Jan 2023')
cy.get('.visually-hidden').contains('Pending invite')
cy.findByTestId('badge-pending-invite').should(
'have.text',
'Pending invite'
)
cy.get(`.security-state-invite-pending`).should('exist')
})
cy.get('tr:nth-child(2)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.contains('Bobby Lapointe')
cy.contains('2nd Jan 2023')
cy.findByTestId('badge-pending-invite').should('not.exist')
cy.get('.visually-hidden').contains('Not managed')
})
cy.get('tr:nth-child(3)').within(() => {
cy.contains('claire.jennings@test.com')
cy.contains('Claire Jennings')
cy.contains('3rd Jan 2023')
cy.findByTestId('badge-pending-invite').should('not.exist')
cy.get('.visually-hidden').contains('Managed')
})
})
})
it('sends an invite', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 201,
body: {
user: {
email: 'someone.else@test.com',
invite: true,
},
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(4)').within(() => {
cy.contains('someone.else@test.com')
cy.contains('N/A')
cy.get('.visually-hidden').contains('Pending invite')
cy.findByTestId('badge-pending-invite').should(
'have.text',
'Pending invite'
)
cy.get(`.security-state-invite-pending`).should('exist')
})
})
})
it('tries to send an invite and displays the error', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 500,
body: {
error: {
message: 'User already added',
},
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByRole('alert').contains('Error: User already added')
})
it('checks the select all checkbox', function () {
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').should('not.be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByTestId('select-single-checkbox').should('not.be.checked')
})
})
cy.findByTestId('select-all-checkbox').click()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').should('be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByTestId('select-single-checkbox').should('be.checked')
})
})
cy.get('button').contains('Remove from group').click()
})
it('remove a member', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 200,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').check()
})
})
cy.get('button').contains('Remove from group').click()
cy.get('small').contains('You have added 2 of 10 available members')
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.contains('Bobby Lapointe')
cy.contains('2nd Jan 2023')
})
})
})
it('cannot remove a managed member', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 200,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
// no checkbox should be shown for 'Claire Jennings', a managed user
cy.get('tr:nth-child(3)').within(() => {
cy.findByTestId('select-single-checkbox').should('not.exist')
})
})
})
it('tries to remove a user and displays the error', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 500,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByTestId('select-single-checkbox').check()
})
})
cy.get('.page-header').within(() => {
cy.get('button').contains('Remove from group').click()
})
cy.findByRole('alert').contains('Sorry, something went wrong')
})
})
describe('Group members when group SSO is enabled', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [
JOHN_DOE,
BOBBY_LAPOINTE,
CLAIRE_JENNINGS,
])
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-groupSize', 10)
win.metaAttributesCache.set('ol-managedUsersActive', true)
})
})
it('should not display SSO Column when group sso is not enabled', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupSSOActive', false)
})
mountGroupMembersProvider()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(2)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.get('.visually-hidden')
.contains('SSO not active')
.should('not.exist')
})
cy.get('tr:nth-child(3)').within(() => {
cy.contains('claire.jennings@test.com')
cy.get('.visually-hidden').contains('SSO active').should('not.exist')
})
})
})
it('should display SSO Column when Group SSO is enabled', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupSSOActive', true)
})
mountGroupMembersProvider()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(2)').within(() => {
cy.contains('bobby.lapointe@test.com')
cy.get('.visually-hidden').contains('SSO not active')
})
cy.get('tr:nth-child(3)').within(() => {
cy.contains('claire.jennings@test.com')
cy.get('.visually-hidden').contains('SSO active')
})
})
})
})

View File

@@ -0,0 +1,802 @@
import type { PropsWithChildren } from 'react'
import sinon from 'sinon'
import DropdownButton from '@/features/group-management/components/members-table/dropdown-button'
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
import { User } from '../../../../../../types/group-management/user'
function Wrapper({ children }: PropsWithChildren<Record<string, unknown>>) {
return (
<table className="table">
<tbody>
<tr>
<td className="managed-users-actions" style={{ textAlign: 'right' }}>
<GroupMembersProvider>{children}</GroupMembersProvider>
</td>
</tr>
</tbody>
</table>
)
}
function mountDropDownComponent(user: User, subscriptionId: string) {
cy.mount(
<Wrapper>
<DropdownButton
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</Wrapper>
)
}
describe('DropdownButton', function () {
const subscriptionId = '123abc123abc'
describe('with a standard group', function () {
describe('for a pending user (has not joined group)', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date(),
enrollment: {},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-group-invite-action').should('be.visible')
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
describe('for the group admin', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {},
isEntityAdmin: true,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
})
describe('with Managed Users enabled', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-managedUsersActive', true)
win.metaAttributesCache.set('ol-groupSSOActive', false)
})
})
describe('for a pending user (has not joined group)', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date(),
enrollment: {},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-group-invite-action').should('be.visible')
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
describe('for a managed group member', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
managedBy: subscriptionId,
enrolledAt: new Date(),
},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render the dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('delete-user-action').should('be.visible')
cy.findByTestId('remove-user-action').should('not.exist')
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
describe('for a non-managed group member', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-managed-user-invite-action').should(
'be.visible'
)
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
describe('for a managed group admin user', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
managedBy: subscriptionId,
enrolledAt: new Date(),
},
isEntityAdmin: true,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render the button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the (empty) menu when the button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('no-actions-available').should('exist')
})
})
})
describe('with Group SSO enabled', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-managedUsersActive', false)
win.metaAttributesCache.set('ol-groupSSOActive', true)
})
})
describe('for a pending user (has not joined group)', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date(),
enrollment: {},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-group-invite-action').should('be.visible')
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
cy.findByTestId('unlink-user-action').should('not.exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
describe('for a group member not linked with SSO yet', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
})
it('should show resend invite when user is admin', function () {
mountDropDownComponent({ ...user, isEntityAdmin: true }, '123abc')
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-sso-link-invite-action').should('exist')
})
it('should not show resend invite when SSO is disabled', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupSSOActive', false)
})
mountDropDownComponent(user, '123abc')
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
})
it('should show the resend SSO invite option when dropdown button is clicked', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupSSOActive', true)
})
mountDropDownComponent(user, '123abc')
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-sso-link-invite-action').should('be.visible')
})
it('should make the correct post request when resend SSO invite is clicked ', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupSSOActive', true)
})
cy.intercept(
'POST',
'/manage/groups/123abc/resendSSOLinkInvite/some-user',
{ success: true }
).as('resendInviteRequest')
mountDropDownComponent(user, '123abc')
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-sso-link-invite-action')
.should('exist')
.as('resendInvite')
cy.get('@resendInvite').click()
cy.wait('@resendInviteRequest')
})
})
})
describe('with Managed Users and Group SSO enabled', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-managedUsersActive', true)
win.metaAttributesCache.set('ol-groupSSOActive', true)
})
})
describe('for a pending user (has not joined group)', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date(),
enrollment: {},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-group-invite-action').should('be.visible')
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
cy.findByTestId('unlink-user-action').should('not.exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
describe('for a non-managed group member with SSO linked', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
sso: [
{
groupId: subscriptionId,
linkedAt: new Date(),
primary: true,
},
],
},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-managed-user-invite-action').should(
'be.visible'
)
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('unlink-user-action').should('be.visible')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
})
})
describe('for a non-managed group member with SSO not linked', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
sso: [],
},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-managed-user-invite-action').should(
'be.visible'
)
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('resend-sso-link-invite-action').should('be.visible')
cy.findByTestId('no-actions-available').should('not.exist')
cy.findByTestId('unlink-user-action').should('not.exist')
})
})
describe('for a non-managed group admin with SSO linked', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
sso: [
{
groupId: subscriptionId,
linkedAt: new Date(),
primary: true,
},
],
},
isEntityAdmin: true,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-managed-user-invite-action').should(
'be.visible'
)
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('unlink-user-action').should('be.visible')
cy.findByTestId('delete-user-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
describe('for a non-managed group admin with SSO not linked', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
sso: [],
},
isEntityAdmin: true,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-managed-user-invite-action').should(
'be.visible'
)
cy.findByTestId('remove-user-action').should('be.visible')
cy.findByTestId('delete-user-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
describe('for a managed group member with SSO not linked', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
managedBy: subscriptionId,
enrolledAt: new Date(),
sso: [],
},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render the dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('delete-user-action').should('be.visible')
cy.findByTestId('remove-user-action').should('not.exist')
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
describe('for a managed group member with SSO linked', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
managedBy: subscriptionId,
enrolledAt: new Date(),
sso: [
{
groupId: subscriptionId,
linkedAt: new Date(),
primary: true,
},
],
},
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render the dropdown button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('delete-user-action').should('be.visible')
cy.findByTestId('remove-user-action').should('not.exist')
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
describe('for a managed group admin with SSO not linked', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
managedBy: subscriptionId,
enrolledAt: new Date(),
sso: [],
},
isEntityAdmin: true,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render the button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show the correct menu when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('resend-sso-link-invite-action').should('exist')
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
cy.findByTestId('remove-user-action').should('not.exist')
cy.findByTestId('delete-user-action').should('not.exist')
cy.findByTestId('no-actions-available').should('not.exist')
})
})
describe('for a managed group admin with SSO linked', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
managedBy: subscriptionId,
enrolledAt: new Date(),
sso: [
{
groupId: subscriptionId,
linkedAt: new Date(),
primary: true,
},
],
},
isEntityAdmin: true,
}
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
mountDropDownComponent(user, subscriptionId)
})
it('should render the button', function () {
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.findByRole('button', { name: /actions/i })
})
it('should show no actions except to unlink when dropdown button is clicked', function () {
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('unlink-user-action').should('exist')
cy.findByTestId('no-actions-available').should('not.exist')
cy.findByTestId('delete-user-action').should('not.exist')
cy.findByTestId('remove-user-action').should('not.exist')
cy.findByTestId('resend-managed-user-invite-action').should('not.exist')
cy.findByTestId('resend-sso-link-invite-action').should('not.exist')
})
})
})
})

View File

@@ -0,0 +1,86 @@
import ManagedUserStatus from '@/features/group-management/components/members-table/managed-user-status'
import { User } from '../../../../../../types/group-management/user'
describe('MemberStatus', function () {
describe('with a pending invite', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date(),
enrollment: undefined,
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.mount(<ManagedUserStatus user={user} />)
})
it('should render a pending state', function () {
cy.get('.security-state-invite-pending').contains('Managed')
})
})
describe('with a managed user', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: { managedBy: 'some-group', enrolledAt: new Date() },
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.mount(<ManagedUserStatus user={user} />)
})
it('should render a pending state', function () {
cy.get('.security-state-managed').contains('Managed')
})
})
describe('with an un-managed user', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: undefined,
isEntityAdmin: undefined,
}
beforeEach(function () {
cy.mount(<ManagedUserStatus user={user} />)
})
it('should render an un-managed state', function () {
cy.get('.security-state-not-managed').contains('Managed')
})
})
describe('with the group admin', function () {
const user: User = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: undefined,
isEntityAdmin: true,
}
beforeEach(function () {
cy.mount(<ManagedUserStatus user={user} />)
})
it('should render no state indicator', function () {
cy.get('.security-state-group-admin')
.contains('Managed')
.should('not.exist')
})
})
})

View File

@@ -0,0 +1,710 @@
import sinon from 'sinon'
import MemberRow from '@/features/group-management/components/members-table/member-row'
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
import { User } from '../../../../../../types/group-management/user'
describe('MemberRow', function () {
const subscriptionId = '123abc'
describe('default view', function () {
describe('with an ordinary user', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('renders the row', function () {
cy.get('tr')
// Checkbox
cy.findByTestId('select-single-checkbox').should('not.be.checked')
// Email
cy.get('tr').contains(user.email)
// Name
cy.get('tr').contains(user.first_name)
cy.get('tr').contains(user.last_name)
// Last active date
cy.get('tr').contains('21st Nov 2070')
// Dropdown button
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.get('tr').contains('SSO').should('not.exist')
cy.get('tr').contains('Managed').should('not.exist')
})
})
describe('with a pending invite', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should render a "Pending invite" badge', function () {
cy.findByTestId('badge-pending-invite').should(
'have.text',
'Pending invite'
)
})
})
describe('with a group admin', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: true,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should render a "Group admin" symbol', function () {
cy.findByTestId('group-admin-symbol').within(() => {
cy.findByText(/group admin/i)
})
})
})
describe('selecting and unselecting user row', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should select and unselect the user', function () {
cy.findByTestId('select-single-checkbox').should('not.be.checked')
cy.findByTestId('select-single-checkbox').click()
cy.findByTestId('select-single-checkbox').should('be.checked')
cy.findByTestId('select-single-checkbox').click()
cy.findByTestId('select-single-checkbox').should('not.be.checked')
})
})
})
describe('with Managed Users enabled', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-managedUsersActive', true)
})
})
describe('with an ordinary user', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('renders the row', function () {
cy.get('tr').should('exist')
// Checkbox
cy.findByTestId('select-single-checkbox').should('not.be.checked')
// Email
cy.get('tr').contains(user.email)
// Name
cy.get('tr').contains(user.first_name)
cy.get('tr').contains(user.last_name)
// Last active date
cy.get('tr').contains('21st Nov 2070')
// Managed status
cy.get('tr').contains('Managed')
// Dropdown button
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
})
})
describe('with a pending invite', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should render a "Pending invite" badge', function () {
cy.findByTestId('badge-pending-invite').should(
'have.text',
'Pending invite'
)
})
})
describe('with a group admin', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: true,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should render a "Group admin" symbol', function () {
cy.findByTestId('group-admin-symbol').within(() => {
cy.findByText(/group admin/i)
})
})
})
describe('selecting and unselecting user row', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should select and unselect the user', function () {
cy.findByTestId('select-single-checkbox').should('not.be.checked')
cy.findByTestId('select-single-checkbox').click()
cy.findByTestId('select-single-checkbox').should('be.checked')
cy.findByTestId('select-single-checkbox').click()
cy.findByTestId('select-single-checkbox').should('not.be.checked')
})
})
})
describe('with Group SSO enabled', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupSSOActive', true)
})
})
describe('with an ordinary user', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('renders the row', function () {
// Checkbox
cy.findByTestId('select-single-checkbox').should('not.be.checked')
// Email
cy.get('tr').contains(user.email)
// Name
cy.get('tr').contains(user.first_name)
cy.get('tr').contains(user.last_name)
// Last active date
cy.get('tr').contains('21st Nov 2070')
// SSO status
cy.get('tr').contains('SSO')
// Dropdown button
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
cy.get('tr').contains('Managed').should('not.exist')
})
})
describe('with a pending invite', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should render a "Pending invite" badge', function () {
cy.findByTestId('badge-pending-invite').should(
'have.text',
'Pending invite'
)
})
})
describe('with a group admin', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: true,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should render a "Group admin" symbol', function () {
cy.findByTestId('group-admin-symbol').within(() => {
cy.findByText(/group admin/i)
})
})
})
describe('selecting and unselecting user row', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should select and unselect the user', function () {
cy.findByTestId('select-single-checkbox').should('not.be.checked')
cy.findByTestId('select-single-checkbox').click()
cy.findByTestId('select-single-checkbox').should('be.checked')
cy.findByTestId('select-single-checkbox').click()
cy.findByTestId('select-single-checkbox').should('not.be.checked')
})
})
})
describe('with Managed Users and Group SSO enabled', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-managedUsersActive', true)
win.metaAttributesCache.set('ol-groupSSOActive', true)
})
})
describe('with an ordinary user', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('renders the row', function () {
// Checkbox
cy.findByTestId('select-single-checkbox').should('not.be.checked')
// Email
cy.get('tr').contains(user.email)
// Name
cy.get('tr').contains(user.first_name)
cy.get('tr').contains(user.last_name)
// Last active date
cy.get('tr').contains('21st Nov 2070')
// Managed status
cy.get('tr').contains('Managed')
// SSO status
cy.get('tr').contains('SSO')
// Dropdown button
cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
'exist'
)
})
})
describe('with a pending invite', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should render a "Pending invite" badge', function () {
cy.findByTestId('badge-pending-invite').should(
'have.text',
'Pending invite'
)
})
})
describe('with a group admin', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: true,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should render a "Group admin" symbol', function () {
cy.findByTestId('group-admin-symbol').within(() => {
cy.findByText(/group admin/i)
})
})
})
describe('selecting and unselecting user row', function () {
let user: User
beforeEach(function () {
user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [user])
})
cy.mount(
<GroupMembersProvider>
<MemberRow
user={user}
openOffboardingModalForUser={sinon.stub()}
openUnlinkUserModal={sinon.stub()}
groupId={subscriptionId}
setGroupUserAlert={sinon.stub()}
/>
</GroupMembersProvider>
)
})
it('should select and unselect the user', function () {
cy.findByTestId('select-single-checkbox').should('not.be.checked')
cy.findByTestId('select-single-checkbox').click()
cy.findByTestId('select-single-checkbox').should('be.checked')
cy.findByTestId('select-single-checkbox').click()
cy.findByTestId('select-single-checkbox').should('not.be.checked')
})
})
})
})

View File

@@ -0,0 +1,332 @@
import MembersList from '@/features/group-management/components/members-table/members-list'
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
import { User } from '../../../../../../types/group-management/user'
const groupId = 'somegroup'
function mountManagedUsersList() {
cy.mount(
<GroupMembersProvider>
<MembersList groupId={groupId} />
</GroupMembersProvider>
)
}
describe('MembersList', function () {
describe('with users', function () {
const users = [
{
_id: 'user-one',
email: 'sarah.brennan@example.com',
first_name: 'Sarah',
last_name: 'Brennan',
invite: false,
last_active_at: new Date('2070-10-22T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
},
{
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: false,
last_active_at: new Date('2070-11-21T03:00:00'),
enrollment: undefined,
isEntityAdmin: undefined,
},
]
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', users)
})
mountManagedUsersList()
})
it('should render the table headers but not SSO Column', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupSSOActive', false)
})
mountManagedUsersList()
// Select-all checkbox
cy.findByTestId('managed-entities-table').within(() => {
cy.findByTestId('select-all-checkbox')
})
cy.findByTestId('managed-entities-table').should('contain.text', 'Email')
cy.findByTestId('managed-entities-table').should('contain.text', 'Name')
cy.findByTestId('managed-entities-table').should(
'contain.text',
'Last Active'
)
cy.findByTestId('managed-entities-table').should(
'not.contain.text',
'Security'
)
})
it('should render the table headers with SSO Column', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupSSOActive', true)
})
mountManagedUsersList()
// Select-all checkbox
cy.findByTestId('managed-entities-table').within(() => {
cy.findByTestId('select-all-checkbox')
})
cy.findByTestId('managed-entities-table').should('contain.text', 'Email')
cy.findByTestId('managed-entities-table').should('contain.text', 'Name')
cy.findByTestId('managed-entities-table').should(
'contain.text',
'Last Active'
)
cy.findByTestId('managed-entities-table').should(
'contain.text',
'Security'
)
})
it('should render the list of users', function () {
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.findAllByRole('row').should('have.length', 2)
})
// First user
cy.findByTestId('managed-entities-table').should(
'contain.text',
users[0].email
)
cy.findByTestId('managed-entities-table').should(
'contain.text',
users[0].first_name
)
cy.findByTestId('managed-entities-table').should(
'contain.text',
users[0].last_name
)
// Second user
cy.findByTestId('managed-entities-table').should(
'contain.text',
users[1].email
)
cy.findByTestId('managed-entities-table').should(
'contain.text',
users[1].first_name
)
cy.findByTestId('managed-entities-table').should(
'contain.text',
users[1].last_name
)
})
})
describe('empty user list', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [])
})
cy.mount(
<GroupMembersProvider>
<MembersList groupId={groupId} />
</GroupMembersProvider>
)
})
it('should render the list, with a "no members" message', function () {
cy.findByTestId('managed-entities-table').should(
'contain.text',
'No members'
)
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.findAllByRole('row')
.should('have.length', 1)
.and('contain.text', 'No members')
})
})
})
describe('SSO unlinking', function () {
const USER_PENDING_INVITE: User = {
_id: 'abc123def456',
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@test.com',
last_active_at: new Date('2023-01-15'),
invite: true,
}
const USER_NOT_LINKED: User = {
_id: 'bcd234efa567',
first_name: 'Bobby',
last_name: 'Lapointe',
email: 'bobby.lapointe@test.com',
last_active_at: new Date('2023-01-02'),
invite: false,
}
const USER_LINKED: User = {
_id: 'defabc231453',
first_name: 'Claire',
last_name: 'Jennings',
email: 'claire.jennings@test.com',
last_active_at: new Date('2023-01-03'),
invite: false,
enrollment: {
sso: [
{
groupId,
linkedAt: new Date('2023-01-03'),
primary: true,
},
],
},
}
const USER_LINKED_AND_MANAGED: User = {
_id: 'defabc231453',
first_name: 'Jean-Luc',
last_name: 'Picard',
email: 'picard@test.com',
last_active_at: new Date('2023-01-03'),
invite: false,
enrollment: {
managedBy: groupId,
enrolledAt: new Date('2023-01-03'),
sso: [
{
groupId,
linkedAt: new Date('2023-01-03'),
primary: true,
},
],
},
}
const users = [
USER_PENDING_INVITE,
USER_NOT_LINKED,
USER_LINKED,
USER_LINKED_AND_MANAGED,
]
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupId', groupId)
win.metaAttributesCache.set('ol-users', users)
win.metaAttributesCache.set('ol-groupSSOActive', true)
})
cy.intercept('POST', `manage/groups/${groupId}/unlink-user/*`, {
statusCode: 200,
})
})
describe('unlinking user', function () {
beforeEach(function () {
mountManagedUsersList()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(3)').within(() => {
cy.findByText('SSO active')
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('unlink-user-action').click()
})
})
})
it('should show successs notification and update the user row after unlinking', function () {
cy.findByRole('dialog').within(() => {
cy.findByRole('button', { name: /unlink user/i }).click()
})
cy.findByRole('alert').should(
'contain.text',
`SSO reauthentication request has been sent to ${USER_LINKED.email}`
)
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(3)').within(() => {
cy.findByText('SSO not active')
})
})
})
})
describe('managed users enabled', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-managedUsersActive', true)
})
mountManagedUsersList()
})
describe('when user is not managed', function () {
beforeEach(function () {
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(3)').within(() => {
cy.findByText('SSO active')
cy.findByText('Not managed')
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('unlink-user-action').click()
})
})
})
it('should show successs notification and update the user row after unlinking', function () {
cy.findByRole('dialog').within(() => {
cy.findByRole('button', { name: /unlink user/i }).click()
})
cy.findByRole('alert').should(
'contain.text',
`SSO reauthentication request has been sent to ${USER_LINKED.email}`
)
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(3)').within(() => {
cy.findByText('SSO not active')
cy.findByText('Not managed')
})
})
})
})
describe('when user is managed', function () {
beforeEach(function () {
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(4)').within(() => {
cy.findByText('SSO active')
cy.findAllByText('Managed')
cy.findByRole('button', { name: /actions/i }).click()
cy.findByTestId('unlink-user-action').click()
})
})
})
it('should show successs notification and update the user row after unlinking', function () {
cy.findByRole('dialog').within(() => {
cy.findByRole('button', { name: /unlink user/i }).click()
})
cy.findByRole('alert').should(
'contain.text',
`SSO reauthentication request has been sent to ${USER_LINKED_AND_MANAGED.email}`
)
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(4)').within(() => {
cy.findByText('SSO not active')
cy.findAllByText('Managed')
})
})
})
})
})
})
})

View File

@@ -0,0 +1,107 @@
import OffboardManagedUserModal from '@/features/group-management/components/members-table/offboard-managed-user-modal'
import sinon from 'sinon'
describe('OffboardManagedUserModal', function () {
describe('happy path', function () {
const groupId = 'some-group'
const user = {
_id: 'some-user',
email: 'some.user@example.com',
first_name: 'Some',
last_name: 'User',
invite: true,
last_active_at: new Date(),
enrollment: {
managedBy: `${groupId}`,
enrolledAt: new Date(),
},
isEntityAdmin: undefined,
}
const otherUser = {
_id: 'other-user',
email: 'other.user@example.com',
first_name: 'Other',
last_name: 'User',
invite: false,
last_active_at: new Date(),
enrollment: {
managedBy: `${groupId}`,
enrolledAt: new Date(),
},
isEntityAdmin: undefined,
}
const allMembers = [user, otherUser]
beforeEach(function () {
cy.mount(
<OffboardManagedUserModal
user={user}
allMembers={allMembers}
groupId={groupId}
onClose={sinon.stub()}
/>
)
})
it('should render the modal', function () {
cy.get('#delete-user-form').should('exist')
})
it('should disable the button if a recipient is not selected', function () {
// Button should be disabled initially
cy.get('button[type="submit"]').should('be.disabled')
// Not selecting a recipient...
// Fill in the email input
cy.get('#supplied-email-input').type(user.email)
// Button still disabled
cy.get('button[type="submit"]').should('be.disabled')
})
it('should disable the button if the email is not filled in', function () {
// Button should be disabled initially
cy.get('button[type="submit"]').should('be.disabled')
// Select a recipient
cy.get('#recipient-select-input').select('other.user@example.com')
// Not filling in the email...
// Button still disabled
cy.get('button[type="submit"]').should('be.disabled')
})
it('should disable the button if the email does not match the user', function () {
// Button should be disabled initially
cy.get('button[type="submit"]').should('be.disabled')
// Select a recipient
cy.get('#recipient-select-input').select('other.user@example.com')
// Fill in the email input, with the wrong email address
cy.get('#supplied-email-input').type('totally.wrong@example.com')
// Button still disabled
cy.get('button[type="submit"]').should('be.disabled')
})
it('should fill out the form, and enable the delete button', function () {
// Button should be disabled initially
cy.get('button[type="submit"]').should('be.disabled')
// Select a recipient
cy.get('#recipient-select-input').select('other.user@example.com')
// Button still disabled
cy.get('button[type="submit"]').should('be.disabled')
// Fill in the email input
cy.get('#supplied-email-input').type(user.email)
// Button should be enabled now
cy.get('button[type="submit"]').should('not.be.disabled')
})
})
})

View File

@@ -0,0 +1,74 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ReactElement } from 'react'
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
import UnlinkUserModal from '@/features/group-management/components/members-table/unlink-user-modal'
import { GroupMembersProvider } from '@/features/group-management/context/group-members-context'
import { expect } from 'chai'
export function renderWithContext(component: ReactElement, props = {}) {
const GroupMembersProviderWrapper = ({
children,
}: {
children: ReactElement
}) => <GroupMembersProvider {...props}>{children}</GroupMembersProvider>
return render(component, { wrapper: GroupMembersProviderWrapper })
}
describe('<UnlinkUserModal />', function () {
let defaultProps: any
const groupId = 'group123'
const userId = 'user123'
beforeEach(function () {
defaultProps = {
onClose: sinon.stub(),
user: { _id: userId },
setGroupUserAlert: sinon.stub(),
}
window.metaAttributesCache.set('ol-groupId', groupId)
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('displays the modal', async function () {
renderWithContext(<UnlinkUserModal {...defaultProps} />)
await screen.findByRole('heading', {
name: 'Unlink user',
})
screen.getByText('Youre about to remove the SSO login option for', {
exact: false,
})
})
it('closes the modal on success', async function () {
fetchMock.post(`/manage/groups/${groupId}/unlink-user/${userId}`, 200)
renderWithContext(<UnlinkUserModal {...defaultProps} />)
await screen.findByRole('heading', {
name: 'Unlink user',
})
const confirmButton = screen.getByRole('button', { name: 'Unlink user' })
fireEvent.click(confirmButton)
await waitFor(() => expect(defaultProps.onClose).to.have.been.called)
})
it('handles errors', async function () {
fetchMock.post(`/manage/groups/${groupId}/unlink-user/${userId}`, 500)
renderWithContext(<UnlinkUserModal {...defaultProps} />)
await screen.findByRole('heading', {
name: 'Unlink user',
})
const confirmButton = screen.getByRole('button', { name: 'Unlink user' })
fireEvent.click(confirmButton)
await waitFor(() => screen.findByText('Sorry, something went wrong'))
})
})

View File

@@ -0,0 +1,38 @@
import { SplitTestProvider } from '@/shared/context/split-test-context'
import MissingBillingInformation from '@/features/group-management/components/missing-billing-information'
describe('<MissingBillingInformation />', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
})
cy.mount(
<SplitTestProvider>
<MissingBillingInformation />
</SplitTestProvider>
)
})
it('shows missing payment details notification', function () {
cy.findByRole('alert').within(() => {
cy.findByText(/missing payment details/i)
cy.findByText(
/it looks like your payment details are missing\. Please.*, or.*with our Support team for more help/i
).within(() => {
cy.findByRole('link', {
name: /update your billing information/i,
}).should(
'have.attr',
'href',
'/user/subscription/recurly/billing-details'
)
cy.findByRole('link', { name: /get in touch/i }).should(
'have.attr',
'href',
'/contact'
)
})
})
})
})

View File

@@ -0,0 +1,171 @@
import PublisherManagers from '@/features/group-management/components/publisher-managers'
const JOHN_DOE = {
_id: 'abc123def456',
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@test.com',
last_active_at: new Date('2023-01-15'),
invite: true,
}
const BOBBY_LAPOINTE = {
_id: 'bcd234efa567',
first_name: 'Bobby',
last_name: 'Lapointe',
email: 'bobby.lapointe@test.com',
last_active_at: new Date('2023-01-02'),
invite: false,
}
const GROUP_ID = '000fff000fff'
const PATHS = {
addMember: `/manage/publishers/${GROUP_ID}/managers`,
removeMember: `/manage/publishers/${GROUP_ID}/managers`,
}
describe('publisher managers', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
win.metaAttributesCache.set('ol-groupId', GROUP_ID)
win.metaAttributesCache.set('ol-groupName', 'My Awesome Publisher')
})
cy.mount(<PublisherManagers />)
})
it('renders the publisher management page', function () {
cy.findByRole('heading', { name: /my awesome publisher/i, level: 1 })
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByText('john.doe@test.com')
cy.findByText('John Doe')
cy.findByText('15th Jan 2023')
cy.findByText('Invite not yet accepted')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByText('bobby.lapointe@test.com')
cy.findByText('Bobby Lapointe')
cy.findByText('2nd Jan 2023')
cy.findByText('Accepted invite')
})
})
})
it('sends an invite', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 201,
body: {
user: {
email: 'someone.else@test.com',
invite: true,
},
},
})
cy.findByTestId('add-members-form').within(() => {
cy.findByRole('textbox').type('someone.else@test.com')
cy.findByRole('button').click()
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(3)').within(() => {
cy.findByText('someone.else@test.com')
cy.findByText('N/A')
cy.findByText('Invite not yet accepted')
})
})
})
it('tries to send an invite and displays the error', function () {
cy.intercept('POST', PATHS.addMember, {
statusCode: 500,
body: {
error: {
message: 'User already added',
},
},
})
cy.findByTestId('add-members-form').within(() => {
cy.findByRole('textbox').type('someone.else@test.com')
cy.findByRole('button').click()
})
cy.findByRole('alert').should('contain.text', 'Error: User already added')
})
it('checks the select all checkbox', function () {
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).should('not.be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByLabelText(/select user/i).should('not.be.checked')
})
})
cy.findByTestId('select-all-checkbox').click()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).should('be.checked')
})
cy.get('tr:nth-child(2)').within(() => {
cy.findByLabelText(/select user/i).should('be.checked')
})
})
})
it('remove a member', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 200,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).check()
})
})
cy.findByRole('button', { name: 'Remove manager' }).click()
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByText('bobby.lapointe@test.com')
cy.findByText('Bobby Lapointe')
cy.findByText('2nd Jan 2023')
cy.findByText('Accepted invite')
})
})
})
it('tries to remove a manager and displays the error', function () {
cy.intercept('DELETE', `${PATHS.removeMember}/abc123def456`, {
statusCode: 500,
})
cy.findByTestId('managed-entities-table')
.find('tbody')
.within(() => {
cy.get('tr:nth-child(1)').within(() => {
cy.findByLabelText(/select user/i).check()
})
})
cy.findByRole('button', { name: /remove manager/i }).click()
cy.findByRole('alert').should('contain.text', 'Sorry, something went wrong')
})
})

View File

@@ -0,0 +1,44 @@
import RequestStatus from '@/features/group-management/components/request-status'
describe('<RequestStatus />', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
})
cy.mount(
<RequestStatus icon="email" title="Test title" content="Test content" />
)
})
it('renders the back button', function () {
cy.findByTestId('group-heading').within(() => {
cy.findByRole('button', { name: /back to subscription/i }).should(
'have.attr',
'href',
'/user/subscription'
)
})
})
it('shows the group name', function () {
cy.findByTestId('group-heading').within(() => {
cy.findByRole('heading', { name: 'My Awesome Team' })
})
})
it('shows the title', function () {
cy.findByTestId('title').should('contain.text', 'Test title')
})
it('shows the content', function () {
cy.findByText('Test content')
})
it('renders the link to subscriptions', function () {
cy.findByRole('button', { name: /go to subscriptions/i }).should(
'have.attr',
'href',
'/user/subscription'
)
})
})

View File

@@ -0,0 +1,23 @@
import SubtotalLimitExceeded from '@/features/group-management/components/subtotal-limit-exceeded'
describe('<SubtotalLimitExceeded />', function () {
beforeEach(function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
})
cy.mount(<SubtotalLimitExceeded />)
})
it('shows subtotal limit exceeded notification', function () {
cy.findByRole('alert').within(() => {
cy.findByText(
/sorry, there was an issue upgrading your subscription\. Please.*for help/i
).within(() => {
cy.findByRole('link', {
name: /contact our support team/i,
}).should('have.attr', 'href', '/contact')
})
})
})
})

View File

@@ -0,0 +1,150 @@
import UpgradeSubscription from '@/features/group-management/components/upgrade-subscription/upgrade-subscription'
import { SubscriptionChangePreview } from '../../../../../types/subscription/subscription-change-preview'
describe('<UpgradeSubscription />', function () {
const resetPreviewAndRemount = (preview: SubscriptionChangePreview) => {
cy.window().then(win => {
win.metaAttributesCache.set('ol-subscriptionChangePreview', preview)
})
cy.mount(<UpgradeSubscription />)
}
beforeEach(function () {
this.totalLicenses = 2
this.preview = {
change: {
type: 'group-plan-upgrade',
prevPlan: { name: 'Overleaf Standard Group' },
},
currency: 'USD',
immediateCharge: {
subtotal: 353.99,
tax: 70.8,
total: 424.79,
discount: 0,
},
paymentMethod: 'Visa **** 1111',
nextPlan: { annual: true },
nextInvoice: {
date: '2025-11-05T11:35:32.000Z',
plan: { name: 'Overleaf Professional Group', amount: 0 },
addOns: [
{
code: 'additional-license',
name: 'Seat',
quantity: 2,
unitAmount: 399,
amount: 798,
},
],
subtotal: 798,
tax: { rate: 0.2, amount: 159.6 },
total: 957.6,
},
}
cy.window().then(win => {
win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
win.metaAttributesCache.set('ol-totalLicenses', this.totalLicenses)
})
resetPreviewAndRemount(this.preview)
})
it('shows the group name', function () {
cy.findByTestId('group-heading').within(() => {
cy.findByRole('heading', { name: 'My Awesome Team' })
})
})
it('shows the "Add more licenses to my plan" label', function () {
cy.findByText(/add more licenses to my plan/i).should(
'have.attr',
'href',
'/user/subscription/group/add-users'
)
})
it('shows the "Upgrade" and "Cancel" buttons', function () {
cy.findByRole('button', { name: /upgrade/i })
cy.findByRole('button', { name: /cancel/i }).should(
'have.attr',
'href',
'/user/subscription'
)
})
describe('shows plan details', function () {
it('shows per user price', function () {
cy.findByTestId('per-user-price').within(() => {
cy.findByText('$399')
})
})
it('shows additional features', function () {
cy.findByText(/unlimited collaborators per project/i)
cy.findByText(/sso/i)
cy.findByText(/managed user accounts/i)
})
})
describe('shows upgrade summary', function () {
it('shows subtotal, tax and total price', function () {
cy.findByTestId('subtotal').within(() => {
cy.findByText('$353.99')
})
cy.findByTestId('tax').within(() => {
cy.findByText('$70.80')
})
cy.findByTestId('total').within(() => {
cy.findByText('$424.79')
})
cy.findByTestId('discount').should('not.exist')
})
it('shows subtotal, discount, tax and total price', function () {
resetPreviewAndRemount({
...this.preview,
immediateCharge: {
subtotal: 353.99,
tax: 70.8,
total: 424.79,
discount: 50,
},
})
cy.findByTestId('subtotal').within(() => {
cy.findByText('$353.99')
})
cy.findByTestId('tax').within(() => {
cy.findByText('$70.80')
})
cy.findByTestId('total').within(() => {
cy.findByText('$424.79')
})
cy.findByTestId('discount').within(() => {
cy.findByText('($50.00)')
})
})
it('shows total users', function () {
cy.findByText(/you have 2 licenses on your subscription./i)
})
})
describe('submit upgrade request', function () {
it('request succeeded', function () {
cy.intercept('POST', '/user/subscription/group/upgrade-subscription', {
statusCode: 200,
}).as('upgradeRequest')
cy.findByRole('button', { name: /upgrade/i }).click()
cy.findByText(/youve upgraded your plan!/i)
})
it('request failed', function () {
cy.intercept('POST', '/user/subscription/group/upgrade-subscription', {
statusCode: 400,
}).as('upgradeRequest')
cy.findByRole('button', { name: /upgrade/i }).click()
cy.findByText(/something went wrong/i)
})
})
})

View File

@@ -0,0 +1,680 @@
import { useState } from 'react'
import ToggleSwitch from '../../../../../frontend/js/features/history/components/change-list/toggle-switch'
import ChangeList from '../../../../../frontend/js/features/history/components/change-list/change-list'
import {
EditorProviders,
USER_EMAIL,
USER_ID,
} from '../../../helpers/editor-providers'
import { HistoryProvider } from '../../../../../frontend/js/features/history/context/history-context'
import { updates } from '../fixtures/updates'
import { labels } from '../fixtures/labels'
import { formatTime, relativeDate } from '@/features/utils/format-date'
const mountWithEditorProviders = (
component: React.ReactNode,
scope: Record<string, unknown> = {},
props: Record<string, unknown> = {}
) => {
cy.mount(
<EditorProviders scope={scope} {...props}>
<HistoryProvider>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<div className="history-react">{component}</div>
</div>
</HistoryProvider>
</EditorProviders>
)
}
describe('change list (Bootstrap 5)', function () {
const scope = {
ui: { view: 'history', pdfLayout: 'sideBySide', chatOpen: true },
}
const waitForData = () => {
cy.wait('@updates')
cy.wait('@labels')
cy.wait('@diff')
}
beforeEach(function () {
cy.intercept('GET', '/project/*/updates*', {
body: updates,
}).as('updates')
cy.intercept('GET', '/project/*/labels', {
body: labels,
}).as('labels')
cy.intercept('GET', '/project/*/filetree/diff*', {
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
}).as('diff')
window.metaAttributesCache.set('ol-inactiveTutorials', [
'react-history-buttons-tutorial',
])
})
describe('toggle switch', function () {
it('renders switch buttons', function () {
mountWithEditorProviders(
<ToggleSwitch labelsOnly={false} setLabelsOnly={() => {}} />
)
cy.findByLabelText(/all history/i)
cy.findByLabelText(/labels/i)
})
it('toggles "all history" and "labels" buttons', function () {
function ToggleSwitchWrapped({ labelsOnly }: { labelsOnly: boolean }) {
const [labelsOnlyLocal, setLabelsOnlyLocal] = useState(labelsOnly)
return (
<ToggleSwitch
labelsOnly={labelsOnlyLocal}
setLabelsOnly={setLabelsOnlyLocal}
/>
)
}
mountWithEditorProviders(<ToggleSwitchWrapped labelsOnly={false} />)
cy.findByLabelText(/all history/i).as('all-history')
cy.findByLabelText(/labels/i).as('labels')
cy.get('@all-history').should('be.checked')
cy.get('@labels').should('not.be.checked')
cy.get('@labels').click({ force: true })
cy.get('@all-history').should('not.be.checked')
cy.get('@labels').should('be.checked')
})
})
describe('tags', function () {
it('renders tags', function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
cy.findByLabelText(/all history/i).click({ force: true })
cy.findAllByTestId('history-version-details').as('details')
cy.get('@details').should('have.length', 5)
// start with 2nd details entry, as first has no tags
cy.get('@details')
.eq(1)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 2)
cy.get('@tags').eq(0).should('contain.text', 'tag-2')
cy.get('@tags').eq(1).should('contain.text', 'tag-1')
// should have delete buttons
cy.get('@tags').each(tag =>
cy.wrap(tag).within(() => {
cy.findByRole('button', { name: /delete/i })
})
)
// 3rd details entry
cy.get('@details')
.eq(2)
.within(() => {
cy.findAllByTestId('history-version-badge').should('have.length', 0)
})
// 4th details entry
cy.get('@details')
.eq(3)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 2)
cy.get('@tags').eq(0).should('contain.text', 'tag-4')
cy.get('@tags').eq(1).should('contain.text', 'tag-3')
// should not have delete buttons
cy.get('@tags').each(tag =>
cy.wrap(tag).within(() => {
cy.findByRole('button', { name: /delete/i }).should('not.exist')
})
)
cy.findByLabelText(/labels/i).click({ force: true })
cy.findAllByTestId('history-version-details').as('details')
// first details on labels is always "current version", start testing on second
cy.get('@details').should('have.length', 3)
cy.get('@details')
.eq(1)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 2)
cy.get('@tags').eq(0).should('contain.text', 'tag-2')
cy.get('@tags').eq(1).should('contain.text', 'tag-1')
cy.get('@details')
.eq(2)
.within(() => {
cy.findAllByTestId('history-version-badge').as('tags')
})
cy.get('@tags').should('have.length', 3)
cy.get('@tags').eq(0).should('contain.text', 'tag-5')
cy.get('@tags').eq(1).should('contain.text', 'tag-4')
cy.get('@tags').eq(2).should('contain.text', 'tag-3')
})
it('deletes tag', function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
cy.findByLabelText(/all history/i).click({ force: true })
const labelToDelete = 'tag-2'
cy.findAllByTestId('history-version-details').eq(1).as('details')
cy.get('@details').within(() => {
cy.findAllByTestId('history-version-badge').eq(0).as('tag')
})
cy.get('@tag').should('contain.text', labelToDelete)
cy.get('@tag').within(() => {
cy.findByRole('button', { name: /delete/i }).as('delete-btn')
})
cy.get('@delete-btn').click()
cy.findByRole('dialog').as('modal')
cy.get('@modal').within(() => {
cy.findByRole('heading', { name: /delete label/i })
})
cy.get('@modal').contains(
new RegExp(
`are you sure you want to delete the following label "${labelToDelete}"?`,
'i'
)
)
cy.get('@modal').within(() => {
cy.findByRole('button', { name: /cancel/i }).click()
})
cy.findByRole('dialog').should('not.exist')
cy.get('@delete-btn').click()
cy.findByRole('dialog').as('modal')
cy.intercept('DELETE', '/project/*/labels/*', {
statusCode: 500,
}).as('delete')
cy.get('@modal').within(() => {
cy.findByRole('button', { name: /delete/i }).click()
})
cy.wait('@delete')
cy.get('@modal').within(() => {
cy.findByRole('alert').within(() => {
cy.contains(/sorry, something went wrong/i)
})
})
cy.findByText(labelToDelete).should('have.length', 1)
cy.intercept('DELETE', '/project/*/labels/*', {
statusCode: 204,
}).as('delete')
cy.get('@modal').within(() => {
cy.findByRole('button', { name: /delete/i }).click()
})
cy.wait('@delete')
cy.findByText(labelToDelete).should('not.exist')
})
it('verifies that selecting the same list item will not trigger a new diff', function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
const stub = cy.stub().as('diffStub')
cy.intercept('GET', '/project/*/filetree/diff*', stub).as('diff')
cy.findAllByTestId('history-version-details').eq(2).as('details')
cy.get('@details').click() // 1st click
cy.wait('@diff')
cy.get('@details').click() // 2nd click
cy.get('@diffStub').should('have.been.calledOnce')
})
})
describe('all history', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
})
it('shows grouped versions date', function () {
cy.findByText(relativeDate(updates.updates[0].meta.end_ts))
cy.findByText(relativeDate(updates.updates[1].meta.end_ts))
})
it('shows the date of the version', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByTestId('history-version-metadata-time').should(
'have.text',
formatTime(updates.updates[0].meta.end_ts, 'Do MMMM, h:mm a')
)
})
})
it('shows change action', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByTestId('history-version-change-action').should(
'have.text',
'Created'
)
})
})
it('shows changed document name', function () {
cy.findAllByTestId('history-version-details')
.eq(2)
.within(() => {
cy.findByTestId('history-version-change-doc').should(
'have.text',
updates.updates[2].pathnames[0]
)
})
})
it('shows users', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByTestId('history-version-metadata-users')
.should('contain.text', 'You')
.and('contain.text', updates.updates[1].meta.users[1].first_name)
})
})
})
describe('labels only', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
cy.findByLabelText(/labels/i).click({ force: true })
})
it('shows the dropdown menu item for adding new labels', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByRole('button', { name: /more actions/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /label this version/i,
}).should('exist')
})
})
})
it('resets from compare to view mode when switching tabs', function () {
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.findByRole('button', {
name: /Compare/i,
}).click()
})
cy.findByLabelText(/all history/i).click({ force: true })
cy.findAllByTestId('history-version-details').should($versions => {
const [selected, ...rest] = Array.from($versions)
expect(selected).to.have.attr('data-selected', 'selected')
expect(
rest.every(version => version.dataset.selected === 'belowSelected')
).to.be.true
})
})
it('opens the compare drop down and compares with selected version', function () {
cy.findByLabelText(/all history/i).click({ force: true })
cy.findAllByTestId('history-version-details')
.eq(3)
.within(() => {
cy.findByRole('button', {
name: /compare from this version/i,
}).click()
})
cy.findAllByTestId('history-version-details')
.eq(1)
.within(() => {
cy.get('[aria-label="Compare"]').click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /compare up to this version/i,
}).click()
})
})
cy.findAllByTestId('history-version-details').should($versions => {
const [
aboveSelected,
upperSelected,
withinSelected,
lowerSelected,
belowSelected,
] = Array.from($versions)
expect(aboveSelected).to.have.attr('data-selected', 'aboveSelected')
expect(upperSelected).to.have.attr('data-selected', 'upperSelected')
expect(withinSelected).to.have.attr('data-selected', 'withinSelected')
expect(lowerSelected).to.have.attr('data-selected', 'lowerSelected')
expect(belowSelected).to.have.attr('data-selected', 'belowSelected')
})
})
})
describe('compare mode', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
})
it('compares versions', function () {
cy.findAllByTestId('history-version-details').should($versions => {
const [first, ...rest] = Array.from($versions)
expect(first).to.have.attr('data-selected', 'selected')
rest.forEach(version =>
// Based on the fact that we are selecting first version as we load the page
// Every other version will be belowSelected
expect(version).to.have.attr('data-selected', 'belowSelected')
)
})
cy.intercept('GET', '/project/*/filetree/diff*', {
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
}).as('compareDiff')
cy.findAllByTestId('history-version-details')
.last()
.within(() => {
cy.findByTestId('compare-icon-version').click()
})
cy.wait('@compareDiff')
})
})
describe('dropdown', function () {
beforeEach(function () {
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: true,
},
})
waitForData()
})
it('adds badge/label', function () {
cy.findAllByTestId('history-version-details').eq(1).as('version')
cy.get('@version').within(() => {
cy.findByRole('button', { name: /more actions/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /label this version/i,
}).click()
})
})
cy.intercept('POST', '/project/*/labels', req => {
req.reply(200, {
id: '64633ee158e9ef7da614c000',
comment: req.body.comment,
version: req.body.version,
user_id: USER_ID,
created_at: '2023-05-16T08:29:21.250Z',
user_display_name: 'john.doe',
})
}).as('addLabel')
const newLabel = 'my new label'
cy.findByRole('dialog').within(() => {
cy.findByRole('heading', { name: /add label/i })
cy.findByRole('button', { name: /cancel/i })
cy.findByRole('button', { name: /add label/i }).should('be.disabled')
cy.findByPlaceholderText(/new label name/i).as('input')
cy.get('@input').type(newLabel)
cy.findByRole('button', { name: /add label/i }).should('be.enabled')
cy.get('@input').type('{enter}')
})
cy.wait('@addLabel')
cy.get('@version').within(() => {
cy.findAllByTestId('history-version-badge').should($badges => {
const includes = Array.from($badges).some(badge =>
badge.textContent?.includes(newLabel)
)
expect(includes).to.be.true
})
})
})
it('downloads version', function () {
cy.intercept('GET', '/project/*/version/*/zip', { statusCode: 200 }).as(
'download'
)
cy.findAllByTestId('history-version-details')
.eq(0)
.within(() => {
cy.findByRole('button', { name: /more actions/i }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', {
name: /download this version/i,
}).click()
})
})
cy.wait('@download')
})
})
describe('paywall', function () {
const now = Date.now()
const oneMinuteAgo = now - 60 * 1000
const justOverADayAgo = now - 25 * 60 * 60 * 1000
const twoDaysAgo = now - 48 * 60 * 60 * 1000
const updates = {
updates: [
{
fromV: 3,
toV: 4,
meta: {
users: [
{
first_name: 'john.doe',
last_name: '',
email: 'john.doe@test.com',
id: '1',
},
],
start_ts: oneMinuteAgo,
end_ts: oneMinuteAgo,
},
labels: [],
pathnames: [],
project_ops: [{ add: { pathname: 'name.tex' }, atV: 3 }],
},
{
fromV: 1,
toV: 3,
meta: {
users: [
{
first_name: 'bobby.lapointe',
last_name: '',
email: 'bobby.lapointe@test.com',
id: '2',
},
],
start_ts: justOverADayAgo,
end_ts: justOverADayAgo - 10 * 1000,
},
labels: [],
pathnames: ['main.tex'],
project_ops: [],
},
{
fromV: 0,
toV: 1,
meta: {
users: [
{
first_name: 'john.doe',
last_name: '',
email: 'john.doe@test.com',
id: '1',
},
],
start_ts: twoDaysAgo,
end_ts: twoDaysAgo,
},
labels: [
{
id: 'label1',
comment: 'tag-1',
version: 0,
user_id: USER_ID,
created_at: justOverADayAgo,
},
],
pathnames: [],
project_ops: [{ add: { pathname: 'main.tex' }, atV: 0 }],
},
],
}
const labels = [
{
id: 'label1',
comment: 'tag-1',
version: 0,
user_id: USER_ID,
created_at: justOverADayAgo,
user_display_name: 'john.doe',
},
]
const waitForData = () => {
cy.wait('@updates')
cy.wait('@labels')
cy.wait('@diff')
}
beforeEach(function () {
cy.intercept('GET', '/project/*/updates*', {
body: updates,
}).as('updates')
cy.intercept('GET', '/project/*/labels', {
body: labels,
}).as('labels')
cy.intercept('GET', '/project/*/filetree/diff*', {
body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
}).as('diff')
})
it('shows non-owner paywall', function () {
const scope = {
ui: {
view: 'history',
pdfLayout: 'sideBySide',
chatOpen: true,
},
}
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: false,
},
})
waitForData()
cy.get('.history-paywall-prompt').should('have.length', 1)
cy.findAllByTestId('history-version').should('have.length', 2)
cy.get('.history-paywall-prompt button').should('not.exist')
})
it('shows owner paywall', function () {
const scope = {
ui: {
view: 'history',
pdfLayout: 'sideBySide',
chatOpen: true,
},
}
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: false,
},
projectOwner: {
_id: USER_ID,
email: USER_EMAIL,
},
})
waitForData()
cy.get('.history-paywall-prompt').should('have.length', 1)
cy.findAllByTestId('history-version').should('have.length', 2)
cy.get('.history-paywall-prompt button').should('have.length', 1)
})
it('shows all labels in free tier', function () {
const scope = {
ui: {
view: 'history',
pdfLayout: 'sideBySide',
chatOpen: true,
},
}
mountWithEditorProviders(<ChangeList />, scope, {
user: {
id: USER_ID,
email: USER_EMAIL,
isAdmin: false,
},
projectOwner: {
_id: USER_ID,
email: USER_EMAIL,
},
})
waitForData()
cy.findByLabelText(/labels/i).click({ force: true })
// One pseudo-label for the current state, one for our label
cy.get('.history-version-label').should('have.length', 2)
})
})
})

View File

@@ -0,0 +1,256 @@
import DocumentDiffViewer from '../../../../../frontend/js/features/history/components/diff-view/document-diff-viewer'
import { Highlight } from '../../../../../frontend/js/features/history/services/types/doc'
import { FC } from 'react'
import { EditorProviders } from '../../../helpers/editor-providers'
const doc = `\\documentclass{article}
% Language setting
% Replace \`english' with e.g. \`spanish' to change the document language
\\usepackage[english]{babel}
% Set page size and margins
% Replace \`letterpaper' with \`a4paper' for UK/EU standard size
\\usepackage[letterpaper,top=2cm,bottom=2cm,left=3cm,right=3cm,marginparwidth=1.75cm]{geometry}
% Useful packages
\\usepackage{amsmath}
\\usepackage{graphicx}
\\usepackage[colorlinks=true, allcolors=blue]{hyperref}
\\title{Your Paper}
\\author{You}
\\begin{document}
\\maketitle
\\begin{abstract}
Your abstract.
\\end{abstract}
\\section{Introduction}
Your introduction goes here! Simply start writing your document and use the Recompile button to view the updated PDF preview. Examples of commonly used commands and features are listed below, to help you get started.
Once you're familiar with the editor, you can find various project settings in the Overleaf menu, accessed via the button in the very top left of the editor. To view tutorials, user guides, and further documentation, please visit our \\href{https://www.overleaf.com/learn}{help library}, or head to our plans page to \\href{https://www.overleaf.com/user/subscription/plans}{choose your plan}.
${'\n'.repeat(200)}
\\end{document}`
const highlights: Highlight[] = [
{
type: 'addition',
range: { from: 15, to: 22 },
hue: 200,
label: 'Added by Wombat on Monday',
},
{
type: 'deletion',
range: { from: 27, to: 35 },
hue: 200,
label: 'Deleted by Wombat on Tuesday',
},
{
type: 'addition',
range: { from: doc.length - 9, to: doc.length - 1 },
hue: 200,
label: 'Added by Wombat on Wednesday',
},
]
const Container: FC = ({ children }) => (
<div style={{ width: 600, height: 400 }}>{children}</div>
)
const mockScope = () => {
return {
settings: {
fontSize: 12,
fontFamily: 'monaco',
lineHeight: 'normal',
overallTheme: '',
},
}
}
describe('document diff viewer', function () {
it('displays highlights with hover tooltips', function () {
const scope = mockScope()
cy.mount(
<Container>
<EditorProviders scope={scope}>
<DocumentDiffViewer doc={doc} highlights={highlights} />
</EditorProviders>
</Container>
)
cy.get('.ol-cm-addition-marker').should('have.length', 1)
cy.get('.ol-cm-addition-marker').first().as('addition')
cy.get('@addition').should('have.text', 'article')
cy.get('.ol-cm-deletion-marker').should('have.length', 1)
cy.get('.ol-cm-deletion-marker').first().as('deletion')
cy.get('@deletion').should('have.text', 'Language')
// Check hover tooltips
cy.get('@addition').trigger('mouseover')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Added by Wombat on Monday')
cy.get('@deletion').trigger('mouseover')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Deleted by Wombat on Tuesday')
})
it('displays highlights with hover tooltips for empty lines', function () {
const scope = mockScope()
const doc = `1
Addition
End
2
Deletion
End
3`
const highlights: Highlight[] = [
{
type: 'addition',
range: { from: 2, to: 16 },
hue: 200,
label: 'Added by Wombat on Monday',
},
{
type: 'deletion',
range: { from: 19, to: 32 },
hue: 200,
label: 'Deleted by Wombat on Tuesday',
},
]
cy.mount(
<Container>
<EditorProviders scope={scope}>
<DocumentDiffViewer doc={doc} highlights={highlights} />
</EditorProviders>
</Container>
)
cy.get('.ol-cm-empty-line-addition-marker').should('have.length', 2)
cy.get('.ol-cm-empty-line-deletion-marker').should('have.length', 1)
// For an empty line marker, we need to trigger mouseover on the containing
// line beause the marker itself does not trigger mouseover
cy.get('.ol-cm-empty-line-addition-marker')
.first()
.parent()
.as('firstAdditionLine')
cy.get('.ol-cm-empty-line-addition-marker')
.first()
.parent()
.as('lastAdditionLine')
cy.get('.ol-cm-empty-line-deletion-marker')
.last()
.parent()
.as('deletionLine')
// Check hover tooltips
cy.get('@lastAdditionLine').trigger('mouseover')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Added by Wombat on Monday')
cy.get('@lastAdditionLine').trigger('mouseleave')
cy.get('@firstAdditionLine').trigger('mouseover')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Added by Wombat on Monday')
cy.get('@deletionLine').trigger('mouseover')
cy.get('.ol-cm-highlight-tooltip').should('have.length', 1)
cy.get('.ol-cm-highlight-tooltip')
.first()
.should('have.text', 'Deleted by Wombat on Tuesday')
})
it("renders 'More updates' buttons", function () {
const scope = mockScope()
cy.mount(
<Container>
<EditorProviders scope={scope}>
<DocumentDiffViewer doc={doc} highlights={highlights} />
</EditorProviders>
</Container>
)
cy.get('.cm-scroller').first().as('scroller')
// Check the initial state, which should be a "More updates below" button
// but no "More updates above", with the editor scrolled to the top
cy.get('.ol-cm-addition-marker').should('have.length', 1)
cy.get('.ol-cm-deletion-marker').should('have.length', 1)
cy.get('.previous-highlight-button').should('have.length', 0)
cy.get('.next-highlight-button').should('have.length', 1)
cy.get('@scroller').invoke('scrollTop').should('equal', 0)
// Click the "More updates below" button, which should scroll the editor,
// and check the new state
cy.get('.next-highlight-button').first().click()
cy.get('@scroller').invoke('scrollTop').should('not.equal', 0)
cy.get('.previous-highlight-button').should('have.length', 1)
cy.get('.next-highlight-button').should('have.length', 0)
// Click the "More updates above" button, which should scroll the editor up
// but not quite to the top, and check the new state
cy.get('.previous-highlight-button').first().click()
cy.get('@scroller').invoke('scrollTop').should('equal', 0)
cy.get('.previous-highlight-button').should('not.exist')
cy.get('.next-highlight-button').should('have.length', 1)
})
it('scrolls to first change', function () {
const scope = mockScope()
const finalHighlightOnly = highlights.slice(-1)
cy.mount(
<Container>
<EditorProviders scope={scope}>
<DocumentDiffViewer doc={doc} highlights={finalHighlightOnly} />
</EditorProviders>
</Container>
)
cy.get('.cm-scroller').first().invoke('scrollTop').should('not.equal', 0)
cy.get('.ol-cm-addition-marker')
.first()
.then($marker => {
cy.get('.cm-content')
.first()
.then($content => {
const contentRect = $content[0].getBoundingClientRect()
const markerRect = $marker[0].getBoundingClientRect()
expect(markerRect.top).to.be.within(
contentRect.top,
contentRect.bottom
)
expect(markerRect.bottom).to.be.within(
contentRect.top,
contentRect.bottom
)
})
})
})
})

Some files were not shown because too many files have changed in this diff Show More