Merge pull request #30637 from overleaf/mj-ce-sp-new-editor

[web] Release editor redesign to Community Edition and Server Pro

GitOrigin-RevId: 062779fb418e44a4b245572fab1b4f365585a7f0
This commit is contained in:
Mathias Jakobsen
2026-01-14 10:08:20 +00:00
committed by Copybot
parent 422b474a17
commit 19545b35d8
14 changed files with 2496 additions and 36 deletions

View File

@@ -87,7 +87,9 @@ describe('GracefulShutdown', function () {
bringServerProBackUp()
cy.then(() => {
cy.visit(`/project/${projectId}?trick-cypress-into-page-reload=true`)
cy.visit(
`/project/${projectId}?trick-cypress-into-page-reload=true&old-editor-override=true`
)
})
cy.log('check loading doc from mongo')

View File

@@ -1,19 +1,38 @@
import { login } from './login'
import { openEmail } from './email'
import { v4 as uuid } from 'uuid'
import { prepareWaitForNextCompileSlot } from './compile'
export const NEW_PROJECT_BUTTON_MATCHER = /new project/i
const NEW_EDITOR_QUERY_PARAMS = ''
const OLD_EDITOR_QUERY_PARAMS = '?old-editor-override=true'
export function redirectEditorUrlWithQueryParams(newEditor: boolean) {
const queryString = newEditor
? NEW_EDITOR_QUERY_PARAMS
: OLD_EDITOR_QUERY_PARAMS
cy.intercept(
{ method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
req => {
// Intercept redirect and add the query param to use the new editor
req.redirect(`${req.url}${queryString}`)
}
)
}
export function createProject(
name: string,
{
type = 'Blank project',
newProjectButtonMatcher = NEW_PROJECT_BUTTON_MATCHER,
open = true,
newEditor = false,
}: {
type?: 'Blank project' | 'Example project'
newProjectButtonMatcher?: RegExp
open?: boolean
newEditor?: boolean
} = {}
): Cypress.Chainable<string> {
cy.url().then(url => {
@@ -30,12 +49,14 @@ export function createProject(
cy.intercept(
{ method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
req => {
projectId = req.url.split('/').pop()!
projectId = req.url.split('/').pop()!.split('?')[0]
// Redirect back to the project dashboard, effectively reload the page.
req.redirect('/project')
}
).as(interceptId)
})
} else {
redirectEditorUrlWithQueryParams(newEditor)
}
cy.findAllByRole('button').contains(newProjectButtonMatcher).click()
// FIXME: This should only look in the left menu
@@ -51,7 +72,7 @@ export function createProject(
return cy
.url()
.should('match', /\/project\/[a-fA-F0-9]{24}/)
.then(url => url.split('/').pop())
.then(url => url.split('/').pop()?.split('?')[0])
} else {
const alias = `@${interceptId}` // IDEs do not like computed values in cy.wait().
cy.wait(alias)
@@ -59,35 +80,93 @@ export function createProject(
}
}
export function openProjectByName(projectName: string) {
// TODO ide-redesign-cleanup: Remove this and just use createProject directly
export function createProjectAndOpenInNewEditor(
projectName: string,
options: {
type?: 'Blank project' | 'Example project'
newProjectButtonMatcher?: RegExp
} = {}
) {
return createProject(projectName, { ...options, open: false }).then(
projectId => {
// Open the new project in the new editor
openProjectById(projectId, true)
return cy.then(() => projectId)
}
)
}
export function openProjectByName(projectName: string, newEditor = false) {
cy.visit('/project')
redirectEditorUrlWithQueryParams(newEditor)
cy.findByText(projectName).click()
waitForMainDocToLoad()
if (newEditor) {
// Close the beta intro modal if it appears
// TODO ide-redesign-cleanup: Remove this when the intro modal is removed
cy.get('body').type('{esc}')
}
}
export function openProjectById(projectId: string) {
cy.visit(`/project/${projectId}`)
waitForMainDocToLoad()
}
export function openProjectViaLinkSharingAsAnon(url: string) {
export function openProjectById(projectId: string, newEditor = false) {
const url = newEditor
? `/project/${projectId}${NEW_EDITOR_QUERY_PARAMS}`
: `/project/${projectId}${OLD_EDITOR_QUERY_PARAMS}`
cy.visit(url)
waitForMainDocToLoad()
if (newEditor) {
// Close the beta intro modal if it appears
// TODO ide-redesign-cleanup: Remove this when the intro modal is removed
cy.get('body').type('{esc}')
}
}
export function openProjectViaLinkSharingAsAnon(
url: string,
newEditor = false
) {
redirectEditorUrlWithQueryParams(newEditor)
cy.visit(url)
waitForMainDocToLoad()
if (newEditor) {
// Close the beta intro modal if it appears
// TODO ide-redesign-cleanup: Remove this when the intro modal is removed
cy.get('body').type('{esc}')
}
}
export function openProjectViaLinkSharingAsUser(
url: string,
projectName: string,
email: string
email: string,
newEditor: boolean = false
) {
cy.visit(url)
cy.findByText(projectName) // wait for lazy loading
cy.contains(`as ${email}`)
redirectEditorUrlWithQueryParams(newEditor)
cy.findByText('OK, join project').click()
waitForMainDocToLoad()
if (newEditor) {
// Close the beta intro modal if it appears
// TODO ide-redesign-cleanup: Remove this when the intro modal is removed
cy.get('body').type('{esc}')
}
}
export function openProjectViaInviteNotification(projectName: string) {
export function openProjectViaInviteNotification(
projectName: string,
newEditor: boolean = false
) {
cy.visit('/project')
cy.findByText(projectName)
.parent()
@@ -95,17 +174,42 @@ export function openProjectViaInviteNotification(projectName: string) {
.within(() => {
cy.findByText('Join Project').click()
})
cy.findByText('Open Project').click()
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
waitForMainDocToLoad()
cy.intercept(
// NOTE: Struggling to work out why but this only seems to work with times: 2.
// This is temporary code so leaving it for now.
{ method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 2 },
req => {
const queryString = newEditor
? NEW_EDITOR_QUERY_PARAMS
: OLD_EDITOR_QUERY_PARAMS
// Intercept redirect and add the query param to use the new editor
req.redirect(`${req.url}${queryString}`)
}
)
const { waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
cy.findByText('Open Project').click()
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
waitForMainDocToLoad()
if (newEditor) {
// Close the beta intro modal if it appears
// TODO ide-redesign-cleanup: Remove this when the intro modal is removed
cy.get('body').type('{esc}')
}
})
}
function shareProjectByEmail(
projectName: string,
email: string,
level: 'Viewer' | 'Editor'
level: 'Viewer' | 'Editor',
newEditor: boolean = false
) {
openProjectByName(projectName)
openProjectByName(projectName, newEditor)
cy.findByRole('button', { name: 'Share' }).click()
cy.findByRole('dialog').within(() => {
cy.findByLabelText('Add email address', { selector: 'input' }).type(
@@ -127,12 +231,13 @@ function shareProjectByEmail(
export function shareProjectByEmailAndAcceptInviteViaDash(
projectName: string,
email: string,
level: 'Viewer' | 'Editor'
level: 'Viewer' | 'Editor',
newEditor: boolean = false
) {
shareProjectByEmail(projectName, email, level)
shareProjectByEmail(projectName, email, level, newEditor)
login(email)
openProjectViaInviteNotification(projectName)
openProjectViaInviteNotification(projectName, newEditor)
}
export function getSpamSafeProjectName() {
@@ -150,9 +255,10 @@ export function getSpamSafeProjectName() {
export function shareProjectByEmailAndAcceptInviteViaEmail(
projectName: string,
email: string,
level: 'Viewer' | 'Editor'
level: 'Viewer' | 'Editor',
newEditor: boolean = false
) {
shareProjectByEmail(projectName, email, level)
shareProjectByEmail(projectName, email, level, newEditor)
login(email)
@@ -167,8 +273,17 @@ export function shareProjectByEmailAndAcceptInviteViaEmail(
cy.url().should('match', /\/project\/[a-f0-9]+\/invite\/token\/[a-f0-9]+/)
cy.findByText(/user would like you to join/)
cy.contains(new RegExp(`You are accepting this invite as ${email}`))
redirectEditorUrlWithQueryParams(newEditor)
cy.findByText('Join Project').click()
waitForMainDocToLoad()
if (newEditor) {
// Close the beta intro modal if it appears
// TODO ide-redesign-cleanup: Remove this when the intro modal is removed
cy.get('body').type('{esc}')
}
}
export function enableLinkSharing() {

View File

@@ -0,0 +1,144 @@
import { ensureUserExists, login } from './helpers/login'
import {
createProject,
createProjectAndOpenInNewEditor,
openProjectViaInviteNotification,
} from './helpers/project'
import { isExcludedBySharding, startWith } from './helpers/config'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
const USER = 'user@example.com'
const COLLABORATOR = 'collaborator@example.com'
describe('new editor.Project creation and compilation', function () {
if (isExcludedBySharding('CE_DEFAULT')) return
startWith({})
ensureUserExists({ email: USER })
ensureUserExists({ email: COLLABORATOR })
it('users can create project and compile it', function () {
login(USER)
const { recompile, waitForCompile, waitForCompileRateLimitCoolOff } =
prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('test-project')
})
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type('\n\\section{{}Test Section}')
})
waitForCompileRateLimitCoolOff()
recompile()
cy.findByRole('region', { name: 'PDF preview' }).within(() => {
cy.findByLabelText(/Page.*1/i).should('be.visible')
cy.findByText('Test Section').should('be.visible')
})
})
it('create and edit markdown file', function () {
const fileName = `test-${Date.now()}.md`
const markdownContent = '# Markdown title'
login(USER)
createProjectAndOpenInNewEditor('test-project')
cy.findByRole('button', { name: 'New file' }).click()
cy.findByRole('dialog').within(() => {
cy.findByLabelText('File Name').as('fileName').clear()
cy.get('@fileName').type(fileName)
cy.findByRole('button', { name: 'Create' }).click()
})
cy.findByRole('treeitem', { name: fileName }).click()
// wait until we've switched to the newly created empty file
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'have.length',
1
)
cy.findByRole('textbox', { name: 'Source Editor editing' }).type(
markdownContent
)
cy.findByRole('treeitem', { name: 'main.tex' }).click()
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\maketitle'
)
cy.findByRole('treeitem', { name: fileName }).click()
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
markdownContent
)
})
it('can link and display linked image from other project', function () {
const sourceProjectName = `test-project-${Date.now()}`
const targetProjectName = `${sourceProjectName}-target`
login(USER)
createProject(sourceProjectName, {
type: 'Example project',
open: false,
newEditor: true,
}).as('sourceProjectId')
createProjectAndOpenInNewEditor(targetProjectName)
// link the image from `projectName` into this project
cy.findByRole('button', { name: 'New file' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('button', { name: 'From another project' }).click()
cy.findByLabelText('Select a Project').select(sourceProjectName)
cy.findByLabelText('Select a File').select('frog.jpg')
cy.findByRole('button', { name: 'Create' }).click()
})
cy.findByRole('treeitem', { name: 'frog.jpg' }).click()
cy.findByRole('link', { name: 'Another project' })
.should('have.attr', 'href')
.then(href => {
cy.get('@sourceProjectId').then(sourceProjectId => {
expect(href).to.equal(`/project/${sourceProjectId}`)
})
})
})
it('can refresh linked files as collaborator', function () {
const sourceProjectName = `test-project-${Date.now()}`
const targetProjectName = `${sourceProjectName}-target`
login(USER)
createProject(sourceProjectName, {
type: 'Example project',
open: false,
newEditor: true,
}).as('sourceProjectId')
createProjectAndOpenInNewEditor(targetProjectName).as('targetProjectId')
// link the image from `projectName` into this project
cy.findByRole('button', { name: 'New file' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('button', { name: 'From another project' }).click()
cy.findByLabelText('Select a Project').select(sourceProjectName)
cy.findByLabelText('Select a File').select('frog.jpg')
cy.findByRole('button', { name: 'Create' }).click()
})
cy.findByRole('navigation', { name: 'Project actions' }).within(() => {
cy.findByRole('button', { name: 'Share' }).click()
})
cy.findByRole('dialog').within(() => {
cy.findByTestId('collaborator-email-input').type(COLLABORATOR + ',')
cy.findByRole('button', { name: 'Invite' }).click()
cy.findByText('Invite not yet accepted.')
})
login(COLLABORATOR)
openProjectViaInviteNotification(targetProjectName, true)
cy.get('@targetProjectId').then(targetProjectId => {
cy.url().should('include', targetProjectId)
})
cy.findByRole('treeitem', { name: 'frog.jpg' }).click()
cy.findByRole('link', { name: 'Another project' })
.should('have.attr', 'href')
.then(href => {
cy.get('@sourceProjectId').then(sourceProjectId => {
expect(href).to.equal(`/project/${sourceProjectId}`)
})
})
})
})

View File

@@ -0,0 +1,290 @@
import {
createNewFile,
createProjectAndOpenInNewEditor,
openProjectById,
testNewFileUpload,
} from './helpers/project'
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import { v4 as uuid } from 'uuid'
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
const USER = 'user@example.com'
const COLLABORATOR = 'collaborator@example.com'
describe('new editor.editor', function () {
if (isExcludedBySharding('PRO_DEFAULT_1')) return
startWith({ pro: true })
ensureUserExists({ email: USER })
ensureUserExists({ email: COLLABORATOR })
let projectName: string
let projectId: string
let recompile: () => void
let waitForCompile: (fn: () => void) => void
beforeWithReRunOnTestRetry(() => {
projectName = `project-${uuid()}`
login(USER)
createProjectAndOpenInNewEditor(projectName, {
type: 'Example project',
}).then(id => (projectId = id))
;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
})
beforeEach(function () {
login(USER)
waitForCompile(() => {
openProjectById(projectId, true)
})
})
describe('spelling', function () {
function changeSpellCheckLanguageTo(lng: string) {
cy.log(`change project language to '${lng}'`)
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').within(() => {
cy.findByLabelText('Spellcheck language').select(lng)
})
cy.get('body').type('{esc}')
}
afterEach(function () {
changeSpellCheckLanguageTo('Off')
})
it('word dictionary and spelling', function () {
changeSpellCheckLanguageTo('English (American)')
createNewFile()
const word = createRandomLetterString()
cy.log('edit project file')
cy.get('.cm-line').type(word)
cy.findByText(word).should('have.class', 'ol-cm-spelling-error')
changeSpellCheckLanguageTo('Off')
cy.findByText(word).should('not.have.class', 'ol-cm-spelling-error')
changeSpellCheckLanguageTo('Spanish')
cy.findByText(word).should('have.class', 'ol-cm-spelling-error')
cy.log('add word to dictionary')
cy.findByText(word).rightclick()
cy.findByRole('menuitem', { name: 'Add to dictionary' }).click()
cy.findByText(word).should('not.have.class', 'ol-cm-spelling-error')
cy.log('remove word from dictionary')
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').within(() => {
cy.findByLabelText('Dictionary').click()
})
cy.findByTestId('dictionary-modal').within(() => {
cy.findByText(word)
.parent()
.within(() =>
cy.findByRole('button', { name: 'Remove from dictionary' }).click()
)
cy.findByRole('button', { name: 'Close dialog' }).click()
})
cy.log('close modal')
cy.get('body').type('{esc}')
cy.log('rewrite word to force spelling error')
cy.get('.cm-line').type('{selectAll}{del}' + word + '{enter}')
cy.get('.ol-cm-spelling-error').should('contain.text', word)
})
})
describe('editor', function () {
it('renders jpg', function () {
cy.findByRole('treeitem', { name: 'frog.jpg' }).click({ force: true })
cy.get('[alt="frog.jpg"]')
.should('be.visible')
.and('have.prop', 'naturalWidth')
.should('be.greaterThan', 0)
})
it('symbol palette', function () {
createNewFile()
cy.get('button[aria-label="Insert symbol"]').click({
force: true,
})
cy.get('button').contains('𝜉').click()
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\xi'
)
cy.log('recompile to force flush and avoid "unsaved changes" prompt')
recompile()
})
})
describe('add new file to project', function () {
beforeEach(function () {
cy.findByRole('button', { name: 'New file' }).click()
})
testNewFileUpload()
it('should not display import from URL', function () {
cy.findByRole('button', { name: 'From external URL' }).should('not.exist')
})
})
describe('file menu', function () {
it('can download project sources', function () {
cy.findByRole('button', { name: 'File' }).click()
cy.findByRole('menuitem', { name: 'Download as source (.zip)' }).click()
const zipName = projectName.replaceAll('-', '_')
cy.task('readFileInZip', {
pathToZip: `cypress/downloads/${zipName}.zip`,
fileToRead: 'main.tex',
}).should('contain', 'Your introduction goes here')
})
it('can download project PDF', function () {
cy.log('ensure project is compiled')
cy.findByRole('region', { name: 'PDF preview' }).should(
'contain.text',
'Your Paper'
)
cy.findByRole('button', { name: 'File' }).click()
cy.findByRole('menuitem', { name: 'Download as PDF' }).click()
const pdfName = projectName.replaceAll('-', '_')
cy.task('readPdf', `cypress/downloads/${pdfName}.pdf`).should(
'contain',
'Your introduction goes here'
)
})
it('word count', function () {
cy.log('ensure project is compiled')
cy.findByRole('region', { name: 'PDF preview' }).should(
'contain.text',
'Your Paper'
)
cy.findByRole('button', { name: 'File' }).click()
cy.findByRole('menuitem', { name: 'Word count' }).click()
cy.findByTestId('word-count-modal').within(() => {
cy.findByText('Total Words:')
cy.findByText('607')
cy.findByText('Headers:')
cy.findByText('14')
cy.findByText('Math Inline:')
cy.findByText('6')
cy.findByText('Math Display:')
cy.findByText('1')
})
})
})
describe('cite key search', function () {
it('can insert citation from cite key', function () {
createNewFile()
cy.get('.cm-line').type('\\cite{{}gre')
cy.findByRole('listbox').within(() => {
cy.findByRole('option').should('contain.text', 'greenwade93').click()
})
cy.get('.cm-line').should('have.text', '\\cite{greenwade93}')
})
it('updates citation search when bib file is changed', function () {
createNewFile()
cy.get('.cm-line').type('\\cite{{}new')
// Wait a reasonable time to ensure the autocomplete would've appeared if there were any matches
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(200)
cy.findByRole('listbox').should('not.exist')
cy.findByRole('treeitem', { name: 'sample.bib' }).click()
cy.get('.cm-line')
.last()
.type(
'\n@article{{}newkey2024,\n author = {{}Doe, John},\n title = {{}A New Article},\n journal = {{}Journal of Testing},\n year = 2024\n}\n'
)
createNewFile()
cy.get('.cm-line').type('\\cite{{}new')
cy.findByRole('listbox').within(() => {
cy.findByRole('option').should('contain.text', 'newkey2024').click()
})
cy.get('.cm-line').should('have.text', '\\cite{newkey2024}')
})
})
describe('layout selector', function () {
it('show editor only and switch between editor and pdf', function () {
cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
cy.get('.cm-editor').should('be.visible')
cy.findByRole('button', { name: 'Layout options' }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /Editor only/ }).click()
})
cy.findByRole('region', { name: 'PDF preview' }).should('not.be.visible')
cy.get('.cm-editor').should('be.visible')
// force click as tooltip may cover button for some reason
cy.findByRole('button', { name: 'Switch to PDF' }).click({ force: true })
cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
cy.get('.cm-editor').should('not.be.visible')
cy.findByRole('button', { name: 'Switch to editor' }).click()
cy.findByRole('region', { name: 'PDF preview' }).should('not.be.visible')
cy.get('.cm-editor').should('be.visible')
})
it('show PDF only and go back to split view', function () {
cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
cy.get('.cm-editor').should('be.visible')
cy.findByRole('button', { name: 'Layout options' }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /PDF only/ }).click()
})
cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
cy.get('.cm-editor').should('not.be.visible')
cy.findByRole('button', { name: 'Layout options' }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: 'Split view' }).click()
})
cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
cy.get('.cm-editor').should('be.visible')
})
it('PDF in a separate tab (tests editor only)', function () {
cy.findByTestId('pdf-viewer').should('be.visible')
cy.get('.cm-editor').should('be.visible')
cy.findByRole('button', { name: 'Layout options' }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: 'Open PDF in separate tab' }).click()
})
cy.findByTestId('pdf-viewer').should('not.exist')
cy.get('.cm-editor').should('be.visible')
})
})
})
function createRandomLetterString() {
const chars = 'abcdefghijklmnopqrstuvwxyz'
let result = ''
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}

