[server-ce] tests: migrate host-admin to ESM, zod and npm-workspaces (#28838)

* [server-ce] tests: migrate host-admin to ESM, zod and npm-workspaces

* [server-ce] test: use import.meta.dirname

Co-authored-by: Eric Mc Sween <eric.mcsween@overleaf.com>

* [server-ce] test: fix zod schema for docker compose endpoint

---------

Co-authored-by: Eric Mc Sween <eric.mcsween@overleaf.com>
GitOrigin-RevId: d490948693f341210c0ab5c2947db7c9a17775ef
This commit is contained in:
Jakob Ackermann
2025-10-06 17:22:35 +02:00
committed by Copybot
parent c621d0f318
commit e03ca5a3a8
10 changed files with 248 additions and 4242 deletions

41
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"workspaces": [ "workspaces": [
"jobs/mirror-documentation", "jobs/mirror-documentation",
"libraries/*", "libraries/*",
"server-ce/test",
"services/analytics", "services/analytics",
"services/chat", "services/chat",
"services/clsi", "services/clsi",
@@ -14838,6 +14839,10 @@
"resolved": "tools/saas-e2e", "resolved": "tools/saas-e2e",
"link": true "link": true
}, },
"node_modules/@overleaf/server-ce-test": {
"resolved": "server-ce/test",
"link": true
},
"node_modules/@overleaf/settings": { "node_modules/@overleaf/settings": {
"resolved": "libraries/settings", "resolved": "libraries/settings",
"link": true "link": true
@@ -50695,6 +50700,42 @@
} }
} }
}, },
"server-ce/test": {
"name": "@overleaf/server-ce-test",
"devDependencies": {
"@isomorphic-git/lightning-fs": "^4.6.0",
"@overleaf/validation-tools": "*",
"@testing-library/cypress": "^10.0.3",
"@types/adm-zip": "^0.5.7",
"@types/pdf-parse": "^1.1.5",
"@types/uuid": "^9.0.8",
"adm-zip": "^0.5.12",
"body-parser": "^1.20.3",
"cypress": "13.13.2",
"cypress-multi-reporters": "^2.0.5",
"express": "^4.21.2",
"isomorphic-git": "^1.33.1",
"js-yaml": "^4.1.0",
"mocha-junit-reporter": "^2.2.1",
"pdf-parse": "^1.1.1",
"uuid": "^9.0.1",
"zod-validation-error": "^4.0.1"
}
},
"server-ce/test/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"services/analytics": { "services/analytics": {
"name": "@overleaf/analytics", "name": "@overleaf/analytics",
"dependencies": { "dependencies": {

View File

@@ -52,6 +52,7 @@
"workspaces": [ "workspaces": [
"jobs/mirror-documentation", "jobs/mirror-documentation",
"libraries/*", "libraries/*",
"server-ce/test",
"services/analytics", "services/analytics",
"services/chat", "services/chat",
"services/clsi", "services/clsi",

View File

@@ -64,8 +64,7 @@ pipeline {
parallel { parallel {
stage('Install deps') { stage('Install deps') {
steps { steps {
sh 'make install -j10' sh 'make monorepo_setup'
sh 'make -C server-ce/test npm_install_in_docker'
script { script {
job_npm_install_done = true job_npm_install_done = true
} }
@@ -85,9 +84,7 @@ pipeline {
return job_npm_install_done return job_npm_install_done
} }
} }
dir('server-ce/test') { sh 'bin/run -w /overleaf/server-ce/test monorepo npm run format'
sh 'make format_in_docker'
}
} }
} }
stage('Copybara') { stage('Copybara') {

View File

@@ -5,6 +5,7 @@ all: test-e2e
# We need to have both file-system layouts agree on the path for the docker compose project. # We need to have both file-system layouts agree on the path for the docker compose project.
# Notable the container labels com.docker.compose.project.working_dir and com.docker.compose.project.config_files need to match when creating containers from the docker host (how you started things) and from host-admin (how tests reconfigure the instance). # Notable the container labels com.docker.compose.project.working_dir and com.docker.compose.project.config_files need to match when creating containers from the docker host (how you started things) and from host-admin (how tests reconfigure the instance).
export PWD = $(shell pwd) export PWD = $(shell pwd)
export MONOREPO = $(shell cd ../../ && pwd)
export TEX_LIVE_DOCKER_IMAGE ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1 export TEX_LIVE_DOCKER_IMAGE ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1
export ALL_TEX_LIVE_DOCKER_IMAGES ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1,us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2022.1 export ALL_TEX_LIVE_DOCKER_IMAGES ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1,us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2022.1
@@ -96,14 +97,4 @@ build_mailtrap:
git clone https://github.com/dbck/docker-mailtrap.git || true && cd docker-mailtrap && git checkout v1.5.0 git clone https://github.com/dbck/docker-mailtrap.git || true && cd docker-mailtrap && git checkout v1.5.0
docker build -t mailtrap docker-mailtrap/build docker build -t mailtrap docker-mailtrap/build
npm_install_in_docker: export COMPOSE_PROJECT_NAME=
npm_install_in_docker:
$(MAKE) -C ../../ .metadata/docker-image/monorepo
cd ../../ && bin/run --no-deps --workdir /overleaf/server-ce/test monorepo npm --no-dry-run install
format_in_docker: export COMPOSE_PROJECT_NAME=
format_in_docker:
$(MAKE) -C ../../ .metadata/docker-image/monorepo
cd ../../ && bin/run --no-deps --workdir /overleaf/server-ce/test monorepo npm run format
.PHONY: test-e2e test-e2e-open .PHONY: test-e2e test-e2e-open

View File

@@ -1,6 +1,6 @@
const { defineConfig } = require('cypress') import { defineConfig } from 'cypress'
const { readPdf, readFileInZip } = require('./helpers/read-file') import { readFileInZip, readPdf } from './helpers/read-file'
const fs = require('node:fs') import fs from 'node:fs'
if (process.env.CYPRESS_SHARD && !process.env.SPEC_PATTERN) { if (process.env.CYPRESS_SHARD && !process.env.SPEC_PATTERN) {
// Running Cypress on all the specs is wasteful (~1min) when only few of them // Running Cypress on all the specs is wasteful (~1min) when only few of them
@@ -36,20 +36,19 @@ const specPattern = process.env.SPEC_PATTERN || './**/*.spec.ts'
let reporterOptions = {} let reporterOptions = {}
if (process.env.CI) { if (process.env.CI) {
reporterOptions = { reporterOptions = {
reporter: '/overleaf/server-ce/test/node_modules/cypress-multi-reporters', reporter: `${process.env.MONOREPO}/node_modules/cypress-multi-reporters`,
reporterOptions: { reporterOptions: {
configFile: 'cypress/cypress-multi-reporters.json', configFile: 'cypress/cypress-multi-reporters.json',
}, },
} }
} }
module.exports = defineConfig({ export default defineConfig({
defaultCommandTimeout: 10_000, defaultCommandTimeout: 10_000,
fixturesFolder: 'cypress/fixtures', fixturesFolder: 'cypress/fixtures',
video: process.env.CYPRESS_VIDEO === 'true', video: process.env.CYPRESS_VIDEO === 'true',
screenshotsFolder: 'cypress/results', screenshotsFolder: 'cypress/results',
videosFolder: 'cypress/results', videosFolder: 'cypress/results',
videoUploadOnPasses: false,
viewportHeight: 768, viewportHeight: 768,
viewportWidth: 1024, viewportWidth: 1024,
e2e: { e2e: {

View File

@@ -70,11 +70,15 @@ services:
stop_grace_period: 0s stop_grace_period: 0s
entrypoint: npm entrypoint: npm
command: run cypress:run command: run cypress:run
working_dir: /overleaf/server-ce/test # See comment in Makefile regarding matching file paths
working_dir: $PWD
volumes: volumes:
- ./:/overleaf/server-ce/test - $PWD:$PWD
- $MONOREPO/libraries:$MONOREPO/libraries:ro
- $MONOREPO/node_modules:$MONOREPO/node_modules:ro
user: "${DOCKER_USER:-1000:1000}" user: "${DOCKER_USER:-1000:1000}"
environment: environment:
MONOREPO:
CYPRESS_SHARD: CYPRESS_SHARD:
CYPRESS_BASE_URL: http://sharelatex CYPRESS_BASE_URL: http://sharelatex
CYPRESS_FULL_FILESTORE_MIGRATION: CYPRESS_FULL_FILESTORE_MIGRATION:
@@ -101,11 +105,14 @@ services:
working_dir: $PWD working_dir: $PWD
volumes: volumes:
- $PWD:$PWD - $PWD:$PWD
- $MONOREPO/libraries:$MONOREPO/libraries:ro
- $MONOREPO/node_modules:$MONOREPO/node_modules:ro
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
stop_grace_period: 0s stop_grace_period: 0s
environment: environment:
CI: CI:
PWD: PWD:
MONOREPO:
CYPRESS_SHARD: CYPRESS_SHARD:
COMPOSE_PROJECT_NAME: COMPOSE_PROJECT_NAME:
TEX_LIVE_DOCKER_IMAGE: TEX_LIVE_DOCKER_IMAGE:

View File

@@ -1,10 +1,9 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import pdf from 'pdf-parse' // @ts-ignore broken package entrypoint
import pdf from 'pdf-parse/lib/pdf-parse.js'
import AdmZip from 'adm-zip' import AdmZip from 'adm-zip'
import { promisify } from 'util' import { setTimeout } from 'timers/promises'
const sleep = promisify(setTimeout)
const MAX_ATTEMPTS = 15 const MAX_ATTEMPTS = 15
const POLL_INTERVAL = 500 const POLL_INTERVAL = 500
@@ -31,7 +30,7 @@ export async function readFileInZip({
throw new Error(`${fileToRead} not found in ${pathToZip}`) throw new Error(`${fileToRead} not found in ${pathToZip}`)
} }
} }
await sleep(POLL_INTERVAL) await setTimeout(POLL_INTERVAL)
attempt++ attempt++
} }
throw new Error(`${pathToZip} not found`) throw new Error(`${pathToZip} not found`)
@@ -45,7 +44,7 @@ export async function readPdf(file: string) {
const { text } = await pdf(dataBuffer) const { text } = await pdf(dataBuffer)
return text return text
} }
await sleep(POLL_INTERVAL) await setTimeout(POLL_INTERVAL)
attempt++ attempt++
} }
throw new Error(`${file} not found`) throw new Error(`${file} not found`)

View File

@@ -1,17 +1,14 @@
const fs = require('fs') import fs from 'node:fs'
const Path = require('path') import Path from 'node:path'
const { execFile } = require('child_process') import { execFile } from 'node:child_process'
const express = require('express') import bodyParser from 'body-parser'
const bodyParser = require('body-parser') import express from 'express'
const { import YAML from 'js-yaml'
celebrate: validate, import { isZodErrorLike } from 'zod-validation-error'
Joi, import { ParamsError, validateReq, z } from '@overleaf/validation-tools'
errors: handleValidationErrors,
} = require('celebrate')
const YAML = require('js-yaml')
const DATA_DIR = Path.join( const DATA_DIR = Path.join(
__dirname, import.meta.dirname,
'data', 'data',
// Give each shard their own data dir. // Give each shard their own data dir.
process.env.CYPRESS_SHARD || 'default' process.env.CYPRESS_SHARD || 'default'
@@ -108,84 +105,80 @@ app.use((req, res, next) => {
next() next()
}) })
app.post( app.post('/run/script', (req, res) => {
'/run/script', const {
validate( body: { cwd, script, args, user, hasOverleafEnv },
{ } = validateReq(
body: { req,
cwd: Joi.string().required(), z.object({
script: Joi.string().required(), body: z.object({
args: Joi.array().items(Joi.string()), cwd: z.string(),
user: Joi.string().required(), script: z.string(),
hasOverleafEnv: Joi.boolean().required(), args: z.array(z.string()),
}, user: z.string(),
}, hasOverleafEnv: z.boolean(),
{ allowUnknown: false } }),
), })
(req, res) => { )
const { cwd, script, args, user, hasOverleafEnv } = req.body
const env = hasOverleafEnv const env = hasOverleafEnv
? 'source /etc/overleaf/env.sh || source /etc/sharelatex/env.sh' ? 'source /etc/overleaf/env.sh || source /etc/sharelatex/env.sh'
: 'true' : 'true'
runDockerCompose( runDockerCompose(
'exec', 'exec',
[ [
'--workdir', '--workdir',
`/overleaf/${cwd}`, `/overleaf/${cwd}`,
'sharelatex', 'sharelatex',
'bash', 'bash',
'-c', '-c',
`source /etc/container_environment.sh && ${env} && /sbin/setuser ${user} node ${script} ${args.map(a => JSON.stringify(a)).join(' ')}`, `source /etc/container_environment.sh && ${env} && /sbin/setuser ${user} node ${script} ${args.map(a => JSON.stringify(a)).join(' ')}`,
], ],
(error, stdout, stderr) => { (error, stdout, stderr) => {
res.json({ res.json({
error, error,
stdout, stdout,
stderr, stderr,
}) })
} }
) )
} })
)
app.post( app.post('/run/gruntTask', (req, res) => {
'/run/gruntTask', const {
validate( body: { task, args },
{ } = validateReq(
body: { req,
task: Joi.string().required(), z.object({
args: Joi.array().items(Joi.string()), body: z.object({
}, task: z.string(),
}, args: z.array(z.string()),
{ allowUnknown: false } }),
), })
(req, res) => { )
const { task, args } = req.body
runDockerCompose( runDockerCompose(
'exec', 'exec',
[ [
'--workdir', '--workdir',
'/var/www/sharelatex', '/var/www/sharelatex',
'sharelatex', 'sharelatex',
'bash', 'bash',
'-c', '-c',
`source /etc/container_environment.sh && /sbin/setuser www-data grunt ${JSON.stringify(task)} ${args.map(a => JSON.stringify(a)).join(' ')}`, `source /etc/container_environment.sh && /sbin/setuser www-data grunt ${JSON.stringify(task)} ${args.map(a => JSON.stringify(a)).join(' ')}`,
], ],
(error, stdout, stderr) => { (error, stdout, stderr) => {
res.json({ res.json({
error, error,
stdout, stdout,
stderr, stderr,
}) })
} }
) )
} })
)
const allowedVars = Joi.object( const allowedVars = z.object(
Object.fromEntries( Object.fromEntries(
[ [
'OVERLEAF_APP_NAME', 'OVERLEAF_APP_NAME',
@@ -227,7 +220,7 @@ const allowedVars = Joi.object(
'SHARELATEX_SITE_URL', 'SHARELATEX_SITE_URL',
'SHARELATEX_MONGO_URL', 'SHARELATEX_MONGO_URL',
'SHARELATEX_REDIS_HOST', 'SHARELATEX_REDIS_HOST',
].map(name => [name, Joi.string()]) ].map(name => [name, z.string().optional()])
) )
) )
@@ -296,36 +289,37 @@ function setVarsDockerCompose({
writeDockerComposeOverride(cfg) writeDockerComposeOverride(cfg)
} }
app.post( app.post('/docker/compose/:cmd', (req, res) => {
'/docker/compose/:cmd', const {
validate( params: { cmd },
{ body: { args },
body: { } = validateReq(
args: Joi.array().allow( req,
'--detach', z.object({
'--wait', params: z.object({
'--volumes', cmd: z.literal(['up', 'stop', 'down', 'ps', 'logs']),
'--timeout=60', }),
'sharelatex', body: z.object({
'git-bridge', args: z.array(
'mongo', z.literal([
'redis' '--detach',
'--wait',
'--volumes',
'--timeout=60',
'sharelatex',
'git-bridge',
'mongo',
'redis',
])
), ),
}, }),
params: {
cmd: Joi.allow('up', 'stop', 'down', 'ps', 'logs'),
},
},
{ allowUnknown: false }
),
(req, res) => {
const { cmd } = req.params
const { args } = req.body
runDockerCompose(cmd, args, (error, stdout, stderr) => {
res.json({ error, stdout, stderr })
}) })
} )
)
runDockerCompose(cmd, args, (error, stdout, stderr) => {
res.json({ error, stdout, stderr })
})
})
function maybeResetData(resetData, callback) { function maybeResetData(resetData, callback) {
if (!resetData) return callback() if (!resetData) return callback()
@@ -347,88 +341,78 @@ function maybeResetData(resetData, callback) {
) )
} }
app.post( app.post('/reconfigure', (req, res) => {
'/reconfigure', const {
validate( body: { pro, version, vars, withDataDir, resetData, mongoVersion },
{ } = validateReq(
body: { req,
pro: Joi.boolean().required(), z.object({
mongoVersion: Joi.string().allow('').optional(), body: z.object({
version: Joi.string().required(), pro: z.boolean(),
version: z.string(),
vars: allowedVars, vars: allowedVars,
withDataDir: Joi.boolean().optional(), withDataDir: z.boolean(),
resetData: Joi.boolean().optional(), resetData: z.boolean(),
}, mongoVersion: z.string(),
}, }),
{ allowUnknown: false }
),
(req, res) => {
const { pro, version, vars, withDataDir, resetData, mongoVersion } =
req.body
maybeResetData(resetData, (error, stdout, stderr) => {
if (error) return res.json({ error, stdout, stderr })
const previousConfigServer = previousConfig
const newConfig = JSON.stringify(req.body)
if (previousConfig === newConfig) {
return res.json({ previousConfigServer })
}
try {
setVarsDockerCompose({ pro, version, vars, withDataDir, mongoVersion })
} catch (error) {
return res.json({ error })
}
if (error) return res.json({ error, stdout, stderr })
runDockerCompose(
'up',
['--detach', '--wait', 'sharelatex'],
(error, stdout, stderr) => {
previousConfig = newConfig
res.json({ error, stdout, stderr, previousConfigServer })
}
)
}) })
} )
) maybeResetData(resetData, (error, stdout, stderr) => {
if (error) return res.json({ error, stdout, stderr })
app.post( const previousConfigServer = previousConfig
'/mongo/setFeatureCompatibilityVersion', const newConfig = JSON.stringify(req.body)
validate( if (previousConfig === newConfig) {
{ return res.json({ previousConfigServer })
body: {
mongoVersion: Joi.string().required(),
},
},
{ allowUnknown: false }
),
(req, res) => {
const { mongoVersion } = req.body
const mongosh = mongoVersion > '5' ? 'mongosh' : 'mongo'
const params = {
setFeatureCompatibilityVersion: mongoVersion,
} }
if (mongoVersion >= '7.0') {
// MongoServerError: Once you have upgraded to 7.0, you will not be able to downgrade FCV and binary version without support assistance. Please re-run this command with 'confirm: true' to acknowledge this and continue with the FCV upgrade. try {
// NOTE: 6.0 does not know about this flag. So conditionally add it. setVarsDockerCompose({ pro, version, vars, withDataDir, mongoVersion })
// MongoServerError: BSON field 'setFeatureCompatibilityVersion.confirm' is an unknown field. } catch (error) {
params.confirm = true return res.json({ error })
} }
if (error) return res.json({ error, stdout, stderr })
runDockerCompose( runDockerCompose(
'exec', 'up',
[ ['--detach', '--wait', 'sharelatex'],
'mongo',
mongosh,
'--eval',
`db.adminCommand(${JSON.stringify(params)})`,
],
(error, stdout, stderr) => { (error, stdout, stderr) => {
res.json({ error, stdout, stderr }) previousConfig = newConfig
res.json({ error, stdout, stderr, previousConfigServer })
} }
) )
})
})
app.post('/mongo/setFeatureCompatibilityVersion', (req, res) => {
const {
body: { mongoVersion },
} = validateReq(
req,
z.object({
body: z.object({
mongoVersion: z.string(),
}),
})
)
const mongosh = mongoVersion > '5' ? 'mongosh' : 'mongo'
const params = {
setFeatureCompatibilityVersion: mongoVersion,
} }
) if (mongoVersion >= '7.0') {
// MongoServerError: Once you have upgraded to 7.0, you will not be able to downgrade FCV and binary version without support assistance. Please re-run this command with 'confirm: true' to acknowledge this and continue with the FCV upgrade.
// NOTE: 6.0 does not know about this flag. So conditionally add it.
// MongoServerError: BSON field 'setFeatureCompatibilityVersion.confirm' is an unknown field.
params.confirm = true
}
runDockerCompose(
'exec',
['mongo', mongosh, '--eval', `db.adminCommand(${JSON.stringify(params)})`],
(error, stdout, stderr) => {
res.json({ error, stdout, stderr })
}
)
})
app.get('/redis/keys', (req, res) => { app.get('/redis/keys', (req, res) => {
runDockerCompose( runDockerCompose(
@@ -450,7 +434,14 @@ app.delete('/data/user_files', (req, res) => {
) )
}) })
app.use(handleValidationErrors()) app.use((error, req, res, next) => {
if (error instanceof ParamsError) {
res.status(404).json({ error })
} else if (isZodErrorLike(error)) {
res.status(400).json({ error })
}
next(error)
})
purgeDataDir() purgeDataDir()
writeDockerComposeOverride(defaultDockerComposeOverride()) writeDockerComposeOverride(defaultDockerComposeOverride())

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,23 @@
{ {
"name": "@overleaf/server-ce/test", "name": "@overleaf/server-ce-test",
"description": "e2e tests for Overleaf Community Edition", "description": "e2e tests for Overleaf Community Edition",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"cypress:open": "cypress open --e2e --browser chrome", "cypress:open": "cypress open --e2e --browser chrome",
"cypress:run": "cypress run --e2e --browser chrome", "cypress:run": "cypress run --e2e --browser chrome",
"format": "prettier --list-different $PWD/'**/*.{js,mjs,ts,tsx,json}'", "format": "prettier --list-different $PWD/'**/*.{js,mjs,ts,tsx,json}'",
"format:fix": "prettier --write $PWD/'**/*.{js,mjs,ts,tsx,json}'" "format:fix": "prettier --write $PWD/'**/*.{js,mjs,ts,tsx,json}'"
}, },
"dependencies": { "devDependencies": {
"@isomorphic-git/lightning-fs": "^4.6.0", "@isomorphic-git/lightning-fs": "^4.6.0",
"@overleaf/validation-tools": "*",
"@testing-library/cypress": "^10.0.3", "@testing-library/cypress": "^10.0.3",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/pdf-parse": "^1.1.5", "@types/pdf-parse": "^1.1.5",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"adm-zip": "^0.5.12", "adm-zip": "^0.5.12",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"celebrate": "^15.0.3",
"cypress": "13.13.2", "cypress": "13.13.2",
"cypress-multi-reporters": "^2.0.5", "cypress-multi-reporters": "^2.0.5",
"express": "^4.21.2", "express": "^4.21.2",
@@ -24,7 +25,7 @@
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"mocha-junit-reporter": "^2.2.1", "mocha-junit-reporter": "^2.2.1",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"typescript": "^5.0.4", "uuid": "^9.0.1",
"uuid": "^9.0.1" "zod-validation-error": "^4.0.1"
} }
} }