From ceab650af811ae5e5bef5e0764daaf2d4d025722 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Thu, 27 Feb 2025 07:52:48 +0000 Subject: [PATCH] [server-pro] tests: backport changes from SaaS E2E tests (#23921) * [server-pro] tests: add helper for gitURL * [server-pro] tests: avoid hard-coding URL scheme/origin * [server-pro] tests: fix typo in query selector * [server-pro] tests: fix spelling of GitHub * [server-pro] tests: double down on matching email in body * [server-pro] tests: speed up session resumption * [server-pro] tests: use a single project in editor spec * [server-pro] drop check on started recompile The labels changed between versions and making it configurable is too verbose. GitOrigin-RevId: d1ace3b534f28c65b8e20c808bac12268f26fa4d --- server-ce/test/editor.spec.ts | 215 +++++++++++++++------------- server-ce/test/git-bridge.spec.ts | 23 +-- server-ce/test/helpers/compile.ts | 27 +++- server-ce/test/helpers/login.ts | 3 +- server-ce/test/helpers/project.ts | 71 ++++++++- server-ce/test/project-list.spec.ts | 15 +- 6 files changed, 220 insertions(+), 134 deletions(-) diff --git a/server-ce/test/editor.spec.ts b/server-ce/test/editor.spec.ts index 02f4f65735..13204c5b75 100644 --- a/server-ce/test/editor.spec.ts +++ b/server-ce/test/editor.spec.ts @@ -1,7 +1,17 @@ -import { createProject } from './helpers/project' +import { + createNewFile, + createProject, + enableLinkSharing, + openFile, + openProjectById, + openProjectViaLinkSharingAsUser, + toggleTrackChanges, +} 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' describe('editor', () => { if (isExcludedBySharding('PRO_DEFAULT_1')) return @@ -9,24 +19,32 @@ describe('editor', () => { ensureUserExists({ email: 'user@example.com' }) ensureUserExists({ email: 'collaborator@example.com' }) - it('word dictionary and spelling', () => { - const fileName = 'test.tex' - const word = createRandomLetterString() + let projectName: string + let projectId: string + let recompile: () => void + let waitForCompileRateLimitCoolOff: (fn: () => void) => void + beforeWithReRunOnTestRetry(function () { + projectName = `project-${uuid()}` login('user@example.com') - createProject('test-project') + createProject(projectName, { type: 'Example Project', open: false }).then( + id => (projectId = id) + ) + ;({ recompile, waitForCompileRateLimitCoolOff } = + prepareWaitForNextCompileSlot()) + }) - cy.log('create new project file') - cy.get('button').contains('New file').click({ force: true }) - cy.findByRole('dialog').within(() => { - cy.get('input').clear() - cy.get('input').type(fileName) - cy.findByText('Create').click() + beforeEach(() => { + login('user@example.com') + waitForCompileRateLimitCoolOff(() => { + openProjectById(projectId) }) - cy.findByText(fileName).click() + }) + + it('word dictionary and spelling', () => { + createNewFile() + const word = createRandomLetterString() cy.log('edit project file') - // wait until we've switched to the newly created empty file - cy.get('.cm-line').should('have.length', 1) cy.get('.cm-line').type(word) cy.get('.ol-cm-spelling-error').should('exist') @@ -44,7 +62,7 @@ describe('editor', () => { cy.log('remove word from dictionary') cy.get('button').contains('Menu').click() cy.get('button').contains('Edit').click() - cy.get('[id="dictionary-modal"').within(() => { + cy.get('[id="dictionary-modal"]').within(() => { cy.findByText(word) .parent() .within(() => cy.get('button').click()) @@ -64,92 +82,93 @@ describe('editor', () => { }) describe('collaboration', () => { - let projectId: string + beforeWithReRunOnTestRetry(function () { + enableLinkSharing().then(({ linkSharingReadAndWrite }) => { + const email = 'collaborator@example.com' + login(email) + openProjectViaLinkSharingAsUser( + linkSharingReadAndWrite, + projectName, + email + ) + }) - beforeEach(() => { login('user@example.com') - cy.visit(`/project`) - createProject('test-editor', { type: 'Example Project' }).then( - (id: string) => { - projectId = id - - cy.log('make project shareable') - cy.findByText('Share').click() - cy.findByText('Turn on link sharing').click() - - cy.log('accept project invitation') - cy.findByText('Anyone with this link can edit this project') - .next() - .should('contain.text', 'http://') // wait for the link to appear - .then(el => { - const linkSharingReadAndWrite = el.text() - login('collaborator@example.com') - cy.visit(linkSharingReadAndWrite) - cy.get('button').contains('OK, join project').click() - cy.log( - 'navigate to project dashboard to avoid cross session requests from editor' - ) - cy.visit('/project') - }) - - login('user@example.com') - cy.visit(`/project/${projectId}`) - } - ) + waitForCompileRateLimitCoolOff(() => { + openProjectById(projectId) + }) }) it('track-changes', () => { - cy.log('enable track-changes for everyone') - cy.findByText('Review').click() - cy.get('.review-panel-toolbar-collapse-button').click() // make track-changes switches visible + cy.log('disable track-changes before populating doc') + toggleTrackChanges(false) - cy.intercept('POST', '**/track_changes').as('enableTrackChanges') - cy.findByText('Everyone') - .parent() - .within(() => cy.get('.form-check-input').click()) - cy.wait('@enableTrackChanges') + const fileName = createNewFile() + const oldContent = 'oldContent' + cy.get('.cm-line').type(`static\n${oldContent}`) + + cy.log('recompile to force flush') + recompile() + + cy.log('enable track-changes for everyone') + toggleTrackChanges(true) login('collaborator@example.com') - cy.visit(`/project/${projectId}`) + waitForCompileRateLimitCoolOff(() => { + openProjectById(projectId) + }) + openFile(fileName, 'static') cy.log('make changes in main file') // cy.type() "clicks" in the center of the selected element before typing. This "click" discards the text as selected by the dblclick. // Go down to the lower level event based typing, the frontend tests in web use similar events. cy.get('.cm-editor').as('editor') - cy.get('@editor').findByText('\\maketitle').dblclick() + cy.get('@editor').findByText(oldContent).dblclick() cy.get('@editor').trigger('keydown', { key: 'Delete' }) cy.get('@editor').trigger('keydown', { key: 'Enter' }) cy.get('@editor').trigger('keydown', { key: 'Enter' }) cy.log('recompile to force flush') - cy.findByText('Recompile').click() + recompile() login('user@example.com') - cy.visit(`/project/${projectId}`) + waitForCompileRateLimitCoolOff(() => { + openProjectById(projectId) + }) + openFile(fileName, 'static') cy.log('reject changes') cy.findByText('Review').click() - cy.get('.cm-content').should('not.contain.text', '\\maketitle') + cy.get('.cm-content').should('not.contain.text', oldContent) cy.findByText('Reject').click({ force: true }) + cy.findByText('Review').click() cy.log('verify the changes are applied') - cy.get('.cm-content').should('contain.text', '\\maketitle') + cy.get('.cm-content').should('contain.text', oldContent) + + cy.log('disable track-changes for everyone again') + toggleTrackChanges(false) }) it('track-changes rich text', () => { - cy.log('enable track-changes for everyone') - cy.findByText('Visual Editor').click() - cy.findByText('Review').click() - cy.get('.review-panel-toolbar-collapse-button').click() // make track-changes switches visible + cy.log('disable track-changes before populating doc') + toggleTrackChanges(false) - cy.intercept('POST', '**/track_changes').as('enableTrackChanges') - cy.findByText('Everyone') - .parent() - .within(() => cy.get('.form-check-input').click()) - cy.wait('@enableTrackChanges') + const fileName = createNewFile() + const oldContent = 'oldContent' + cy.get('.cm-line').type(`static\n\\section{{}${oldContent}}`) + + cy.log('recompile to force flush') + recompile() + + cy.log('enable track-changes for everyone') + toggleTrackChanges(true) login('collaborator@example.com') - cy.visit(`/project/${projectId}`) + waitForCompileRateLimitCoolOff(() => { + openProjectById(projectId) + }) + openFile(fileName, 'static') cy.log('enable visual editor and make changes in main file') cy.findByText('Visual Editor').click() @@ -157,35 +176,36 @@ describe('editor', () => { // cy.type() "clicks" in the center of the selected element before typing. This "click" discards the text as selected by the dblclick. // Go down to the lower level event based typing, the frontend tests in web use similar events. cy.get('.cm-editor').as('editor') - cy.get('@editor').contains('Introduction').dblclick() + cy.get('@editor').contains(oldContent).dblclick() cy.get('@editor').trigger('keydown', { key: 'Delete' }) cy.get('@editor').trigger('keydown', { key: 'Enter' }) cy.get('@editor').trigger('keydown', { key: 'Enter' }) cy.log('recompile to force flush') - cy.findByText('Recompile').click() + recompile() login('user@example.com') - cy.visit(`/project/${projectId}`) + waitForCompileRateLimitCoolOff(() => { + openProjectById(projectId) + }) + openFile(fileName, 'static') cy.log('reject changes') cy.findByText('Review').click() - cy.get('.cm-content').should('not.contain.text', 'Introduction') + cy.get('.cm-content').should('not.contain.text', oldContent) cy.findAllByText('Reject').first().click({ force: true }) + cy.findByText('Review').click() cy.log('verify the changes are applied in the visual editor') cy.findByText('Visual Editor').click() - cy.get('.cm-content').should('contain.text', 'Introduction') + cy.get('.cm-content').should('contain.text', oldContent) + + cy.log('disable track-changes for everyone again') + toggleTrackChanges(false) }) }) describe('editor', () => { - beforeEach(() => { - login('user@example.com') - cy.visit(`/project`) - createProject(`project-${uuid()}`, { type: 'Example Project' }) - }) - it('renders jpg', () => { cy.findByTestId('file-tree').findByText('frog.jpg').click() cy.get('[alt="frog.jpg"]') @@ -195,39 +215,41 @@ describe('editor', () => { }) it('symbol palette', () => { + createNewFile() + cy.get('button[aria-label="Toggle Symbol Palette"]').click({ force: true, }) cy.get('button').contains('𝜉').click() cy.get('.cm-content').should('contain.text', '\\xi') + + cy.log('recompile to force flush and avoid "unsaved changes" prompt') + recompile() }) }) describe('add new file to project', () => { - let projectName: string - beforeEach(() => { - projectName = `project-${uuid()}` - login('user@example.com') - cy.visit(`/project`) - createProject(projectName) cy.get('button').contains('New file').click({ force: true }) }) it('can upload file', () => { + const name = `${uuid()}.txt` + const content = `Test File Content ${name}` cy.get('button').contains('Upload').click({ force: true }) cy.get('input[type=file]') .first() .selectFile( { - contents: Cypress.Buffer.from('Test File Content'), - fileName: 'file.txt', + contents: Cypress.Buffer.from(content), + fileName: name, lastModified: Date.now(), }, { force: true } ) - cy.findByTestId('file-tree').findByText('file.txt').click({ force: true }) - cy.findByText('Test File Content') + // force: The file-tree pane is too narrow to display the full name. + cy.findByTestId('file-tree').findByText(name).click({ force: true }) + cy.findByText(content) }) it('should not display import from URL', () => { @@ -236,13 +258,7 @@ describe('editor', () => { }) describe('left menu', () => { - let projectName: string - beforeEach(() => { - projectName = `project-${uuid()}` - login('user@example.com') - cy.visit(`/project`) - createProject(projectName, { type: 'Example Project' }) cy.get('button').contains('Menu').click() }) @@ -288,13 +304,6 @@ describe('editor', () => { }) describe('layout selector', () => { - let projectId: string - beforeEach(() => { - login('user@example.com') - cy.visit(`/project`) - createProject(`project-${uuid()}`, { type: 'Example Project' }) - }) - it('show editor only and switch between editor and pdf', () => { cy.get('.pdf-viewer').should('be.visible') cy.get('.cm-editor').should('be.visible') diff --git a/server-ce/test/git-bridge.spec.ts b/server-ce/test/git-bridge.spec.ts index ea7aea3e5b..010b8ccf74 100644 --- a/server-ce/test/git-bridge.spec.ts +++ b/server-ce/test/git-bridge.spec.ts @@ -22,7 +22,12 @@ describe('git-bridge', function () { V1_HISTORY_URL: 'http://sharelatex:3100/api', } - const gitBridgePublicHost = new URL(Cypress.config().baseUrl!).host + 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 @@ -85,9 +90,7 @@ describe('git-bridge', function () { cy.findByText('Git').click() cy.findByTestId('git-bridge-modal').within(() => { cy.get('@projectId').then(id => { - cy.get('code').contains( - `git clone http://git@${gitBridgePublicHost}/git/${id}` - ) + cy.get('code').contains(`git clone ${gitURL(id.toString())}`) }) cy.findByRole('button', { name: 'Generate token', @@ -101,9 +104,7 @@ describe('git-bridge', function () { cy.findByText('Git').click() cy.findByTestId('git-bridge-modal').within(() => { cy.get('@projectId').then(id => { - cy.get('code').contains( - `git clone http://git@${gitBridgePublicHost}/git/${id}` - ) + cy.get('code').contains(`git clone ${gitURL(id.toString())}`) }) cy.findByText('Generate token').should('not.exist') cy.findByText(/generate a new one in Account Settings/) @@ -192,9 +193,7 @@ describe('git-bridge', function () { cy.findByText('Git').click() cy.get('@projectId').then(projectId => { cy.findByTestId('git-bridge-modal').within(() => { - cy.get('code').contains( - `git clone http://git@${gitBridgePublicHost}/git/${projectId}` - ) + cy.get('code').contains(`git clone ${gitURL(projectId.toString())}`) }) cy.findByRole('button', { name: 'Generate token', @@ -234,9 +233,11 @@ describe('git-bridge', function () { dir, fs, } + const url = gitURL(projectId.toString()) + url.username = '' // basic auth is specified separately. const httpOptions = { http, - url: `http://sharelatex/git/${projectId}`, + url: url.toString(), headers: { Authorization: `Basic ${Buffer.from(`git:${token}`).toString('base64')}`, }, diff --git a/server-ce/test/helpers/compile.ts b/server-ce/test/helpers/compile.ts index e65b36f332..066fc4f9d3 100644 --- a/server-ce/test/helpers/compile.ts +++ b/server-ce/test/helpers/compile.ts @@ -4,22 +4,37 @@ * This helper takes into account that other UI interactions take time. We can deduce that latency from the fixed delay (3s minus other latency). This can bring down the effective waiting time to 0s. */ export function throttledRecompile() { + const { queueReset, recompile } = prepareWaitForNextCompileSlot() + queueReset() + return recompile +} + +export function prepareWaitForNextCompileSlot() { let lastCompile = 0 function queueReset() { cy.then(() => { lastCompile = Date.now() }) } - - queueReset() - return () => + function waitForCompileRateLimitCoolOff(triggerCompile: () => void) { cy.then(() => { - cy.log('Recompile without hitting rate-limit') + cy.log('Wait for recompile rate-limit to cool off') const msSinceLastCompile = Date.now() - lastCompile cy.wait(Math.max(0, 1_000 - msSinceLastCompile)) - cy.findByText('Recompile').click() queueReset() - cy.log('Wait for recompile to finish') + triggerCompile() + cy.log('Wait for compile to finish') cy.findByText('Recompile') }) + } + function recompile() { + waitForCompileRateLimitCoolOff(() => { + cy.findByText('Recompile').click() + }) + } + return { + queueReset, + waitForCompileRateLimitCoolOff, + recompile, + } } diff --git a/server-ce/test/helpers/login.ts b/server-ce/test/helpers/login.ts index 1883e6da09..fa95abec1d 100644 --- a/server-ce/test/helpers/login.ts +++ b/server-ce/test/helpers/login.ts @@ -68,7 +68,8 @@ export function login(username: string, password = DEFAULT_PASSWORD) { { cacheAcrossSpecs: true, async validate() { - cy.request({ url: '/project', followRedirect: false }).then( + // Hit a cheap endpoint that is behind AuthenticationController.requireLogin(). + cy.request({ url: '/user/personal_info', followRedirect: false }).then( response => { expect(response.status).to.equal(200) } diff --git a/server-ce/test/helpers/project.ts b/server-ce/test/helpers/project.ts index 09fdeca125..7291873401 100644 --- a/server-ce/test/helpers/project.ts +++ b/server-ce/test/helpers/project.ts @@ -62,6 +62,11 @@ export function openProjectByName(projectName: string) { waitForMainDocToLoad() } +export function openProjectById(projectId: string) { + cy.visit(`/project/${projectId}`) + waitForMainDocToLoad() +} + export function openProjectViaLinkSharingAsAnon(url: string) { cy.visit(url) waitForMainDocToLoad() @@ -74,7 +79,7 @@ export function openProjectViaLinkSharingAsUser( ) { cy.visit(url) cy.findByText(projectName) // wait for lazy loading - cy.findByText(email) + cy.contains(`as ${email}`) cy.findByText('OK, join project').click() waitForMainDocToLoad() } @@ -147,6 +152,7 @@ export function shareProjectByEmailAndAcceptInviteViaEmail( export function enableLinkSharing() { let linkSharingReadOnly: string let linkSharingReadAndWrite: string + const origin = new URL(Cypress.config().baseUrl!).origin waitForMainDocToLoad() @@ -154,13 +160,13 @@ export function enableLinkSharing() { cy.findByText('Turn on link sharing').click() cy.findByText('Anyone with this link can view this project') .next() - .should('contain.text', 'http://sharelatex/') + .should('contain.text', origin + '/read') .then(el => { linkSharingReadOnly = el.text() }) cy.findByText('Anyone with this link can edit this project') .next() - .should('contain.text', 'http://sharelatex/') + .should('contain.text', origin + '/') .then(el => { linkSharingReadAndWrite = el.text() }) @@ -174,3 +180,62 @@ export function waitForMainDocToLoad() { cy.log('Wait for main doc to load; it will steal the focus after loading') cy.get('.cm-content').should('contain.text', 'Introduction') } + +export function openFile(fileName: string, waitFor: string) { + // force: The file-tree pane is too narrow to display the full name. + cy.findByTestId('file-tree').findByText(fileName).click({ force: true }) + cy.findByText(waitFor) +} + +export function createNewFile() { + const fileName = `${uuid()}.tex` + + cy.log('create new project file') + cy.get('button').contains('New file').click({ force: true }) + cy.findByRole('dialog').within(() => { + cy.get('input').clear() + cy.get('input').type(fileName) + cy.findByText('Create').click() + }) + // force: The file-tree pane is too narrow to display the full name. + cy.findByTestId('file-tree').findByText(fileName).click({ force: true }) + + // wait until we've switched to the newly created empty file + cy.get('.cm-line').should('have.length', 1) + + return fileName +} + +export function toggleTrackChanges(state: boolean) { + cy.findByText('Review').click() + cy.get('.rp-tc-state-collapse').then(el => { + // TODO: simplify this in the frontend? + if (el.hasClass('rp-tc-state-collapse-on')) { + // make track-changes switches visible + cy.get('.rp-tc-state-collapse').click() + } + }) + + cy.findByText('Everyone') + .parent() + .within(() => { + cy.get('.form-check-input').then(el => { + if (el.prop('checked') === state) return + + const id = uuid() + const alias = `@${id}` + cy.intercept({ + method: 'POST', + url: '**/track_changes', + times: 1, + }).as(id) + if (state) { + cy.get('.form-check-input').check() + } else { + cy.get('.form-check-input').uncheck() + } + cy.wait(alias) + }) + }) + cy.findByText('Review').click() +} diff --git a/server-ce/test/project-list.spec.ts b/server-ce/test/project-list.spec.ts index 49e50bd350..3056242a0f 100644 --- a/server-ce/test/project-list.spec.ts +++ b/server-ce/test/project-list.spec.ts @@ -18,11 +18,11 @@ describe('Project List', () => { describe('user with no projects', () => { ensureUserExists({ email: WITHOUT_PROJECTS_USER }) - it("'Import from Github' is not displayed in the welcome page", () => { + it("'Import from GitHub' is not displayed in the welcome page", () => { login(WITHOUT_PROJECTS_USER) cy.visit('/project') cy.findByText('Create a new project').click() - cy.findByText(/Import from Github/i).should('not.exist') + cy.findByText(/Import from GitHub/i).should('not.exist') }) }) @@ -34,11 +34,12 @@ describe('Project List', () => { login(REGULAR_USER) createProject(projectName, { type: 'Example Project', open: false }) }) - - it('Can download project sources', () => { + beforeEach(function () { login(REGULAR_USER) cy.visit('/project') + }) + it('Can download project sources', () => { findProjectRow(projectName).within(() => cy.findByRole('button', { name: 'Download .zip file' }).click() ) @@ -50,9 +51,6 @@ describe('Project List', () => { }) it('Can download project PDF', () => { - login(REGULAR_USER) - cy.visit('/project') - findProjectRow(projectName).within(() => cy.findByRole('button', { name: 'Download PDF' }).click() ) @@ -66,9 +64,6 @@ describe('Project List', () => { it('can assign and remove tags to projects', () => { const tagName = uuid().slice(0, 7) // long tag names are truncated in the UI, which affects selectors - login(REGULAR_USER) - cy.visit('/project') - cy.log('select project') cy.get(`[aria-label="Select ${projectName}"]`).click()