View File

@@ -0,0 +1,458 @@
import { v4 as uuid } from 'uuid'
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import {
createProject,
createProjectAndOpenInNewEditor,
enableLinkSharing,
openProjectByName,
openProjectViaLinkSharingAsUser,
shareProjectByEmailAndAcceptInviteViaDash,
} from './helpers/project'
import git from 'isomorphic-git'
import http from 'isomorphic-git/http/web'
import LightningFS from '@isomorphic-git/lightning-fs'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
const USER = 'user@example.com'
describe('new editor.git-bridge', function () {
const ENABLED_VARS = {
GIT_BRIDGE_ENABLED: 'true',
GIT_BRIDGE_HOST: 'git-bridge',
GIT_BRIDGE_PORT: '8000',
V1_HISTORY_URL: 'http://sharelatex:3100/api',
}
function gitURL(projectId: string) {
const url = new URL(Cypress.config().baseUrl!)
url.username = 'git'
url.pathname = `/git/${projectId}`
return url
}
describe('enabled in Server Pro', function () {
if (isExcludedBySharding('PRO_CUSTOM_1')) return
startWith({
pro: true,
vars: ENABLED_VARS,
})
ensureUserExists({ email: USER })
function clearAllTokens() {
cy.findAllByRole('button', { name: 'Remove' })
.not('[disabled]')
.each($button => {
cy.wrap($button).click()
cy.findByRole('button', { name: 'Delete token' }).click()
})
cy.findByRole('dialog').should('not.exist')
}
function maybeClearAllTokens() {
cy.visit('/user/settings')
cy.findByRole('heading', { name: 'Git integration' })
cy.findByRole('button', {
name: /Generate token|Add another token/i,
}).then(btn => {
if (btn.text() === 'Add another token') {
clearAllTokens()
}
})
}
beforeEach(function () {
login(USER)
})
it('should render the git-bridge UI in the settings', function () {
maybeClearAllTokens()
cy.visit('/user/settings')
cy.findByRole('heading', { name: 'Git integration' })
cy.findByRole('button', {
name: 'Git integration Generate token',
}).click()
cy.findByRole('dialog').within(() => {
cy.findByLabelText('Git authentication token')
.contains(/olp_[a-zA-Z0-9]{16}/)
.then(el => el.text())
.as('newToken')
cy.findByRole('button', { name: 'Close dialog' }).click()
})
cy.get('@newToken').then(token => {
// There can be more than one token with the same prefix when retrying
cy.findAllByText(
`${token.slice(0, 'olp_1234'.length)}${'*'.repeat(12)}`
).should('have.length.at.least', 1)
})
cy.findByRole('button', {
name: 'Git integration Generate token',
}).should('not.exist')
cy.findByRole('button', { name: 'Add another token' }).should('exist')
clearAllTokens()
cy.findByRole('button', {
name: 'Git integration Generate token',
}).should('exist')
cy.findByRole('button', { name: 'Add another token' }).should('not.exist')
})
it('should render the git-bridge UI in the editor', function () {
maybeClearAllTokens()
createProjectAndOpenInNewEditor('git').as('projectId')
cy.findByRole('tab', { name: 'Integrations' }).click()
cy.findByText('Git clone this project.').click()
cy.findByTestId('git-bridge-modal').within(() => {
cy.get('@projectId').then(id => {
cy.findByLabelText('Git clone project command').contains(
`git clone ${gitURL(id.toString())}`
)
})
cy.findByRole('button', {
name: 'Generate token',
}).click()
cy.findByLabelText('Git authentication token').contains(
/olp_[a-zA-Z0-9]{16}/
)
})
// Re-open
cy.url().then(url => cy.visit(url))
cy.findByText('Git clone this project.').click()
cy.findByTestId('git-bridge-modal').within(() => {
cy.get('@projectId').then(id => {
cy.findByLabelText('Git clone project command').contains(
`git clone ${gitURL(id.toString())}`
)
})
cy.findByRole('button', {
name: 'Generate token',
}).should('not.exist')
cy.findByText(/generate a new one in Account settings/)
cy.findByRole('link', { name: 'Go to settings' })
.should('have.attr', 'target', '_blank')
.and('have.attr', 'href', '/user/settings')
})
})
describe('git access', function () {
ensureUserExists({ email: 'collaborator-rw@example.com' })
ensureUserExists({ email: 'collaborator-ro@example.com' })
ensureUserExists({ email: 'collaborator-link-rw@example.com' })
ensureUserExists({ email: 'collaborator-link-ro@example.com' })
let projectName: string
let recompile: () => void
let waitForCompile: (triggerCompile: () => void) => void
beforeEach(function () {
projectName = uuid()
createProject(projectName, { open: false }).as('projectId')
;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
})
it('should expose r/w interface to owner', function () {
maybeClearAllTokens()
waitForCompile(() => {
openProjectByName(projectName, true)
})
checkGitAccess('readAndWrite')
})
it('should expose r/w interface to invited r/w collaborator', function () {
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
'collaborator-rw@example.com',
'Editor',
true
)
maybeClearAllTokens()
waitForCompile(() => {
openProjectByName(projectName, true)
})
checkGitAccess('readAndWrite')
})
it('should expose r/o interface to invited r/o collaborator', function () {
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
'collaborator-ro@example.com',
'Viewer',
true
)
maybeClearAllTokens()
waitForCompile(() => {
openProjectByName(projectName, true)
})
checkGitAccess('readOnly')
})
it('should expose r/w interface to link-sharing r/w collaborator', function () {
openProjectByName(projectName, true)
enableLinkSharing().then(({ linkSharingReadAndWrite }) => {
const email = 'collaborator-link-rw@example.com'
login(email)
maybeClearAllTokens()
waitForCompile(() => {
openProjectViaLinkSharingAsUser(
linkSharingReadAndWrite,
projectName,
email,
true
)
})
checkGitAccess('readAndWrite')
})
})
it('should expose r/o interface to link-sharing r/o collaborator', function () {
waitForCompile(() => {
openProjectByName(projectName, true)
})
enableLinkSharing().then(({ linkSharingReadOnly }) => {
const email = 'collaborator-link-ro@example.com'
login(email)
maybeClearAllTokens()
waitForCompile(() => {
openProjectViaLinkSharingAsUser(
linkSharingReadOnly,
projectName,
email,
true
)
})
checkGitAccess('readOnly')
})
})
function checkGitAccess(access: 'readOnly' | 'readAndWrite') {
cy.findByRole('tab', { name: 'Integrations' }).click()
cy.findByText('Git clone this project.').click()
cy.get('@projectId').then(projectId => {
cy.findByTestId('git-bridge-modal').within(() => {
cy.findByLabelText('Git clone project command').contains(
`git clone ${gitURL(projectId.toString())}`
)
cy.findByRole('heading', { name: 'Clone with Git' })
cy.findByRole('button', {
name: 'Generate token',
}).click()
})
cy.findByLabelText('Git authentication token')
.contains(/olp_[a-zA-Z0-9]{16}/)
.then(async tokenEl => {
const token = tokenEl.text()
// close Git modal
cy.findByRole('button', { name: 'Close dialog' }).click()
cy.findByTestId('git-bridge-modal').should('not.exist')
// close the modal
cy.get('body').type('{esc}')
cy.findByTestId('left-menu').should('not.exist')
const fs = new LightningFS('fs')
const dir = `/${projectId}`
async function readFile(path: string): Promise<string> {
return await new Promise((resolve, reject) => {
fs.readFile(path, { encoding: 'utf8' }, (err, blob) => {
if (err) return reject(err)
resolve(blob as string)
})
})
}
async function writeFile(path: string, data: string) {
return await new Promise<void>((resolve, reject) => {
fs.writeFile(path, data, undefined, err => {
if (err) return reject(err)
resolve()
})
})
}
const commonOptions = {
dir,
fs,
}
const url = gitURL(projectId.toString())
url.username = '' // basic auth is specified separately.
const httpOptions = {
http,
url: url.toString(),
headers: {
Authorization: `Basic ${Buffer.from(`git:${token}`).toString('base64')}`,
},
}
const authorOptions = {
author: { name: 'user', email: USER },
committer: { name: 'user', email: USER },
}
const mainTex = `${dir}/main.tex`
// Clone
cy.then({ timeout: 10_000 }, async () => {
await git.clone({
...commonOptions,
...httpOptions,
})
})
cy.findByText(/\\documentclass/)
.parent()
.parent()
.then(async editor => {
const onDisk = await readFile(mainTex)
expect(onDisk.replaceAll('\n', '')).to.equal(editor.text())
})
const text = `
\\documentclass{article}
\\begin{document}
Hello world
\\end{document}
`
// Make a change
cy.then(async () => {
await writeFile(mainTex, text)
await git.add({
...commonOptions,
filepath: 'main.tex',
})
await git.commit({
...commonOptions,
...authorOptions,
message: 'Swap main.tex',
})
})
if (access === 'readAndWrite') {
// check history before push
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'History' })
.click()
cy.findByText('(via Git)').should('not.exist')
cy.findAllByText('Back to editor').last().click()
cy.then(async () => {
await git.push({
...commonOptions,
...httpOptions,
})
})
} else {
cy.then(async () => {
try {
await git.push({
...commonOptions,
...httpOptions,
})
expect.fail('push should have failed')
} catch (err) {
expect(err).to.match(/branches were not updated/)
expect(err).to.match(/forbidden/)
}
})
return // return early, below are write access bits
}
// check push in editor
cy.findByText(/\\documentclass/)
.parent()
.parent()
.should('have.text', text.replaceAll('\n', ''))
// Wait for history sync - trigger flush by toggling the UI
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'History' })
.click()
cy.findAllByText('Back to editor').last().click()
// check push in history
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'History' })
.click()
cy.findByText(/Hello world/)
cy.findByText('(via Git)').should('exist')
// Back to the editor
cy.findAllByText('Back to editor').last().click()
cy.findByText(/\\documentclass/)
.parent()
.parent()
.as('editor')
.click()
cy.get('@editor').type('% via editor{enter}')
// Trigger flush via compile
recompile()
// Back into the history, check what we just added
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'History' })
.click()
cy.findByText(/% via editor/)
// Pull the change
cy.then(async () => {
await git.pull({
...commonOptions,
...httpOptions,
...authorOptions,
})
expect(await readFile(mainTex)).to.equal(
text + '% via editor\n'
)
})
})
})
}
})
})
function checkDisabled() {
ensureUserExists({ email: USER })
it('should not render the git-bridge UI in the settings', function () {
login(USER)
cy.visit('/user/settings')
cy.findByRole('heading', { name: 'Git integration' }).should('not.exist')
})
it('should not render the git-bridge UI in the editor', function () {
login(USER)
createProjectAndOpenInNewEditor('maybe git')
cy.findByRole('tab', {
name: 'File tree',
}).should('exist') // Wait for load
cy.findByRole('tab', {
name: 'Integrations',
}).should('not.exist')
})
}
describe('disabled in Server Pro', function () {
if (isExcludedBySharding('PRO_DEFAULT_1')) return
startWith({
pro: true,
})
checkDisabled()
})
describe('unavailable in CE', function () {
if (isExcludedBySharding('CE_CUSTOM_1')) return
startWith({
pro: false,
vars: ENABLED_VARS,
})
checkDisabled()
})
})

