first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
export function beforeWithReRunOnTestRetry(fn: () => void | Promise<any>) {
let ranOnce = false
beforeEach(() => {
if (ranOnce && Cypress.currentRetry === 0) return
ranOnce = true
return fn()
})
}

View File

@@ -0,0 +1,40 @@
/**
* Helper function for throttling clicks on the recompile button to avoid hitting server side rate limits.
* The naive approach is waiting a fixed a mount of time (3s) just before clicking the button.
* 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()
})
}
function waitForCompileRateLimitCoolOff(triggerCompile: () => void) {
cy.then(() => {
cy.log('Wait for recompile rate-limit to cool off')
const msSinceLastCompile = Date.now() - lastCompile
cy.wait(Math.max(0, 1_000 - msSinceLastCompile))
queueReset()
triggerCompile()
cy.log('Wait for compile to finish')
cy.findByText('Recompile').should('be.visible')
})
}
function recompile() {
waitForCompileRateLimitCoolOff(() => {
cy.findByText('Recompile').click()
})
}
return {
queueReset,
waitForCompileRateLimitCoolOff,
recompile,
}
}

View File

@@ -0,0 +1,64 @@
import { reconfigure } from './hostAdminClient'
import { resetActivateUserRateLimit, resetCreatedUsersCache } from './login'
export const STARTUP_TIMEOUT =
parseInt(Cypress.env('STARTUP_TIMEOUT'), 10) || 120_000
export function isExcludedBySharding(
shard:
| 'CE_DEFAULT'
| 'CE_CUSTOM_1'
| 'CE_CUSTOM_2'
| 'PRO_DEFAULT_1'
| 'PRO_DEFAULT_2'
| 'PRO_CUSTOM_1'
| 'PRO_CUSTOM_2'
| 'PRO_CUSTOM_3'
) {
const SHARD = Cypress.env('SHARD')
return SHARD && shard !== SHARD
}
let previousConfigFrontend: string
export function startWith({
pro = false,
version = 'latest',
vars = {},
varsFn = () => ({}),
withDataDir = false,
resetData = false,
}) {
before(async function () {
Object.assign(vars, varsFn())
const cfg = JSON.stringify({
pro,
version,
vars,
withDataDir,
resetData,
})
if (resetData) {
resetCreatedUsersCache()
resetActivateUserRateLimit()
// no return here, always reconfigure when resetting data
} else if (previousConfigFrontend === cfg) {
return
}
this.timeout(STARTUP_TIMEOUT)
const { previousConfigServer } = await reconfigure({
pro,
version,
vars,
withDataDir,
resetData,
})
if (previousConfigServer !== cfg) {
await Cypress.session.clearAllSavedSessions()
}
previousConfigFrontend = cfg
})
}
export { reconfigure }

View File

@@ -0,0 +1,39 @@
/**
* Helper function for opening an email in Roundcube based mailtrap.
* We need to cross an origin boundary, which complicates the use of variables.
* Any variables need to be explicitly defined and the "runner" may only reference these and none from its scope.
* It is not possible to use Cypress helper functions, e.g. from the testing library or other functions like "activateUser", inside the "runner".
* REF: https://github.com/testing-library/cypress-testing-library/issues/221
*/
export function openEmail<T>(
subject: string | RegExp,
runner: (frame: Cypress.Chainable<JQuery<any>>, args: T) => void,
args?: T
) {
const runnerS = runner.toString()
cy.origin(
Cypress.env('MAILTRAP_URL') || 'http://mailtrap',
{ args: { args, runnerS, subject } },
({ args, runnerS, subject }) => {
cy.visit('/')
cy.get('input[name="_user"]').type('mailtrap')
cy.get('input[name="_pass"]').type('password-for-mailtrap')
cy.get('button[type="submit"]').click()
cy.url().then(url => {
if (!url.includes('?_task=login')) return
cy.log('mailtrap login is flaky in cypress, submit again')
cy.get('input[name="_pass"]').type('password-for-mailtrap')
cy.get('button[type="submit"]').click()
})
// Use force as the subject is partially hidden
cy.contains(subject).click({ force: true })
cy.log('wait for iframe loading')
cy.wait(1000)
cy.get('iframe[id="messagecontframe"]').then(frame => {
// runnerS='(frame, args) => { runner body }'. Extract the runnable function.
const runner = new Function('return ' + runnerS)()
runner(cy.wrap(frame.prop('contentWindow').document.body), args)
})
}
)
}

