[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
This commit is contained in:
Jakob Ackermann
2025-02-27 07:52:48 +00:00
committed by Copybot
parent 4645d38eb1
commit ceab650af8
6 changed files with 220 additions and 134 deletions

View File

@@ -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')

View File

@@ -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')}`,
},

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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()
}

View File

@@ -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()