View File

@@ -0,0 +1,109 @@
import { ensureUserExists, login } from './helpers/login'
import {
isExcludedBySharding,
STARTUP_TIMEOUT,
startWith,
} from './helpers/config'
import { dockerCompose, getRedisKeys } from './helpers/hostAdminClient'
import { createProjectAndOpenInNewEditor } from './helpers/project'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
const USER = 'user@example.com'
const PROJECT_NAME = 'Old Project'
function bringServerProBackUp() {
cy.log('bring server pro back up')
cy.then({ timeout: STARTUP_TIMEOUT }, async () => {
await dockerCompose('up', '--detach', '--wait', 'sharelatex')
})
}
describe('new editor.GracefulShutdown', function () {
if (isExcludedBySharding('PRO_CUSTOM_1')) return
startWith({
pro: true,
withDataDir: true,
resetData: true,
})
ensureUserExists({ email: USER })
let projectId: string
it('should display banner and flush changes out of redis', function () {
bringServerProBackUp()
login(USER)
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor(PROJECT_NAME).then(id => {
projectId = id
})
})
cy.log('add additional content')
cy.findByRole('region', { name: 'Editor' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type(`\n\\section{{}New Section}`)
})
recompile()
cy.log(
'check flush from frontend to backend: should include new section in PDF'
)
cy.findByRole('region', { name: 'PDF preview' }).should(
'contain.text',
'New Section'
)
cy.log('should have unflushed content in redis before shutdown')
cy.then(async () => {
const keys = await getRedisKeys()
expect(keys).to.contain(`DocsIn:${projectId}`)
expect(keys).to.contain(`ProjectHistory:Ops:{${projectId}}`)
})
cy.log('trigger graceful shutdown')
let pendingShutdown: Promise<any>
cy.then(() => {
pendingShutdown = dockerCompose('stop', '--timeout=60', 'sharelatex')
})
cy.log('wait for banner')
cy.findByRole('dialog').findByText(/performing maintenance/)
cy.log('wait for page reload')
cy.findByRole('heading', { name: 'Maintenance' })
cy.findByText(/is currently down for maintenance/)
cy.log('wait for shutdown to complete')
cy.then({ timeout: 60 * 1000 }, async () => {
await pendingShutdown
})
cy.log('should not have any unflushed content in redis after shutdown')
cy.then(async () => {
const keys = await getRedisKeys()
expect(keys).to.not.contain(`DocsIn:${projectId}`)
expect(keys).to.not.contain(`ProjectHistory:Ops:{${projectId}}`)
})
bringServerProBackUp()
cy.then(() => {
cy.visit(`/project/${projectId}?trick-cypress-into-page-reload=true`)
})
cy.log('check loading doc from mongo')
cy.findByRole('region', { name: 'Editor' }).findByText('New Section')
cy.log('check PDF')
cy.findByRole('region', { name: 'PDF preview' }).should(
'contain.text',
'New Section'
)
cy.log('check history')
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'History' })
.click()
cy.findByText(/\\section\{New Section}/)
})
})

