mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
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:
committed by
Copybot
parent
422b474a17
commit
19545b35d8
@@ -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')
|
||||
|
||||
@@ -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() {
|
||||
|
||||
144
server-ce/test/new-editor-create-and-compile-project.spec.ts
Normal file
144
server-ce/test/new-editor-create-and-compile-project.spec.ts
Normal 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}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
290
server-ce/test/new-editor-editor.spec.ts
Normal file
290
server-ce/test/new-editor-editor.spec.ts
Normal 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
|
||||
}
|
||||
458
server-ce/test/new-editor-git-bridge.spec.ts
Normal file
458
server-ce/test/new-editor-git-bridge.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
109
server-ce/test/new-editor-graceful-shutdown.spec.ts
Normal file
109
server-ce/test/new-editor-graceful-shutdown.spec.ts
Normal 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}/)
|
||||
})
|
||||
})
|
||||
164
server-ce/test/new-editor-history.spec.ts
Normal file
164
server-ce/test/new-editor-history.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
534
server-ce/test/new-editor-project-sharing.spec.ts
Normal file
534
server-ce/test/new-editor-project-sharing.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
345
server-ce/test/new-editor-sandboxed-compiles.spec.ts
Normal file
345
server-ce/test/new-editor-sandboxed-compiles.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
281
server-ce/test/new-editor-templates.spec.ts
Normal file
281
server-ce/test/new-editor-templates.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user