View File

@@ -0,0 +1,92 @@
const hostAdminURL = Cypress.env('HOST_ADMIN_URL') || 'http://host-admin'
export async function dockerCompose(cmd: string, ...args: string[]) {
return await fetchJSON(`${hostAdminURL}/docker/compose/${cmd}`, {
method: 'POST',
body: JSON.stringify({
args,
}),
})
}
export async function reconfigure({
pro = false,
version = 'latest',
vars = {},
withDataDir = false,
resetData = false,
}): Promise<{ previousConfigServer: string }> {
return await fetchJSON(`${hostAdminURL}/reconfigure`, {
method: 'POST',
body: JSON.stringify({
pro,
version,
vars,
withDataDir,
resetData,
}),
})
}
async function fetchJSON<T = { stdout: string; stderr: string }>(
input: RequestInfo,
init?: RequestInit
): Promise<T> {
if (init?.body) {
init.headers = { 'Content-Type': 'application/json' }
}
let res
for (let attempt = 0; attempt < 5; attempt++) {
try {
res = await fetch(input, init)
break
} catch {
await sleep(3_000)
}
}
if (!res) {
res = await fetch(input, init)
}
const { error, stdout, stderr, ...rest } = await res.json()
if (error) {
console.error(input, init, 'failed:', error)
if (stdout) console.log(stdout)
if (stderr) console.warn(stderr)
const err = new Error(error.message)
Object.assign(err, error)
throw err
}
return { stdout, stderr, ...rest }
}
export async function runScript({
cwd,
script,
args = [],
}: {
cwd: string
script: string
args?: string[]
}) {
return await fetchJSON(`${hostAdminURL}/run/script`, {
method: 'POST',
body: JSON.stringify({
cwd,
script,
args,
}),
})
}
export async function getRedisKeys() {
const { stdout } = await fetchJSON(`${hostAdminURL}/redis/keys`, {
method: 'GET',
})
return stdout.split('\n')
}
async function sleep(ms: number) {
return new Promise(resolve => {
setTimeout(resolve, ms)
})
}

View File