View File

@@ -0,0 +1,164 @@
import { createProjectAndOpenInNewEditor } from './helpers/project'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
import { ensureUserExists, login } from './helpers/login'
import { isExcludedBySharding, startWith } from './helpers/config'
describe('new editor.History', function () {
if (isExcludedBySharding('CE_DEFAULT')) return
startWith({})
ensureUserExists({ email: 'user@example.com' })
beforeEach(function () {
login('user@example.com')
})
function addLabel(name: string) {
cy.log(`add label ${JSON.stringify(name)}`)
// The input is not clickable due to being visually hidden, click its label instead
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findByRole('group', {
name: 'Show all of the project history or only labelled versions.',
}).within(() => {
cy.findByText('Labels').click()
})
cy.findByRole('radio', { name: 'Labels' }).should('be.checked')
cy.findByRole('radio', { name: 'All history' }).should('not.be.checked')
cy.findAllByTestId('history-version-details')
.first()
.within(() => {
cy.findByRole('button', { name: 'More actions' }).click()
cy.findByRole('menuitem', { name: 'Label this version' }).click()
})
})
cy.findByRole('dialog').within(() => {
cy.findByLabelText('New label name').type(`${name}{enter}`)
})
}
function downloadVersion(name: string) {
cy.log(`download version ${JSON.stringify(name)}`)
// The input is not clickable due to being visually hidden, click its label instead
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findByRole('group', {
name: 'Show all of the project history or only labelled versions.',
}).within(() => {
cy.findByText('Labels').click()
})
cy.findByRole('radio', { name: 'Labels' }).should('be.checked')
cy.findByRole('radio', { name: 'All history' }).should('not.be.checked')
cy.findByText(name)
.closest('[data-testid="history-version-details"]')
.within(() => {
cy.findByRole('button', { name: 'More actions' }).click()
cy.findByRole('menuitem', { name: 'Download this version' }).click()
})
})
}
const CLASS_ADDITION = 'ol-cm-addition-marker'
const CLASS_DELETION = 'ol-cm-deletion-marker'
it('should support labels, comparison and download', function () {
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('labels')
})
cy.log('add content, including a line that will get removed soon')
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type('\n% added')
cy.findByText('\\maketitle').parent().type('\n% to be removed')
})
recompile()
cy.findByRole('button', { name: 'History' }).click()
cy.log('expect to see additions in history')
cy.get('.document-diff-container').within(() => {
cy.findByText('% to be removed').should('have.class', CLASS_ADDITION)
cy.findByText('% added').should('have.class', CLASS_ADDITION)
})
addLabel('Before removal')
cy.log('remove content')
cy.findByRole('button', { name: 'Back to editor' }).click()
cy.findByText('% to be removed').parent().type('{end}{shift}{upArrow}{del}')
recompile()
cy.findByRole('button', { name: 'History' }).click()
cy.log('expect to see annotation for newly removed content in history')
cy.get('.document-diff-container').within(() => {
cy.findByText('% to be removed').should('have.class', CLASS_DELETION)
cy.findByText('% added').should('not.have.class', CLASS_ADDITION)
})
addLabel('After removal')
cy.log('add more content after labeling')
cy.findByRole('button', { name: 'Back to editor' }).click()
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type('\n% more')
})
recompile()
cy.log('compare non current versions')
cy.findByRole('button', { name: 'History' }).click()
// The input is not clickable due to being visually hidden, click its label instead
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findByRole('group', {
name: 'Show all of the project history or only labelled versions.',
}).within(() => {
cy.findByText('Labels').click()
})
cy.findByRole('radio', { name: 'Labels' }).should('be.checked')
cy.findByRole('radio', { name: 'All history' }).should('not.be.checked')
cy.findAllByTestId('compare-icon-version').last().click()
cy.findAllByTestId('compare-icon-version').filter(':visible').click()
cy.findByRole('menuitem', {
name: 'Compare up to this version',
}).click()
})
cy.log(
'expect to see annotation for removed content between the two versions'
)
cy.get('.document-diff-container').within(() => {
cy.findByText('% to be removed').should('have.class', CLASS_DELETION)
cy.findByText('% added').should('not.have.class', CLASS_ADDITION)
cy.findByText('% more').should('not.exist')
})
downloadVersion('Before removal')
cy.task('readFileInZip', {
pathToZip: `cypress/downloads/labels (Version 2).zip`,
fileToRead: 'main.tex',
})
.should('contain', '% added')
.should('contain', '% to be removed')
.should('not.contain', '% more')
downloadVersion('After removal')
cy.task('readFileInZip', {
pathToZip: `cypress/downloads/labels (Version 3).zip`,
fileToRead: 'main.tex',
})
.should('contain', '% added')
.should('not.contain', '% to be removed')
.should('not.contain', '% more')
downloadVersion('Current state')
cy.task('readFileInZip', {
pathToZip: `cypress/downloads/labels (Version 4).zip`,
fileToRead: 'main.tex',
})
.should('contain', '% added')
.should('not.contain', '% to be removed')
.should('contain', '% more')
})
})

