diff --git a/server-ce/test/graceful-shutdown.spec.ts b/server-ce/test/graceful-shutdown.spec.ts index 066e06bd86..6284cb243d 100644 --- a/server-ce/test/graceful-shutdown.spec.ts +++ b/server-ce/test/graceful-shutdown.spec.ts @@ -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') diff --git a/server-ce/test/helpers/project.ts b/server-ce/test/helpers/project.ts index 8fd635c921..eab0685613 100644 --- a/server-ce/test/helpers/project.ts +++ b/server-ce/test/helpers/project.ts @@ -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 { 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() { diff --git a/server-ce/test/new-editor-create-and-compile-project.spec.ts b/server-ce/test/new-editor-create-and-compile-project.spec.ts new file mode 100644 index 0000000000..6ea586e168 --- /dev/null +++ b/server-ce/test/new-editor-create-and-compile-project.spec.ts @@ -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}`) + }) + }) + }) +}) diff --git a/server-ce/test/new-editor-editor.spec.ts b/server-ce/test/new-editor-editor.spec.ts new file mode 100644 index 0000000000..a26df70370 --- /dev/null +++ b/server-ce/test/new-editor-editor.spec.ts @@ -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 +} diff --git a/server-ce/test/new-editor-git-bridge.spec.ts b/server-ce/test/new-editor-git-bridge.spec.ts new file mode 100644 index 0000000000..aa30a989b0 --- /dev/null +++ b/server-ce/test/new-editor-git-bridge.spec.ts @@ -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 { + 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((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() + }) +}) diff --git a/server-ce/test/new-editor-graceful-shutdown.spec.ts b/server-ce/test/new-editor-graceful-shutdown.spec.ts new file mode 100644 index 0000000000..a7c759c5de --- /dev/null +++ b/server-ce/test/new-editor-graceful-shutdown.spec.ts @@ -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 + 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}/) + }) +}) diff --git a/server-ce/test/new-editor-history.spec.ts b/server-ce/test/new-editor-history.spec.ts new file mode 100644 index 0000000000..110cb2370c --- /dev/null +++ b/server-ce/test/new-editor-history.spec.ts @@ -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') + }) +}) diff --git a/server-ce/test/new-editor-project-sharing.spec.ts b/server-ce/test/new-editor-project-sharing.spec.ts new file mode 100644 index 0000000000..54f76bbb84 --- /dev/null +++ b/server-ce/test/new-editor-project-sharing.spec.ts @@ -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() + }) + }) + }) +}) diff --git a/server-ce/test/new-editor-sandboxed-compiles.spec.ts b/server-ce/test/new-editor-sandboxed-compiles.spec.ts new file mode 100644 index 0000000000..4e3ad779fd --- /dev/null +++ b/server-ce/test/new-editor-sandboxed-compiles.spec.ts @@ -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() + }) +}) diff --git a/server-ce/test/new-editor-templates.spec.ts b/server-ce/test/new-editor-templates.spec.ts new file mode 100644 index 0000000000..ad6c3e6f07 --- /dev/null +++ b/server-ce/test/new-editor-templates.spec.ts @@ -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() + }) +}) diff --git a/server-ce/test/templates.spec.ts b/server-ce/test/templates.spec.ts index f1804fca87..5493f1fa67 100644 --- a/server-ce/test/templates.spec.ts +++ b/server-ce/test/templates.spec.ts @@ -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}`) ) diff --git a/services/web/frontend/js/features/ide-redesign/components/rail/rail.tsx b/services/web/frontend/js/features/ide-redesign/components/rail/rail.tsx index 0ea5921842..b23fceb346 100644 --- a/services/web/frontend/js/features/ide-redesign/components/rail/rail.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/rail/rail.tsx @@ -59,6 +59,8 @@ export const RailLayout = () => { const { selectedTab, openTab, isOpen, togglePane } = useRailContext() const { features } = useProjectContext() const { isRestrictedTokenMember } = useEditorContext() + const gitBridgeEnabled = getMeta('ol-gitBridgeEnabled') + const { isOverleaf } = getMeta('ol-ExposedSettings') const { view, setLeftMenuShown } = useLayoutContext() @@ -93,6 +95,7 @@ export const RailLayout = () => { icon: 'integration_instructions', title: t('integrations'), component: , + hide: !isOverleaf && !gitBridgeEnabled, }, { key: 'review-panel', @@ -114,7 +117,14 @@ export const RailLayout = () => { }, ...moduleRailEntries, ], - [t, features.trackChangesVisible, view, isRestrictedTokenMember] + [ + t, + features.trackChangesVisible, + view, + isRestrictedTokenMember, + isOverleaf, + gitBridgeEnabled, + ] ) const railActions: RailAction[] = useMemo( diff --git a/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/new-editor-setting.tsx b/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/new-editor-setting.tsx index f1b3ad8a2b..c2042d16be 100644 --- a/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/new-editor-setting.tsx +++ b/services/web/frontend/js/features/ide-redesign/components/settings/editor-settings/new-editor-setting.tsx @@ -5,8 +5,10 @@ import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor- import { useCallback } from 'react' import { useLayoutContext } from '@/shared/context/layout-context' import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' +import getMeta from '@/utils/meta' export default function NewEditorSetting() { + const { isOverleaf } = getMeta('ol-ExposedSettings') const { t } = useTranslation() const { setEditorRedesignStatus } = useSwitchEnableNewEditorState() const { setLeftMenuShown } = useLayoutContext() @@ -25,13 +27,15 @@ export default function NewEditorSetting() { description={ <>
{t('the_new_overleaf_editor_info')}
- - {t('share_feedback_on_the_new_editor')} - + {isOverleaf && ( + + {t('share_feedback_on_the_new_editor')} + + )} } checked={enabled} diff --git a/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts b/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts index 3a7ff510ba..e43e269bba 100644 --- a/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts +++ b/services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts @@ -1,17 +1,13 @@ import { useFeatureFlag } from '@/shared/context/split-test-context' import { useUserSettingsContext } from '@/shared/context/user-settings-context' -import getMeta from '@/utils/meta' // For e2e tests purposes, allow overriding to old editor export const oldEditorOverride = new URLSearchParams(window.location.search).get('old-editor-override') === 'true' -// We don't want to enable the new editor on server-pro/CE until we have fully rolled it out on SaaS -const { isOverleaf } = getMeta('ol-ExposedSettings') - export const canUseNewEditor = () => { - return isOverleaf && !oldEditorOverride + return !oldEditorOverride } export const useIsNewEditorEnabled = () => {