@@ -0,0 +1,113 @@
import { runScript } from './hostAdminClient'
const DEFAULT_PASSWORD = 'Passw0rd!'
const createdUsers = new Set<string>()
export function resetCreatedUsersCache() {
createdUsers.clear()
}
export async function createMongoUser({
email,
isAdmin = false,
}: {
email: string
isAdmin?: boolean
}) {
const t0 = Math.floor(Date.now() / 1000)
const { stdout } = await runScript({
cwd: 'services/web',
script: 'modules/server-ce-scripts/scripts/create-user.js',
args: [`--email=${email}`, `--admin=${isAdmin}`],
})
const [url] = stdout.match(/http:\/\/.+\/user\/activate\?token=\S+/)!
const userId = new URL(url, location.origin).searchParams.get('user_id')!
const signupDate = parseInt(userId.slice(0, 8), 16)
if (signupDate < t0) {
return { url, exists: true }
}
return { url, exists: false }
}
export function ensureUserExists({
email,
password = DEFAULT_PASSWORD,
isAdmin = false,
}: {
email: string
password?: string
isAdmin?: boolean
}) {
let url: string
let exists: boolean
before(async function () {
exists = createdUsers.has(email)
if (exists) return
;({ url, exists } = await createMongoUser({ email, isAdmin }))
})
before(function () {
if (exists) return
activateUser(url, password)
cy.then(() => {
createdUsers.add(email)
})
})
}
export function login(username: string, password = DEFAULT_PASSWORD) {
cy.session(
[username, password],
() => {
cy.visit('/login')
cy.get('input[name="email"]').type(username)
cy.get('input[name="password"]').type(password)
cy.findByRole('button', { name: 'Login' }).click()
cy.url().should('contain', '/project')
},
{
cacheAcrossSpecs: true,
async validate() {
// 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)
}
)
},
}
)
}
let activateRateLimitState = { count: 0, reset: 0 }
export function resetActivateUserRateLimit() {
activateRateLimitState = { count: 0, reset: 0 }
}
function handleActivateUserRateLimit() {
cy.then(() => {
activateRateLimitState.count++
if (activateRateLimitState.reset < Date.now()) {
activateRateLimitState.reset = Date.now() + 65_000
activateRateLimitState.count = 1
} else if (activateRateLimitState.count >= 6) {
cy.wait(activateRateLimitState.reset - Date.now())
activateRateLimitState.count = 1
}
})
}
export function activateUser(url: string, password = DEFAULT_PASSWORD) {
handleActivateUserRateLimit()
cy.session(url, () => {
cy.visit(url)
cy.url().then(url => {
if (url.includes('/login')) return
cy.url().should('contain', '/user/activate')
cy.get('input[name="password"]').type(password)
cy.findByRole('button', { name: 'Activate' }).click()
cy.url().should('contain', '/project')
})
})
}

View File