View File

@@ -0,0 +1,534 @@
import { v4 as uuid } from 'uuid'
import {
isExcludedBySharding,
startWith,
reloadWith,
STARTUP_TIMEOUT,
} from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import {
createProjectAndOpenInNewEditor,
enableLinkSharing,
getSpamSafeProjectName,
openProjectByName,
openProjectViaLinkSharingAsAnon,
openProjectViaLinkSharingAsUser,
shareProjectByEmailAndAcceptInviteViaDash,
shareProjectByEmailAndAcceptInviteViaEmail,
} from './helpers/project'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
describe('new editor.Project Sharing', function () {
if (isExcludedBySharding('PRO_CUSTOM_4')) return
ensureUserExists({ email: 'user@example.com' })
startWith({ withDataDir: true, pro: true })
let projectName: string
let recompile: () => void
let waitForCompile: (triggerCompile: () => void) => void
beforeWithReRunOnTestRetry(() => {
projectName = getSpamSafeProjectName()
;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
setupTestProject()
})
beforeEach(function () {
// Always start with a fresh session
cy.session([uuid()], () => {})
})
let linkSharingReadOnly: string
let linkSharingReadAndWrite: string
function setupTestProject() {
login('user@example.com')
waitForCompile(() => {
createProjectAndOpenInNewEditor(projectName)
})
// Add chat message
cy.findByRole('tab', { name: 'Chat' }).click()
// wait for lazy loading of the chat pane
cy.findByText('Start the conversation by saying hello or sharing an update')
cy.get(
'textarea[placeholder="Send a message to your collaborators…"]'
).type('New Chat Message{enter}')
// Get link sharing links
enableLinkSharing().then(
({ linkSharingReadOnly: ro, linkSharingReadAndWrite: rw }) => {
linkSharingReadAndWrite = rw
linkSharingReadOnly = ro
}
)
}
function expectContentReadOnlyAccess() {
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\maketitle'
)
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'have.attr',
'contenteditable',
'false'
)
}
function expectContentWriteAccess() {
const section = `Test Section ${uuid()}`
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
// wait for the editor to finish loading
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\maketitle'
)
// the editor should be writable
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'have.attr',
'contenteditable',
'true'
)
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type(`\n\\section{{}${section}}`)
})
// should have written
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
`\\section{${section}}`
)
// check PDF
recompile()
cy.findByRole('region', { name: 'PDF preview' }).within(() => {
cy.findByLabelText(/Page.*1/i).should('be.visible')
cy.findByText(projectName).should('be.visible')
})
cy.findByRole('region', { name: 'PDF preview' }).within(() => {
cy.findByLabelText(/Page.*1/i).should('be.visible')
cy.contains(section)
})
}
function expectNoAccess() {
// try read only access link
cy.visit(linkSharingReadOnly)
cy.url().should('match', /\/login/)
// Cypress bugs: cypress resolves the link-sharing link outside the browser, and it carries over the hash of the link-sharing link to the login page redirect (bug 1).
// Effectively, cypress then instructs the browser to change the page from /login#read-only-hash to /login#read-and-write-hash.
// This is turn does not trigger a "page load", but rather just "scrolling", which in turn trips up the "page loaded" detection in cypress (bug 2).
// Work around this by navigating away from the /login page in between checks.
cy.visit('/user/password/reset')
// try read and write access link
cy.visit(linkSharingReadAndWrite)
cy.url().should('match', /\/login/)
}
function expectChatAccess() {
cy.findByRole('tab', { name: 'Chat' }).click()
cy.findByText('New Chat Message')
}
function expectHistoryAccess() {
cy.findByRole('button', { name: 'History' }).click()
// The input is not clickable due to being visually hidden, click its label instead
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findByRole('group', {
name: 'Show all of the project history or only labelled versions.',
}).within(() => {
cy.findByText('All history').click()
})
cy.findByRole('radio', { name: 'Labels' }).should('not.be.checked')
cy.findByRole('radio', { name: 'All history' }).should('be.checked')
})
cy.findByText(/\\begin\{document}/)
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findAllByTestId('history-version-metadata-users')
.last()
.should('have.text', 'user')
})
cy.findByRole('button', { name: 'Back to editor' }).click()
}
function expectNoChatAccess() {
cy.findByRole('button', { name: 'Layout options' }) // wait for lazy loading
cy.findByRole('tab', { name: 'Chat' }).should('not.exist')
}
function expectNoHistoryAccess() {
cy.findByRole('button', { name: 'Layout options' }) // wait for lazy loading
cy.findByRole('button', { name: 'History' }).should('not.exist')
}
function expectCommentAccess() {
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\maketitle'
)
cy.findByText('\\maketitle').parent().dblclick()
cy.findByRole('button', { name: 'Add comment' }).should('be.visible')
cy.findByRole('textbox', { name: 'Source Editor editing' }).click()
}
function expectNoCommentAccess() {
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\maketitle'
)
cy.findByText('\\maketitle').parent().dblclick()
cy.findByRole('button', { name: 'Add comment' }).should('not.exist')
cy.findByRole('textbox', { name: 'Source Editor editing' }).click()
}
function expectFullReadOnlyAccess() {
expectContentReadOnlyAccess()
expectChatAccess()
expectHistoryAccess()
expectNoCommentAccess()
}
function expectRestrictedReadOnlyAccess() {
expectContentReadOnlyAccess()
expectNoChatAccess()
expectNoHistoryAccess()
expectNoCommentAccess()
}
function expectFullReadAndWriteAccess() {
expectContentWriteAccess()
expectChatAccess()
expectHistoryAccess()
expectCommentAccess()
}
function expectAnonymousReadAndWriteAccess() {
expectContentWriteAccess()
expectChatAccess()
expectHistoryAccess()
expectNoCommentAccess()
}
function expectProjectDashboardEntry() {
cy.visit('/project')
cy.findByText(projectName)
}
function expectEditAuthoredAs(author: string) {
cy.findByRole('button', { name: 'History' }).click()
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findAllByTestId('history-version-metadata-users')
.first()
.should('contain.text', author) // might have other edits in the same group
})
}
describe('via email', function () {
const email = 'collaborator-email@example.com'
ensureUserExists({ email })
beforeEach(function () {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaEmail(
projectName,
email,
'Viewer',
true
)
})
it('should grant the collaborator read access', function () {
expectFullReadOnlyAccess()
expectProjectDashboardEntry()
})
})
describe('read only', function () {
const email = 'collaborator-ro@example.com'
ensureUserExists({ email })
beforeWithReRunOnTestRetry(() => {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
email,
'Viewer',
true
)
})
it('should grant the collaborator read access', function () {
login(email)
openProjectByName(projectName, true)
expectFullReadOnlyAccess()
expectProjectDashboardEntry()
})
})
describe('read and write', function () {
const email = 'collaborator-rw@example.com'
ensureUserExists({ email })
beforeWithReRunOnTestRetry(() => {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
email,
'Editor',
true
)
})
it('should grant the collaborator write access', function () {
login(email)
openProjectByName(projectName, true)
expectFullReadAndWriteAccess()
expectEditAuthoredAs('You')
expectProjectDashboardEntry()
})
})
describe('token access', function () {
describe('logged in', function () {
describe('read only', function () {
const email = 'collaborator-link-ro@example.com'
ensureUserExists({ email })
it('should grant restricted read access', function () {
login(email)
openProjectViaLinkSharingAsUser(
linkSharingReadOnly,
projectName,
email,
true
)
expectRestrictedReadOnlyAccess()
expectProjectDashboardEntry()
})
})
describe('read and write', function () {
const email = 'collaborator-link-rw@example.com'
ensureUserExists({ email })
it('should grant full write access', function () {
login(email)
openProjectViaLinkSharingAsUser(
linkSharingReadAndWrite,
projectName,
email,
true
)
expectFullReadAndWriteAccess()
expectEditAuthoredAs('You')
expectProjectDashboardEntry()
})
})
})
describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=false', function () {
describe('wrap startup', function () {
startWith({
pro: true,
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false',
},
withDataDir: true,
})
it('should block access', function () {
expectNoAccess()
})
})
describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', function () {
startWith({
pro: true,
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false',
OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
},
withDataDir: true,
})
it('should block access', function () {
expectNoAccess()
})
})
})
describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=true', function () {
describe('wrap startup', function () {
startWith({
pro: true,
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
},
withDataDir: true,
})
it('should grant read access with read link', function () {
openProjectViaLinkSharingAsAnon(linkSharingReadOnly, true)
expectRestrictedReadOnlyAccess()
})
it('should prompt for login with write link', function () {
cy.visit(linkSharingReadAndWrite)
cy.url().should('match', /\/login/)
})
})
describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', function () {
startWith({
pro: true,
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
},
withDataDir: true,
})
it('should grant read access with read link', function () {
openProjectViaLinkSharingAsAnon(linkSharingReadOnly, true)
expectRestrictedReadOnlyAccess()
})
it('should grant write access with write link', function () {
openProjectViaLinkSharingAsAnon(linkSharingReadAndWrite, true)
expectAnonymousReadAndWriteAccess()
expectEditAuthoredAs('Anonymous')
})
})
})
describe('with OVERLEAF_DISABLE_LINK_SHARING=true', function () {
const email = 'collaborator-email@example.com'
ensureUserExists({ email })
const invitedEmail = 'invited-email@example.com'
ensureUserExists({ email: invitedEmail })
const retainedViewerEmail = 'collaborator-retained-viewer@example.com'
ensureUserExists({ email: retainedViewerEmail })
const retainedEditorEmail = 'collaborator-retained-editor@example.com'
ensureUserExists({ email: retainedEditorEmail })
// Link-sharing urls have to be created before disabling link sharing.
// We use the `beforeEach` hook to reload the server with link sharing
// disabled **after** the initial setup which happens in the `before`
// block. The `before` hook always runs prior to the `beforeEach` hook.
// Set up retained access before disabling link sharing
before(function () {
// Set up retained viewer access
login(retainedViewerEmail)
openProjectViaLinkSharingAsUser(
linkSharingReadOnly,
projectName,
retainedViewerEmail,
true
)
// Set up retained editor access
login(retainedEditorEmail)
openProjectViaLinkSharingAsUser(
linkSharingReadAndWrite,
projectName,
retainedEditorEmail,
true
)
})
beforeEach(function () {
this.timeout(STARTUP_TIMEOUT) // Increase timeout for server reload
return cy.wrap(
reloadWith({
pro: true,
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
OVERLEAF_DISABLE_LINK_SHARING: 'true',
},
withDataDir: true,
}),
{ timeout: STARTUP_TIMEOUT }
)
})
it('should not display link sharing in the sharing modal', function () {
login('user@example.com')
openProjectByName(projectName, true)
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'Share' })
.click()
cy.findByRole('button', { name: 'Turn on link sharing' }).should(
'not.exist'
)
})
it('should block new access to read-only link shared projects', function () {
login(email)
// Test read-only link returns 404
cy.request({
url: linkSharingReadOnly,
failOnStatusCode: false,
}).then(response => {
expect(response.status).to.eq(404)
})
})
it('should block new access to read-write link shared projects', function () {
login(email)
// Test read-write link returns 404
cy.request({
url: linkSharingReadAndWrite,
failOnStatusCode: false,
}).then(response => {
expect(response.status).to.eq(404)
})
})
it('should continue to allow email sharing', function () {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaEmail(
projectName,
invitedEmail,
'Viewer',
true
)
expectFullReadOnlyAccess()
expectProjectDashboardEntry()
})
it('should retain read-only access when project was joined via link before link sharing was turned off', function () {
login(retainedViewerEmail)
openProjectByName(projectName, true)
expectRestrictedReadOnlyAccess()
expectProjectDashboardEntry()
})
it('should retain read-write access when project was joined via link before link sharing was turned off', function () {
login(retainedEditorEmail)
openProjectByName(projectName, true)
expectFullReadAndWriteAccess()
expectProjectDashboardEntry()
})
})
})
})

