Files
overleaf-cep/server-ce/test/helpers/project.ts
Alexandre Bourdin 77b9d2fd1b Merge pull request #32959 from overleaf/ab-fix-32861-spam-safe-project-name
[saas-e2e] Fix project name generation still producing + and / characters

GitOrigin-RevId: 2367c8d45ee9ec4441e9ee2a1d74c2fd281f9e90
2026-04-22 08:06:04 +00:00

403 lines
11 KiB
TypeScript

import { login } from './login'
import { openEmail } from './email'
import { v4 as uuid } from 'uuid'
import { prepareWaitForNextCompileSlot } from './compile'
export const NEW_PROJECT_BUTTON_MATCHER = /new project/i
const NEW_EDITOR_QUERY_PARAMS = ''
const OLD_EDITOR_QUERY_PARAMS = '?old-editor-override=true'
export function redirectEditorUrlWithQueryParams(newEditor: boolean) {
const queryString = newEditor
? NEW_EDITOR_QUERY_PARAMS
: OLD_EDITOR_QUERY_PARAMS
cy.intercept(
{ method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
req => {
// Intercept redirect and add the query param to use the new editor
req.redirect(`${req.url}${queryString}`)
}
)
}
export function createProject(
name: string,
{
type = 'Blank project',
newProjectButtonMatcher = NEW_PROJECT_BUTTON_MATCHER,
open = true,
newEditor = false,
}: {
type?: 'Blank project' | 'Example project'
newProjectButtonMatcher?: RegExp
open?: boolean
newEditor?: boolean
} = {}
): Cypress.Chainable<string> {
cy.url().then(url => {
if (!url.endsWith('/project')) {
cy.visit('/project')
}
})
const interceptId = uuid()
let projectId = ''
if (!open) {
cy.then(() => {
// Register intercept just before creating the project, otherwise we might
// intercept a request from a prior createProject invocation.
cy.intercept(
{ method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
req => {
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
// The upgrading tests create projects in older versions of Server Pro which used different casing of the project type. Use case-insensitive match.
cy.findAllByText(type, { exact: false }).first().click()
cy.findByRole('dialog').within(() => {
cy.get('input').type(name)
cy.findByRole('button', { name: 'Create' }).click()
})
if (open) {
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
waitForMainDocToLoad()
return cy
.url()
.should('match', /\/project\/[a-fA-F0-9]{24}/)
.then(url => url.split('/').pop()?.split('?')[0])
} else {
const alias = `@${interceptId}` // IDEs do not like computed values in cy.wait().
cy.wait(alias)
return cy.then(() => projectId)
}
}
// 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, 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,
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,
newEditor: boolean = false
) {
cy.visit('/project')
cy.findByText(projectName)
.parent()
.parent()
.within(() => {
cy.findByText('Join Project').click()
})
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',
newEditor: boolean = false
) {
openProjectByName(projectName, newEditor)
cy.findByRole('button', { name: 'Share' }).click()
cy.findByRole('dialog').within(() => {
cy.findByLabelText('Add email address', { selector: 'input' }).type(
`${email},`
)
cy.findByLabelText('Add email address', { selector: 'input' })
.parents('form')
.within(() => {
cy.findByTestId('add-collaborator-select').as('select').click()
cy.get('@select').then(() => {
cy.findByRole('option', { name: level }).click()
})
})
cy.findByRole('button', { name: 'Invite' }).click()
cy.findByText('Invite not yet accepted.')
})
}
export function shareProjectByEmailAndAcceptInviteViaDash(
projectName: string,
email: string,
level: 'Viewer' | 'Editor',
newEditor: boolean = false
) {
shareProjectByEmail(projectName, email, level, newEditor)
login(email)
openProjectViaInviteNotification(projectName, newEditor)
}
export function getSpamSafeProjectName() {
while (true) {
// Move from hex/16 to base64/64 possible characters per char in string
const name = Buffer.from(uuid().replaceAll('-', ''), 'hex')
.toString('base64')
.replaceAll('/', 'x')
.replaceAll('+', 'y')
.slice(0, 10)
const nDigits = (name.match(/\d/g) || []).length
if (nDigits < 6) return name
}
}
export function shareProjectByEmailAndAcceptInviteViaEmail(
projectName: string,
email: string,
level: 'Viewer' | 'Editor',
newEditor: boolean = false
) {
shareProjectByEmail(projectName, email, level, newEditor)
login(email)
openEmail(projectName, frame => {
frame.contains('View project').then(a => {
cy.log(
'bypass target=_blank and navigate current browser tab/cypress-iframe to project invite'
)
cy.visit(a.attr('href')!)
})
})
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() {
let linkSharingReadOnly: string
let linkSharingReadAndWrite: string
const origin = new URL(Cypress.config().baseUrl!).origin
waitForMainDocToLoad()
cy.findByText('Share').click()
cy.findByText('Turn on link sharing').click()
cy.findByText('Anyone with this link can view this project')
.next()
.should('contain.text', origin + '/read')
.then(el => {
linkSharingReadOnly = el.text()
})
cy.findByText('Anyone with this link can edit this project')
.next()
.should('contain.text', origin + '/')
.then(el => {
linkSharingReadAndWrite = el.text()
})
return cy.then(() => {
return { linkSharingReadOnly, linkSharingReadAndWrite }
})
}
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.findByRole('navigation', { name: 'Project files and outline' })
.findByRole('treeitem', { name: fileName })
.click({ force: true })
// wait until we've switched to the selected file
cy.findByRole('status').should('not.exist')
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')
.findByRole('treeitem', { name: fileName })
.click({ force: true })
// wait until we've switched to the newly created empty file
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
cy.findByRole('status').should('not.exist')
cy.get('.cm-line').should('have.length', 1)
})
cy.get('.cm-line').should('have.length', 1)
return fileName
}
export function expectFileExists(
name: string,
binary: boolean,
content: string
) {
cy.findByRole('treeitem', { name }).click()
if (binary) {
cy.findByText(content).should('not.have.class', 'cm-line')
} else {
cy.findByText(content).should('have.class', 'cm-line')
}
}
export function prepareFileUploadTest(binary = false) {
const name = `${uuid()}.txt`
const content = `Test File Content ${name}${binary ? ' \x00' : ''}`
cy.get('button').contains('Upload').click({ force: true })
cy.get('input[type=file]')
.first()
.selectFile(
{
contents: Cypress.Buffer.from(content),
fileName: name,
lastModified: Date.now(),
},
{ force: true }
)
// wait for the upload to finish
cy.findByRole('treeitem', { name })
return () => expectFileExists(name, binary, content)
}
export function testNewFileUpload() {
it('can upload text file', function () {
const check = prepareFileUploadTest(false)
check()
})
it('can upload binary file', function () {
const check = prepareFileUploadTest(true)
check()
})
}