@@ -0,0 +1,251 @@
import { login } from './login'
import { openEmail } from './email'
import { v4 as uuid } from 'uuid'
export function createProject(
name: string,
{
type = 'Blank Project',
newProjectButtonMatcher = /new project/i,
open = true,
}: {
type?: 'Blank Project' | 'Example Project'
newProjectButtonMatcher?: RegExp
open?: 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()!
// Redirect back to the project dashboard, effectively reload the page.
req.redirect('/project')
}
).as(interceptId)
})
}
cy.findAllByRole('button').contains(newProjectButtonMatcher).click()
// FIXME: This should only look in the left menu
cy.findAllByText(type).first().click()
cy.findByRole('dialog').within(() => {
cy.get('input').type(name)
cy.findByText('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())
} else {
const alias = `@${interceptId}` // IDEs do not like computed values in cy.wait().
cy.wait(alias)
return cy.then(() => projectId)
}
}
export function openProjectByName(projectName: string) {
cy.visit('/project')
cy.findByText(projectName).click()
waitForMainDocToLoad()
}
export function openProjectById(projectId: string) {
cy.visit(`/project/${projectId}`)
waitForMainDocToLoad()
}
export function openProjectViaLinkSharingAsAnon(url: string) {
cy.visit(url)
waitForMainDocToLoad()
}
export function openProjectViaLinkSharingAsUser(
url: string,
projectName: string,
email: string
) {
cy.visit(url)
cy.findByText(projectName) // wait for lazy loading
cy.contains(`as ${email}`)
cy.findByText('OK, join project').click()
waitForMainDocToLoad()
}
export function openProjectViaInviteNotification(projectName: string) {
cy.visit('/project')
cy.findByText(projectName)
.parent()
.parent()
.within(() => {
cy.findByText('Join Project').click()
})
cy.findByText('Open Project').click()
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
waitForMainDocToLoad()
}
function shareProjectByEmail(
projectName: string,
email: string,
level: 'Viewer' | 'Editor'
) {
openProjectByName(projectName)
cy.findByText('Share').click()
cy.findByRole('dialog').within(() => {
cy.findByLabelText('Add people', { selector: 'input' }).type(`${email},`)
cy.findByLabelText('Add people', { selector: 'input' })
.parents('form')
.within(() => {
cy.findByTestId('add-collaborator-select')
.click()
.then(() => {
cy.findByText(level).click()
})
})
cy.findByText('Invite').click({ force: true })
cy.findByText('Invite not yet accepted.')
})
}
export function shareProjectByEmailAndAcceptInviteViaDash(
projectName: string,
email: string,
level: 'Viewer' | 'Editor'
) {
shareProjectByEmail(projectName, email, level)
login(email)
openProjectViaInviteNotification(projectName)
}
export function shareProjectByEmailAndAcceptInviteViaEmail(
projectName: string,
email: string,
level: 'Viewer' | 'Editor'
) {
shareProjectByEmail(projectName, email, level)
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}`))
cy.findByText('Join Project').click()
waitForMainDocToLoad()
}
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.findByTestId('file-tree').findByText(fileName).click({ force: true })
// wait until we've switched to the selected file
cy.findByText('Loading…').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').findByText(fileName).click({ force: true })
// wait until we've switched to the newly created empty file
cy.findByText('Loading…').should('not.exist')
cy.get('.cm-line').should('have.length', 1)
return fileName
}
export function toggleTrackChanges(state: boolean) {
cy.findByText('Review').click()
cy.get('.track-changes-menu-button').then(el => {
// when the menu is expanded renders the `expand_more` icon,
// and the `chevron_right` icon when it's collapsed
if (!el.text().includes('expand_more')) {
el.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.contains('.toolbar-item', 'Review').click()
}

View File

@@ -0,0 +1,52 @@
import fs from 'fs'
import path from 'path'
import pdf from 'pdf-parse'
import AdmZip from 'adm-zip'
import { promisify } from 'util'
const sleep = promisify(setTimeout)
const MAX_ATTEMPTS = 15
const POLL_INTERVAL = 500
type ReadFileInZipArgs = {
pathToZip: string
fileToRead: string
}
export async function readFileInZip({
pathToZip,
fileToRead,
}: ReadFileInZipArgs) {
let attempt = 0
while (attempt < MAX_ATTEMPTS) {
if (fs.existsSync(pathToZip)) {
const zip = new AdmZip(path.resolve(pathToZip))
const entry = zip
.getEntries()
.find(entry => entry.entryName == fileToRead)
if (entry) {
return entry.getData().toString('utf8')
} else {
throw new Error(`${fileToRead} not found in ${pathToZip}`)
}
}
await sleep(POLL_INTERVAL)
attempt++
}
throw new Error(`${pathToZip} not found`)
}
export async function readPdf(file: string) {
let attempt = 0
while (attempt < MAX_ATTEMPTS) {
if (fs.existsSync(file)) {
const dataBuffer = fs.readFileSync(path.resolve(file))
const { text } = await pdf(dataBuffer)
return text
}
await sleep(POLL_INTERVAL)
attempt++
}
throw new Error(`${file} not found`)
}

View File

@@ -0,0 +1,39 @@
export function waitUntilScrollingFinished(selector: string, start = -1) {
const pollSlow = 100
const pollFast = 10
const deadline =
performance.now() + Cypress.config('defaultCommandTimeout') - pollSlow * 2
return cy.get(selector).then(el => {
cy.log(
`waiting until scrolling finished for ${selector}, starting from ${start}`
)
return new Cypress.Promise<number>((resolve, reject) => {
const waitForStable = (prev: number, stableFor: number) => {
if (performance.now() > deadline) {
return reject(new Error('timeout waiting for scrolling to finish'))
}
const current = el.scrollTop()!
if (current !== prev) {
setTimeout(() => waitForStable(current, 0), pollFast)
} else if (stableFor < 5) {
setTimeout(() => waitForStable(current, stableFor + 1), pollFast)
} else {
resolve(current)
}
}
const waitForChange = () => {
if (performance.now() > deadline) {
return reject(new Error('timeout waiting for scrolling to start'))
}
const current = el.scrollTop()!
if (current === start) {
setTimeout(() => waitForChange(), pollSlow)
} else {
setTimeout(() => waitForStable(current, 0), pollFast)
}
}
waitForChange()
})
})
}