View File

@@ -0,0 +1,345 @@
import { ensureUserExists, login } from './helpers/login'
import { createProjectAndOpenInNewEditor } from './helpers/project'
import { isExcludedBySharding, startWith } from './helpers/config'
import { prepareWaitForNextCompileSlot, stopCompile } from './helpers/compile'
import { v4 as uuid } from 'uuid'
import { waitUntilScrollingFinished } from './helpers/waitUntilScrollingFinished'
const LABEL_TEX_LIVE_VERSION = 'TeX Live version'
describe('new editor.SandboxedCompiles', function () {
const enabledVars = {
SANDBOXED_COMPILES: 'true',
ALL_TEX_LIVE_DOCKER_IMAGE_NAMES: '2023,2022',
}
describe('enabled in Server Pro', function () {
if (isExcludedBySharding('PRO_CUSTOM_2')) return
startWith({
pro: true,
vars: enabledVars,
resetData: true,
})
ensureUserExists({ email: 'user@example.com' })
beforeEach(function () {
login('user@example.com')
})
it('should offer TexLive images and switch the compiler', function () {
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('sandboxed')
})
cy.log('wait for compile')
cy.findByRole('region', { name: 'PDF preview' }).should(
'contain.text',
'sandboxed'
)
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
cy.log('Check which compiler version was used, expect 2023')
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2023\) /)
})
cy.log('Switch TeXLive version from 2023 to 2022')
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('option', { name: '2023' }).should('be.selected')
cy.findByRole('combobox', {
name: LABEL_TEX_LIVE_VERSION,
}).select('2022')
})
cy.get('body').type('{esc}')
cy.findByRole('dialog').should('not.exist')
cy.log('Trigger compile with other TeX Live version')
recompile()
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
cy.log('Check which compiler version was used, expect 2022')
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2022\) /)
})
})
checkSyncTeX()
checkXeTeX()
checkRecompilesAfterErrors()
checkStopCompile()
})
function checkStopCompile() {
it('users can stop a running compile', function () {
login('user@example.com')
const { recompile, waitForCompile, waitForCompileRateLimitCoolOff } =
prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('test-project')
})
// create an infinite loop in the main document
// this will cause the compile to run indefinitely
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle')
.parent()
.type('\n\\def\\x{{}Hello!\\par\\x}\\x')
waitForCompileRateLimitCoolOff()
cy.log('Start compile')
// We need to start the compile manually because we do not want to wait for it to finish
cy.findByRole('button', { name: 'Recompile' }).click()
// Now stop the compile and kill the latex process
stopCompile({ delay: 1000 })
cy.findByRole('region', { name: 'PDF preview' })
.invoke('text')
.should('match', /PDF Rendering Error|Compilation cancelled/)
// Check that the previous compile is not running in the background by
// disabling the infinite loop and recompiling
cy.findByText('\\def').parent().click()
cy.findByText('\\def').parent().type('{home}disabled loop% ')
recompile()
cy.findByRole('region', { name: 'PDF preview' })
.should('contain.text', 'disabled loop')
.should('not.contain.text', 'A previous compile is still running')
})
}
function checkSyncTeX() {
describe('SyncTeX', function () {
let projectName: string
beforeEach(function () {
projectName = `Project ${uuid()}`
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor(projectName)
})
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(
() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle')
.parent()
.type(
`\n\\pagebreak\n\\section{{}Section A}\n\\pagebreak\n\\section{{}Section B}\n\\pagebreak`
)
}
)
recompile()
cy.log('wait for pdf-rendering')
cy.findByRole('region', { name: 'PDF preview' }).findByText(projectName)
})
it('should sync to code', function () {
cy.log('navigate to \\maketitle using double click in PDF')
cy.findByRole('region', { name: 'PDF preview' })
.findByText(projectName)
.dblclick()
cy.get('.cm-activeLine').should('have.text', '\\maketitle')
cy.log('navigate to Section A using double click in PDF')
cy.findByRole('region', { name: 'PDF preview' })
.findByText('Section A')
.dblclick()
cy.get('.cm-activeLine').should('have.text', '\\section{Section A}')
cy.log('navigate to Section B using arrow button')
cy.findByTestId('pdfjs-viewer-inner')
.should('have.prop', 'scrollTop')
.as('start')
cy.findByRole('region', { name: 'PDF preview' })
.findByText('Section B')
.scrollIntoView()
cy.get('@start').then((start: any) => {
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
start
)
})
// The sync button is swapped as the position in the PDF changes.
// Cypress appears to click on a button that references a stale position.
// Adding a cy.wait() statement is the most reliable "fix" so far :/
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(1000)
cy.findByRole('button', {
name: 'Go to PDF location in code (Tip: double click on the PDF for best results)',
}).click()
cy.get('.cm-activeLine').should('have.text', '\\section{Section B}')
})
it('should sync to pdf', function () {
cy.log('zoom in')
cy.findByRole('button', { name: 'PDF zoom level' }).click()
cy.findByRole('menuitem', { name: '400%' }).click()
cy.log('scroll to top')
cy.findByTestId('pdfjs-viewer-inner').scrollTo('top')
waitUntilScrollingFinished('[data-testid="pdfjs-viewer-inner"]', -1).as(
'start'
)
cy.log('navigate to title')
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(
() => {
cy.findByText('\\maketitle').parent().click()
}
)
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
cy.get('@start').then((start: any) => {
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
start
)
.as('title')
.should('be.greaterThan', start)
})
cy.log('navigate to Section A')
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() =>
cy.findByText('Section A').click()
)
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
cy.get('@title').then((title: any) => {
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
title
)
.as('sectionA')
.should('be.greaterThan', title)
})
cy.log('navigate to Section B')
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() =>
cy.findByText('Section B').click()
)
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
cy.get('@sectionA').then((title: any) => {
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
title
)
.as('sectionB')
.should('be.greaterThan', title)
})
})
})
}
function checkRecompilesAfterErrors() {
it('recompiles even if there are Latex errors', function () {
login('user@example.com')
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('test-project')
})
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle')
.parent()
.type('\n\\fakeCommand{} \n\\section{{}Test Section}')
})
recompile()
recompile()
cy.findByRole('region', { name: 'PDF preview' })
.findByText('Test Section')
.should('not.contain.text', 'No PDF')
})
}
function checkXeTeX() {
it('should be able to use XeLaTeX', function () {
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('XeLaTeX')
})
cy.log('wait for compile')
cy.findByRole('region', { name: 'PDF preview' }).findByText('XeLaTeX')
cy.log('Check which compiler was used, expect pdfLaTeX')
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
cy.log('Check which compiler was used, expect pdfLaTeX')
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByText(/This is pdfTeX/)
})
cy.log('Switch compiler to from pdfLaTeX to XeLaTeX')
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('option', { name: 'pdfLaTeX' }).should('be.selected')
cy.findByRole('combobox', {
name: 'Compiler',
}).select('XeLaTeX')
})
cy.get('body').type('{esc}')
cy.findByRole('dialog').should('not.exist')
cy.log('Trigger compile with other compiler')
recompile()
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
cy.log('Check which compiler was used, expect XeLaTeX')
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByText(/This is XeTeX/)
})
})
}
function checkUsesDefaultCompiler() {
beforeEach(function () {
login('user@example.com')
})
it('should not offer TexLive images and use default compiler', function () {
createProjectAndOpenInNewEditor('sandboxed')
cy.log('wait for compile')
cy.findByRole('region', { name: 'PDF preview' }).findByText('sandboxed')
cy.log('Check which compiler version was used, expect 2025')
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2025\) /)
})
cy.log('Check that there is no TeX Live version toggle')
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click()
cy.findByRole('dialog').within(() => {
cy.findByText('Main document').should('exist')
cy.findByText(LABEL_TEX_LIVE_VERSION).should('not.exist')
})
})
}
describe('disabled in Server Pro', function () {
if (isExcludedBySharding('PRO_DEFAULT_2')) return
startWith({ pro: true })
ensureUserExists({ email: 'user@example.com' })
beforeEach(function () {
login('user@example.com')
})
checkUsesDefaultCompiler()
checkSyncTeX()
checkXeTeX()
checkRecompilesAfterErrors()
checkStopCompile()
})
// https://github.com/overleaf/internal/issues/20216
// eslint-disable-next-line mocha/no-skipped-tests
describe.skip('unavailable in CE', function () {
if (isExcludedBySharding('CE_CUSTOM_1')) return
startWith({ pro: false, vars: enabledVars, resetData: true })
ensureUserExists({ email: 'user@example.com' })
beforeEach(function () {
login('user@example.com')
})
checkUsesDefaultCompiler()
checkSyncTeX()
checkXeTeX()
checkRecompilesAfterErrors()
checkStopCompile()
})
})

View File

@@ -0,0 +1,281 @@
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import {
createProjectAndOpenInNewEditor,
NEW_PROJECT_BUTTON_MATCHER,
redirectEditorUrlWithQueryParams,
} from './helpers/project'
const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com'
const ADMIN_USER = 'admin@example.com'
const REGULAR_USER = 'user@example.com'
const TEMPLATES_USER = 'templates@example.com'
// Re-use value for "exists" and "does not exist" tests
const LABEL_BROWSE_TEMPLATES = 'Browse templates'
describe('new editor.Templates', function () {
ensureUserExists({ email: TEMPLATES_USER })
ensureUserExists({ email: WITHOUT_PROJECTS_USER })
let OVERLEAF_TEMPLATES_USER_ID: string
before(function () {
login(TEMPLATES_USER)
cy.visit('/')
cy.get('meta[name="ol-user_id"]').then(el => {
OVERLEAF_TEMPLATES_USER_ID = el.attr('content')!
})
})
function varsFn() {
return {
OVERLEAF_TEMPLATES_USER_ID,
OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS:
'[{"name":"All Templates","url":"/templates/all"}]',
}
}
describe('enabled in Server Pro', function () {
if (isExcludedBySharding('PRO_CUSTOM_2')) return
startWith({
pro: true,
varsFn,
})
ensureUserExists({ email: REGULAR_USER })
ensureUserExists({ email: ADMIN_USER, isAdmin: true })
it('should show templates link on welcome page', function () {
login(WITHOUT_PROJECTS_USER)
cy.visit('/')
cy.findByRole('link', { name: LABEL_BROWSE_TEMPLATES }).click()
cy.url().should('match', /\/templates$/)
})
it('should have templates feature', function () {
login(TEMPLATES_USER)
const name = `Template ${Date.now()}`
const description = `Template Description ${Date.now()}`
cy.visit('/')
createProjectAndOpenInNewEditor(name, { type: 'Example project' }).as(
'templateProjectId'
)
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Manage template' }).click()
cy.findByLabelText('Template Description').type(description)
cy.findByRole('button', { name: 'Publish' }).click()
cy.findByRole('button', { name: 'Publishing…' }).should('be.disabled')
cy.findByRole('button', { name: 'Publish' }).should('not.exist')
cy.findByRole('button', { name: 'Unpublish', timeout: 60_000 })
cy.findByRole('button', { name: 'Republish' })
cy.findByRole('link', { name: 'View it in the template gallery' }).click()
cy.url()
.should('match', /\/templates\/[a-f0-9]{24}$/)
.as('templateURL')
cy.findByRole('heading', { level: 2 }).findByText(name)
cy.findByText(description)
cy.findByRole('link', { name: 'Open as Template' })
cy.findByRole('button', { name: 'Unpublish' })
cy.findByRole('button', { name: 'Republish' })
cy.get('img')
.should('have.attr', 'src')
.and('match', /\/v\/0\//)
cy.findByRole('button', { name: 'Republish' }).click()
cy.findByRole('button', { name: 'Publishing…' }).should('be.disabled')
cy.findByRole('button', { name: 'Republish', timeout: 60_000 })
cy.get('img', { timeout: 60_000 })
.should('have.attr', 'src')
.and('match', /\/v\/1\//)
// custom tag
const tagName = `${Date.now()}`
cy.visit('/')
cy.findByRole('checkbox', { name: `Select ${name}` }).check()
cy.findByRole('navigation', { name: 'Project categories and tags' })
.findByRole('button', { name: 'New tag' })
.click()
cy.focused().type(tagName)
cy.findByRole('button', { name: 'Create' }).click()
cy.findByRole('navigation', {
name: 'Project categories and tags',
}).should('contain', `${tagName} (1)`)
// Check listing
cy.visit('/templates')
cy.findByRole('link', { name: tagName })
cy.visit('/templates/all')
cy.findByRole('heading', { name })
cy.visit(`/templates/${tagName}`)
cy.findByRole('heading', { name })
// Unpublish via template page
cy.get('@templateURL').then(url => cy.visit(`${url}`))
cy.findByRole('button', { name: 'Unpublish' }).click()
cy.url().should('match', /\/templates$/)
cy.get('@templateURL').then(url =>
cy.visit(`${url}`, {
failOnStatusCode: false,
})
)
cy.findByRole('heading', { name: 'Not found' })
cy.visit('/templates/all')
cy.findByRole('heading', { name }).should('not.exist')
cy.visit(`/templates/${tagName}`)
cy.findByRole('heading', { name }).should('not.exist')
// Publish again
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Manage template' }).click()
cy.findByRole('button', { name: 'Publish' }).click()
cy.findByRole('button', { name: 'Unpublish', timeout: 60_000 })
// Should assign a new template id
cy.findByRole('link', { name: 'View it in the template gallery' }).click()
cy.url()
.should('match', /\/templates\/[a-f0-9]{24}$/)
.as('newTemplateURL')
cy.get('@newTemplateURL').then(newURL => {
cy.get('@templateURL').then(prevURL => {
expect(newURL).to.match(/\/templates\/[a-f0-9]{24}$/)
expect(prevURL).to.not.equal(newURL)
})
})
// Open project from template
login(REGULAR_USER)
cy.visit('/templates')
cy.findByRole('link', { name: tagName }).click()
cy.findByRole('link', { name }).click()
cy.findByRole('link', { name: 'Open as Template' }).click()
cy.findByRole('navigation', { name: 'Project actions' }).findByText(
/Your Paper/i
) // might have (1) suffix
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Word count' }).click() // wait for lazy loading
cy.findByRole('menuitem', { name: 'Manage template' }).should('not.exist')
// Check management as regular user
cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
cy.findByRole('link', { name: 'Open as Template' })
cy.findByRole('button', { name: 'Unpublish' }).should('not.exist')
cy.findByRole('button', { name: 'Republish' }).should('not.exist')
// Check management as admin user
login(ADMIN_USER)
redirectEditorUrlWithQueryParams(true)
cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
cy.findByRole('link', { name: 'Open as Template' })
cy.findByRole('button', { name: 'Unpublish' })
cy.findByRole('button', { name: 'Republish' })
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Manage template' }).click()
cy.findByRole('button', { name: 'Unpublish' })
// Back to templates user
login(TEMPLATES_USER)
// Unpublish via editor
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Manage template' }).click()
cy.findByRole('button', { name: 'Unpublish' }).click()
cy.findByRole('button', { name: 'Publish' })
cy.visit('/templates/all')
cy.findByRole('link', { name }).should('not.exist')
// check for template links, after creating the first project
cy.visit('/')
cy.findAllByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }).click()
cy.findByRole('menuitem', { name: /All Templates/ }).should(
'have.attr',
'href',
'/templates/all'
)
})
})
function checkDisabled() {
it('should not have templates feature', function () {
login(TEMPLATES_USER)
cy.visit('/')
createProjectAndOpenInNewEditor('maybe templates')
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Word count' }) // wait for lazy loading
cy.findByRole('menuitem', { name: 'Manage template' }).should('not.exist')
cy.visit('/templates', { failOnStatusCode: false })
cy.findByRole('heading', { name: 'Not found' })
cy.visit('/templates/all', { failOnStatusCode: false })
cy.findByRole('heading', { name: 'Not found' })
// check for template links, after creating the first project
cy.visit('/')
cy.findAllByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }).click()
cy.findByRole('menuitem', { name: /All Templates/ }).should('not.exist')
})
it('should not show templates link on welcome page', function () {
login(WITHOUT_PROJECTS_USER)
cy.visit('/')
cy.findByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }) // wait for lazy loading
cy.findByRole('link', { name: LABEL_BROWSE_TEMPLATES }).should(
'not.exist'
)
})
}
describe('disabled Server Pro', function () {
if (isExcludedBySharding('PRO_DEFAULT_2')) return
startWith({ pro: true })
checkDisabled()
})
describe('unavailable in CE', function () {
if (isExcludedBySharding('CE_CUSTOM_1')) return
startWith({
pro: false,
varsFn,
})
checkDisabled()
})
})

View File

@@ -1,6 +1,10 @@
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import { createProject, NEW_PROJECT_BUTTON_MATCHER } from './helpers/project'
import {
createProject,
NEW_PROJECT_BUTTON_MATCHER,
redirectEditorUrlWithQueryParams,
} from './helpers/project'
const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com'
const ADMIN_USER = 'admin@example.com'
@@ -126,6 +130,7 @@ describe('Templates', function () {
cy.findByRole('heading', { name }).should('not.exist')
// Publish again
redirectEditorUrlWithQueryParams(false)
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
@@ -155,6 +160,7 @@ describe('Templates', function () {
cy.visit('/templates')
cy.findByRole('link', { name: tagName }).click()
cy.findByRole('link', { name }).click()
redirectEditorUrlWithQueryParams(false)
cy.findByRole('link', { name: 'Open as Template' }).click()
cy.findByRole('navigation', { name: 'Project actions' }).findByText(
/Your Paper/i
@@ -179,6 +185,7 @@ describe('Templates', function () {
cy.findByRole('link', { name: 'Open as Template' })
cy.findByRole('button', { name: 'Unpublish' })
cy.findByRole('button', { name: 'Republish' })
redirectEditorUrlWithQueryParams(false)
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
@@ -194,6 +201,7 @@ describe('Templates', function () {
login(TEMPLATES_USER)
// Unpublish via editor
redirectEditorUrlWithQueryParams(false)
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)