[web] optimize process notifications and add monitoring metrics (#34699)

GitOrigin-RevId: 411355cbdbb25d871b9cfb2afbb0ad235041337f
This commit is contained in:
Kristina
2026-09-17 11:12:59 +00:00
committed by Copybot
parent 28ad3b03b7
commit 4622848a19
453 changed files with 0 additions and 43080 deletions
-25
View File
@@ -1,25 +0,0 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[Makefile]
indent_style = tab
[*.go]
indent_style = tab
[*.{pug,coffee}]
indent_style = tab
[*.{pug,patch}]
trim_trailing_whitespace = false
[Jenkinsfile]
insert_final_newline = false
max_line_length = off
-3
View File
@@ -1,3 +0,0 @@
/compiles/*
!.gitkeep
.env
-76
View File
@@ -1,76 +0,0 @@
# Overleaf Community Edition, development environment
## Building and running
In this `develop` directory, build the services:
```shell
bin/build
```
> [!NOTE]
> If Docker is running out of RAM while building the services in parallel, create a `.env` file in this directory containing `COMPOSE_PARALLEL_LIMIT=1`.
Then start the services:
```shell
bin/up
```
Once the services are running, open <http://localhost/launchpad> to create the first admin account.
## Development
To avoid running `bin/build && bin/up` after every code change, you can run Overleaf
Community Edition in _development mode_, where services will automatically update on code changes.
To do this, use the included `bin/dev` script:
```shell
bin/dev
```
This will start all services using `node --watch`, which will automatically monitor the code and restart the services as necessary.
To improve performance, you can start only a subset of the services in development mode by providing a space-separated list to the `bin/dev` script:
```shell
bin/dev [service1] [service2] ... [serviceN]
```
> [!NOTE]
> Starting the `web` service in _development mode_ will only update the `web`
> service when backend code changes. In order to automatically update frontend
> code as well, make sure to start the `webpack` service in _development mode_
> as well.
If no services are named, all services will start in development mode.
## Debugging
When run in _development mode_ most services expose a debugging port to which
you can attach a debugger such as
[the inspector in Chrome's Dev Tools](chrome://inspect/) or one integrated into
an IDE. The following table shows the port exposed on the **host machine** for
each service:
| Service | Port |
| ------------------ | ---- |
| `web` | 9229 |
| `clsi` | 9230 |
| `chat` | 9231 |
| `docstore` | 9233 |
| `document-updater` | 9234 |
| `filestore` | 9235 |
| `notifications` | 9236 |
| `real-time` | 9237 |
| `history-v1` | 9239 |
| `project-history` | 9240 |
To attach to a service using Chrome's _remote debugging_, go to
<chrome://inspect/> and make sure _Discover network targets_ is checked. Next
click _Configure..._ and add an entry `localhost:[service port]` for each of the
services you want to attach a debugger to.
After adding an entry, the service will show up as a _Remote Target_ that you
can inspect and debug.
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
docker compose build --pull "$@"
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --no-deps --detach "$@"
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
docker compose down "$@"
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bash
docker compose logs --follow --tail 10 --no-color "$@" \
| ggrep --line-buffered --invert-match "global.gc" \
| ggrep --line-buffered --invert-match "health.check" \
| ggrep --line-buffered --invert-match "slow event loop" \
| ggrep --line-buffered --invert-match "process.memoryUsage" \
| ggrep --line-buffered --only-matching "[{].*" \
| bunyan --output short
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
docker compose exec -it "$@" /bin/bash
-3
View File
@@ -1,3 +0,0 @@
#!/usr/bin/env bash
docker compose up --detach "$@"
View File
-24
View File
@@ -1,24 +0,0 @@
CHAT_HOST=chat
CLSI_HOST=clsi
DOWNLOAD_HOST=clsi-nginx
DOCSTORE_HOST=docstore
DOCUMENT_UPDATER_HOST=document-updater
FILESTORE_HOST=filestore
GRACEFUL_SHUTDOWN_DELAY_SECONDS=0
HISTORY_V1_HOST=history-v1
HISTORY_REDIS_HOST=redis
LISTEN_ADDRESS=0.0.0.0
MONGO_HOST=mongo
MONGO_URL=mongodb://mongo/sharelatex?directConnection=true
NOTIFICATIONS_HOST=notifications
PROJECT_HISTORY_HOST=project-history
QUEUES_REDIS_HOST=redis
DSMP_REDIS_HOST=redis
REALTIME_HOST=real-time
REDIS_HOST=redis
SESSION_SECRET=foo
V1_HISTORY_HOST=history-v1
WEBPACK_HOST=webpack
WEB_API_PASSWORD=overleaf
WEB_API_USER=overleaf
WEB_HOST=web
-128
View File
@@ -1,128 +0,0 @@
services:
clsi:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9230:9229"
volumes:
- ../services/clsi/app:/overleaf/services/clsi/app
- ../services/clsi/app.js:/overleaf/services/clsi/app.js
- ../services/clsi/config:/overleaf/services/clsi/config
chat:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9231:9229"
volumes:
- ../services/chat/app:/overleaf/services/chat/app
- ../services/chat/app.js:/overleaf/services/chat/app.js
- ../services/chat/config:/overleaf/services/chat/config
docstore:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9233:9229"
volumes:
- ../services/docstore/app:/overleaf/services/docstore/app
- ../services/docstore/app.js:/overleaf/services/docstore/app.js
- ../services/docstore/config:/overleaf/services/docstore/config
document-updater:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9234:9229"
volumes:
- ../services/document-updater/app:/overleaf/services/document-updater/app
- ../services/document-updater/app.js:/overleaf/services/document-updater/app.js
- ../services/document-updater/config:/overleaf/services/document-updater/config
filestore:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9235:9229"
volumes:
- ../services/filestore/app:/overleaf/services/filestore/app
- ../services/filestore/app.js:/overleaf/services/filestore/app.js
- ../services/filestore/config:/overleaf/services/filestore/config
history-v1:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9239:9229"
volumes:
- ../services/history-v1/api:/overleaf/services/history-v1/api
- ../services/history-v1/app.js:/overleaf/services/history-v1/app.js
- ../services/history-v1/config:/overleaf/services/history-v1/config
- ../services/history-v1/storage:/overleaf/services/history-v1/storage
- ../services/history-v1/knexfile.js:/overleaf/services/history-v1/knexfile.js
- ../services/history-v1/migrations:/overleaf/services/history-v1/migrations
notifications:
command: ["node", "--watch", "app.ts"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9236:9229"
volumes:
- ../services/notifications/app:/overleaf/services/notifications/app
- ../services/notifications/app.ts:/overleaf/services/notifications/app.ts
- ../services/notifications/config:/overleaf/services/notifications/config
project-history:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9240:9229"
volumes:
- ../services/project-history/app:/overleaf/services/project-history/app
- ../services/project-history/app.js:/overleaf/services/project-history/app.js
- ../services/project-history/config:/overleaf/services/project-history/config
real-time:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9237:9229"
volumes:
- ../services/real-time/app:/overleaf/services/real-time/app
- ../services/real-time/app.js:/overleaf/services/real-time/app.js
- ../services/real-time/config:/overleaf/services/real-time/config
web:
command: ["node", "--watch", "app.mjs", "--watch-locales"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9229:9229"
volumes:
- ../services/web/app:/overleaf/services/web/app
- ../services/web/app.mjs:/overleaf/services/web/app.mjs
- ../services/web/config:/overleaf/services/web/config
- ../services/web/locales:/overleaf/services/web/locales
- ../services/web/modules:/overleaf/services/web/modules
- ../services/web/public:/overleaf/services/web/public
webpack:
volumes:
- ../services/web/app:/overleaf/services/web/app
- ../services/web/config:/overleaf/services/web/config
- ../services/web/frontend:/overleaf/services/web/frontend
- ../services/web/locales:/overleaf/services/web/locales
- ../services/web/modules:/overleaf/services/web/modules
- ../services/web/public:/overleaf/services/web/public
- ../services/web/transform:/overleaf/services/web/transform
- ../services/web/types:/overleaf/services/web/types
- ../services/web/webpack-plugins:/overleaf/services/web/webpack-plugins
-175
View File
@@ -1,175 +0,0 @@
volumes:
clsi-cache:
filestore-public-files:
filestore-template-files:
filestore-uploads:
filestore-user-files:
mongo-data:
redis-data:
sharelatex-data:
web-data:
history-v1-buckets:
services:
chat:
build:
context: ..
dockerfile: services/chat/Dockerfile
env_file:
- dev.env
clsi:
build:
context: ..
dockerfile: services/clsi/Dockerfile
target: with-texlive
env_file:
- dev.env
environment:
- SANDBOXED_COMPILES=false
user: root
volumes:
- ${PWD}/compiles:/overleaf/services/clsi/compiles
- ${PWD}/output:/overleaf/services/clsi/output
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
- clsi-cache:/overleaf/services/clsi/cache
clsi-nginx:
image: nginx:1.28
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /run
volumes:
- ${PWD}/output:/output:ro
- ../services/clsi/nginx.conf:/etc/nginx/conf.d/nginx.conf:ro
docstore:
build:
context: ..
dockerfile: services/docstore/Dockerfile
env_file:
- dev.env
document-updater:
build:
context: ..
dockerfile: services/document-updater/Dockerfile
env_file:
- dev.env
filestore:
build:
context: ..
dockerfile: services/filestore/Dockerfile
env_file:
- dev.env
# environment:
# - ENABLE_CONVERSIONS=true
volumes:
- filestore-public-files:/overleaf/services/filestore/public_files
- filestore-template-files:/overleaf/services/filestore/template_files
- filestore-uploads:/overleaf/services/filestore/uploads
history-v1:
build:
context: ..
dockerfile: services/history-v1/Dockerfile
env_file:
- dev.env
environment:
OVERLEAF_EDITOR_ANALYTICS_BUCKET: "/buckets/analytics"
OVERLEAF_EDITOR_BLOBS_BUCKET: "/buckets/blobs"
OVERLEAF_EDITOR_CHUNKS_BUCKET: "/buckets/chunks"
OVERLEAF_EDITOR_PROJECT_BLOBS_BUCKET: "/buckets/project_blobs"
OVERLEAF_EDITOR_ZIPS_BUCKET: "/buckets/zips"
PERSISTOR_BACKEND: fs
volumes:
- history-v1-buckets:/buckets
mongo:
image: mongo:8
command: --replSet overleaf
ports:
- "127.0.0.1:27017:27017" # for debugging
volumes:
- mongo-data:/data/db
- ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1
notifications:
build:
context: ..
dockerfile: services/notifications/Dockerfile
env_file:
- dev.env
project-history:
build:
context: ..
dockerfile: services/project-history/Dockerfile
env_file:
- dev.env
real-time:
build:
context: ..
dockerfile: services/real-time/Dockerfile
env_file:
- dev.env
redis:
image: redis:7
ports:
- "127.0.0.1:6379:6379" # for debugging
volumes:
- redis-data:/data
web:
build:
context: ..
dockerfile: services/web/Dockerfile
target: dev
env_file:
- dev.env
environment:
- APP_NAME=Overleaf Community Edition
- ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
- EMAIL_CONFIRMATION_DISABLED=true
- NODE_ENV=development
- OVERLEAF_ALLOW_PUBLIC_ACCESS=true
command: ["node", "app.mjs"]
volumes:
- sharelatex-data:/var/lib/overleaf
- web-data:/overleaf/services/web/data
depends_on:
- mongo
- redis
- chat
- clsi
- docstore
- document-updater
- filestore
- history-v1
- notifications
- project-history
- real-time
webpack:
build:
context: ..
dockerfile: services/web/Dockerfile
target: webpack
command:
["npx", "webpack", "serve", "--config", "webpack.config.dev-env.js"]
ports:
- "127.0.0.1:80:3808"
volumes:
- ./webpack.config.dev-env.js:/overleaf/services/web/webpack.config.dev-env.js
-23
View File
@@ -1,23 +0,0 @@
const { merge } = require('webpack-merge')
const base = require('./webpack.config.dev')
module.exports = merge(base, {
devServer: {
allowedHosts: 'auto',
devMiddleware: {
index: false,
},
proxy: [
{
context: '/socket.io/**',
target: 'http://real-time:3026',
ws: true,
},
{
context: ['!**/*.js', '!**/*.css', '!**/*.json'],
target: 'http://web:3000',
},
],
},
})
-18
View File
@@ -1,18 +0,0 @@
FROM cypress/included:15.12.0
ARG USER_UID=1000
ARG USER_GID=1000
# Corepack setup, shared between all the images.
ENV PATH="/overleaf/node_modules/.bin:$PATH"
ENV COREPACK_HOME=/opt/corepack
RUN corepack enable && corepack install -g yarn@4.14.1
ENV COREPACK_ENABLE_NETWORK=0
WORKDIR /overleaf
RUN sed -i s/node:x:1000:/node:x:${USER_GID}:/ /etc/group \
&& sed -i s_node:x:1000:1000::/home/node:/bin/bash_node:x:${USER_UID}:${USER_GID}::/home/node:/bin/bash_ /etc/passwd \
&& chown -R node:node /home/node \
&& chown node:node /overleaf
USER node
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/access-token-encryptor monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/access-token-encryptor
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/access-token-encryptor
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/access-token-encryptor
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
@@ -1,9 +0,0 @@
access-token-encryptor
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--public-repo=False
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/fetch-utils monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/fetch-utils
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/fetch-utils
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/fetch-utils
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
-9
View File
@@ -1,9 +0,0 @@
fetch-utils
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--public-repo=False
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/logger monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/logger
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/logger
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/logger
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
-9
View File
@@ -1,9 +0,0 @@
logger
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--public-repo=False
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/metrics monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/metrics
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/metrics
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/metrics
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
-9
View File
@@ -1,9 +0,0 @@
metrics
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--public-repo=False
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/mongo-utils monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/mongo-utils
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/mongo-utils
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/mongo-utils
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
-10
View File
@@ -1,10 +0,0 @@
mongo-utils
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--public-repo=False
--tsconfig-no-implicit-any=True
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/o-error monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/o-error
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/o-error
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/o-error
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
-9
View File
@@ -1,9 +0,0 @@
o-error
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--public-repo=False
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/object-persistor monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/object-persistor
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/object-persistor
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/object-persistor
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
@@ -1,10 +0,0 @@
object-persistor
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--public-repo=False
--tsconfig-no-implicit-any=True
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/overleaf-editor-core monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/overleaf-editor-core
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/overleaf-editor-core
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/overleaf-editor-core
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
@@ -1,9 +0,0 @@
overleaf-editor-core
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=44
--public-repo=False
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/promise-utils monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/promise-utils
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/promise-utils
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/promise-utils
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
-9
View File
@@ -1,9 +0,0 @@
promise-utils
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--public-repo=False
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/ranges-tracker monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/ranges-tracker
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/ranges-tracker
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/ranges-tracker
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
-9
View File
@@ -1,9 +0,0 @@
ranges-tracker
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=44
--public-repo=False
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/redis-wrapper monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/redis-wrapper
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/redis-wrapper
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/redis-wrapper
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
-9
View File
@@ -1,9 +0,0 @@
redis-wrapper
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--public-repo=False
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/settings monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/settings
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/settings
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/settings
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
-9
View File
@@ -1,9 +0,0 @@
settings
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--public-repo=False
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/stream-utils monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/stream-utils
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/stream-utils
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/stream-utils
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
-9
View File
@@ -1,9 +0,0 @@
stream-utils
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--public-repo=False
-1
View File
@@ -1 +0,0 @@
24.14.1
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/validation-tools monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/validation-tools
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/validation-tools
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/validation-tools
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
@@ -1,10 +0,0 @@
validation-tools
--dependencies=None
--env-add=
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--public-repo=False
--test-acceptance-vitest=True
--test-unit-vitest=True
-33
View File
@@ -1,33 +0,0 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import cypress from 'eslint-plugin-cypress/flat'
import path from 'node:path'
import baseConfig from '../eslint.config.mjs'
const ROOT_DIR = path.resolve(import.meta.dirname, '..')
export default defineConfig([
globalIgnores(['**/hotfix/', '**/develop/']),
{
basePath: ROOT_DIR,
extends: baseConfig,
languageOptions: {
ecmaVersion: 2020,
},
},
{
// The cypress block in baseConfig has patterns rooted at the
// monorepo root (`server-ce/test/helpers/*.ts`). When ESLint loads
// this file (server-ce/eslint.config.mjs) as the closest config --
// which happens when running `yarn run lint` from
// /overleaf/server-ce/test/ -- patterns from baseConfig are
// resolved relative to /overleaf/server-ce/, so those cross-dir
// patterns don't match. Re-declare with paths relative to this
// config file.
files: [
'test/helpers/*.ts',
'test/cypress/support/*.{js,jsx,mjs,cjs,ts,tsx}',
'**/*.spec.ts',
],
...cypress.configs.recommended,
},
])
-2
View File
@@ -1,2 +0,0 @@
data/
docker-mailtrap/
-15
View File
@@ -1,15 +0,0 @@
FROM node:24.14.1
# Corepack setup, shared between all the images.
ENV PATH="/overleaf/node_modules/.bin:$PATH"
ENV COREPACK_HOME=/opt/corepack
RUN corepack enable && corepack install -g yarn@4.14.1
ENV COREPACK_ENABLE_NETWORK=0
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | apt-key add - \
&& echo \
"deb [arch=$(dpkg --print-architecture)] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y docker-ce-cli docker-compose-plugin \
&& rm -rf /var/lib/apt/lists/*
-126
View File
@@ -1,126 +0,0 @@
all: test-e2e
# We are updating the docker compose config via the host-admin service.
# The host-admin service is running inside docker and has its own file-system layout.
# 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).
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 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 IMAGE_TAG_CE ?= sharelatex/sharelatex:main
export IMAGE_TAG_PRO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pro:main
export IMAGE_TAG_GIT_BRIDGE ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/git-bridge:main
export CYPRESS_SHARD ?=
export COMPOSE_PROJECT_NAME ?= test
export USER_UID=$(shell id -u)
export USER_GID=$(shell id -g)
IMAGE_TAG_PRO_BASE := $(shell echo $(IMAGE_TAG_PRO) | sed -E s/:.+//)
print-debug-logs:
-docker compose logs --timestamps --tail=100 e2e
-docker compose logs --timestamps --tail=100 mongo
-docker compose logs --timestamps --tail=100 redis
-docker compose logs --timestamps --tail=100 mailtrap
-docker compose logs --timestamps --tail=100 saml
-docker compose logs --timestamps --tail=100 ldap
-docker compose logs --timestamps --tail=100 git-bridge
-docker compose logs --timestamps --tail=100 host-admin
-docker compose logs --timestamps sharelatex
-docker compose exec sharelatex bash -c 'tail -n100 /var/log/overleaf/*.log'
test-e2e-native:
docker compose -f docker-compose.yml -f docker-compose.native.yml up --no-log-prefix sharelatex host-admin -d
CYPRESS_BASE_URL='http://127.0.0.1:3231' \
CYPRESS_HOST_ADMIN_URL='http://127.0.0.1:3232' \
CYPRESS_SAML_URL='http://127.0.0.1:3233' \
CYPRESS_MAILTRAP_URL='http://127.0.0.1:3234' \
yarn run cypress:open
# For testing "native" on Linux
test-e2e-native-linux:
docker compose -f docker-compose.yml -f docker-compose.native.yml up --no-log-prefix sharelatex host-admin -d
CYPRESS_BASE_URL='http://127.0.0.1:3231' \
CYPRESS_HOST_ADMIN_URL='http://127.0.0.1:3232' \
CYPRESS_SAML_URL='http://127.0.0.1:3233' \
CYPRESS_MAILTRAP_URL='http://127.0.0.1:3234' \
docker compose -f docker-compose.yml -f docker-compose.native.yml up --no-log-prefix cypress
test-e2e:
docker compose build host-admin e2e
docker compose up --detach --wait host-admin
docker compose up --detach --wait mongo
docker compose up --no-log-prefix --exit-code-from=e2e e2e host-admin
test-e2e-open:
docker compose up --detach --wait host-admin
docker compose up --detach --wait mongo
docker compose up --no-log-prefix --exit-code-from=e2e-open e2e-open host-admin
SHARD_PROJECT_NAMES = \
test-ce-default \
test-ce-custom-1 \
test-pro-default-1 \
test-pro-default-2 \
test-pro-custom-1 \
test-pro-custom-2 \
test-pro-custom-3 \
test-pro-custom-4
CLEAN_SHARDS=$(addprefix clean/,$(SHARD_PROJECT_NAMES))
clean: $(CLEAN_SHARDS)
-docker compose run --no-deps --rm --entrypoint rm host-admin -rf docker-compose.override.yml docker-compose.*_*.yml reports/ data/
-docker compose down --remove-orphans --rmi local --timeout 0 --volumes
$(CLEAN_SHARDS): clean/%:
-COMPOSE_PROJECT_NAME=$* docker compose down --remove-orphans --rmi local --timeout 0 --volumes
prefetch: prefetch_default
prefetch_default: prefetch_default_compose
prefetch_default_compose:
docker compose pull e2e mongo redis
prefetch_default: prefetch_default_compose_build
prefetch_default_compose_build:
docker compose build host-admin e2e
prefetch: prefetch_custom
prefetch_custom: prefetch_custom_compose_pull
prefetch_custom_compose_pull:
docker compose pull saml ldap
prefetch_custom: prefetch_custom_texlive_2023
prefetch_custom_texlive_2023:
docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1
docker tag us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2023.1 quay.io/sharelatex/texlive-full:2023.1
prefetch_custom: prefetch_custom_texlive_2022
prefetch_custom_texlive_2022:
docker pull us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2022.1
docker tag us-east1-docker.pkg.dev/overleaf-ops/ol-docker/texlive-full:2022.1 quay.io/sharelatex/texlive-full:2022.1
prefetch_custom: prefetch_old_4_2
prefetch_old_4_2:
docker pull $(IMAGE_TAG_PRO_BASE):4.2
prefetch_custom: prefetch_old_5_0
prefetch_old_5_0:
docker pull $(IMAGE_TAG_PRO_BASE):5.0
prefetch_custom: build_hotfix_5_5_5
build_hotfix_5_5_5:
docker pull $(IMAGE_TAG_PRO_BASE):5.5.4
cd ../../server-pro/hotfix/5.5.5 && docker build --build-arg BASE_IMAGE=$(IMAGE_TAG_PRO_BASE):5.5.4 -t $(IMAGE_TAG_PRO_BASE):5.5.5 .
# Google Cloud Build runs on a very ancient Docker version that does not support the subdir flag.
# Use services -> mailtrap -> build -> context = https://github.com/dbck/docker-mailtrap.git#v1.5.0:build in docker-compose.yml eventually.
prefetch_default_compose_build: build_mailtrap
build_mailtrap:
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
prefetch_default_compose_build: build_cypress
build_cypress:
docker compose build e2e
.PHONY: test-e2e test-e2e-open
-34
View File
@@ -1,34 +0,0 @@
import { createMongoUser, ensureUserExists, login } from './helpers/login'
import { isExcludedBySharding, startWith } from './helpers/config'
describe('Accounts', function () {
if (isExcludedBySharding('CE_DEFAULT')) return
startWith({})
ensureUserExists({ email: 'user@example.com' })
it('can log in and out', function () {
login('user@example.com')
cy.visit('/project')
cy.findByRole('menuitem', { name: 'Account' }).click()
cy.findByRole('menuitem', { name: 'Log Out' }).click()
cy.url().should('include', '/login')
cy.visit('/project')
cy.url().should('include', '/login')
})
it('should render the email on the user activate screen', function () {
const email = 'not-activated-user@example.com'
cy.then(async () => {
const { url } = await createMongoUser({ email })
return url
}).as('url')
cy.get('@url').then(url => {
cy.visit(`${url}`)
cy.url().should('contain', '/user/activate')
cy.findByRole('heading', { name: 'Please set a password' })
cy.findByLabelText('Email').should('be.visible')
cy.findByLabelText('Password').should('be.visible')
cy.findByRole('button', { name: 'Activate' })
})
})
})
-402
View File
@@ -1,402 +0,0 @@
import { isExcludedBySharding, startWith } from './helpers/config'
import {
activateUser,
createMongoUser,
ensureUserExists,
login,
} from './helpers/login'
import { v4 as uuid } from 'uuid'
import { createProject } from './helpers/project'
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
import { openEmail } from './helpers/email'
describe('admin panel', function () {
function registrationTests() {
it('via GUI and opening URL manually', function () {
const user = `${uuid()}@example.com`
cy.findByLabelText('Emails to register new users').type(user + '{enter}')
cy.findByRole('cell', { name: /\/user\/activate/ }).then($td => {
const url = $td.text().trim()
activateUser(url)
})
})
it('via GUI and email', function () {
const user = `${uuid()}@example.com`
cy.findByLabelText('Emails to register new users').type(user + '{enter}')
let url: string
cy.findByRole('cell', { name: /\/user\/activate/ }).then($td => {
url = $td.text().trim()
})
cy.then(() => {
openEmail(
'Activate your E2E test Account',
(frame, { url }) => {
frame.contains('a', 'Set password').then(el => {
expect(el.attr('href')!).to.equal(url)
})
},
{ url }
)
// Run activateUser in the main origin instead of inside openEmail. See docs on openEmail.
activateUser(url)
})
})
it('via script and opening URL manually', function () {
const user = `${uuid()}@example.com`
let url: string
cy.then(async () => {
;({ url } = await createMongoUser({ email: user }))
})
cy.then(() => {
activateUser(url)
})
})
it('via script and email', function () {
const user = `${uuid()}@example.com`
let url: string
cy.then(async () => {
;({ url } = await createMongoUser({ email: user }))
})
cy.then(() => {
openEmail(
'Activate your E2E test Account',
(frame, { url }) => {
frame.contains('a', 'Set password').then(el => {
expect(el.attr('href')!).to.equal(url)
})
},
{ url }
)
// Run activateUser in the main origin instead of inside openEmail. See docs on openEmail.
activateUser(url)
})
})
}
describe('in CE', function () {
if (isExcludedBySharding('CE_DEFAULT')) return
startWith({ pro: false, version: 'latest' })
const admin = 'admin@example.com'
const user = `user+${uuid()}@example.com`
ensureUserExists({ email: admin, isAdmin: true })
ensureUserExists({ email: user })
describe('create users', function () {
beforeEach(function () {
login(admin)
cy.visit('/project')
cy.findByRole('menuitem', { name: 'Admin' }).click()
cy.findByRole('menuitem', { name: 'Manage Users' }).click()
})
registrationTests()
})
})
describe('in server pro', function () {
const admin = 'admin@example.com'
const user1 = 'user@example.com'
const user2 = 'user2@example.com'
let testProjectName = ''
let testProjectId = ''
let deletedProjectName = ''
let projectToDeleteId = ''
const findProjectRow = (projectName: string) => {
cy.log('find project row')
return cy.findByText(projectName).parent().parent()
}
if (isExcludedBySharding('PRO_DEFAULT_2')) return
startWith({
pro: true,
})
ensureUserExists({ email: admin, isAdmin: true })
ensureUserExists({ email: user1 })
ensureUserExists({ email: user2 })
beforeWithReRunOnTestRetry(() => {
testProjectName = `project-${uuid()}`
deletedProjectName = `deleted-project-${uuid()}`
login(user1)
createProject(testProjectName, { open: false }).then(
id => (testProjectId = id)
)
createProject(deletedProjectName, { open: false }).then(
id => (projectToDeleteId = id)
)
})
describe('admin menu items', function () {
beforeEach(function () {
login(admin)
cy.visit('/project')
})
it('displays expected admin menu items', function () {
const menuitems = ['Manage Site', 'Manage Users', 'Project URL Lookup']
menuitems.forEach(name => {
cy.findByRole('menuitem', { name: 'Admin' }).click()
cy.findByRole('menu')
.findAllByRole('menuitem')
.should('have.length', menuitems.length)
cy.findByRole('menu').findByRole('menuitem', { name }).click()
})
})
})
describe('manage site', function () {
beforeEach(function () {
login(admin)
cy.visit('/project')
cy.findByRole('menuitem', { name: 'Admin' }).click()
cy.findByRole('menuitem', { name: 'Manage Site' }).click()
})
it('publish and clear admin messages', function () {
const message = 'Admin Message ' + uuid()
cy.log('create system message')
cy.findByRole('tab', { name: 'System Messages' }).click()
cy.findByLabelText('Message').type(message)
cy.findByRole('button', { name: 'Post Message' }).click()
cy.findByText(message)
login(user1)
cy.visit('/project')
cy.findByText(message)
cy.log('clear system messages')
login(admin)
cy.visit('/project')
cy.findByRole('menuitem', { name: 'Admin' }).click()
cy.findByRole('menuitem', { name: 'Manage Site' }).click()
cy.findByRole('tab', { name: 'System Messages' }).click()
cy.findByRole('button', { name: 'Clear all messages' }).click()
cy.log('verify system messages are no longer displayed')
login(user1)
cy.visit('/project')
cy.findByText(message).should('not.exist')
})
})
describe('manage users', function () {
beforeEach(function () {
login(admin)
cy.visit('/project')
cy.findByRole('menuitem', { name: 'Admin' }).click()
cy.findByRole('menuitem', { name: 'Manage Users' }).click()
})
it('displays expected tabs', function () {
const tabs = ['Users', 'License Usage']
cy.findAllByRole('tab').should('have.length', tabs.length)
tabs.forEach(tabName => {
cy.findByRole('tab', { name: tabName }).click()
})
})
it('license usage tab', function () {
cy.findByRole('tab', { name: 'License Usage' }).click()
cy.findByText(
'An active user is one who has opened a project in this Server Pro instance in the last 12 months.'
)
})
describe('create users', function () {
beforeEach(function () {
cy.findByRole('link', { name: 'New User' }).click()
})
registrationTests()
})
it('user list RegExp search', function () {
cy.findByLabelText('RegExp').click()
cy.findByPlaceholderText('Search users by email or id…').type(
'user[0-9]{enter}'
)
cy.findByRole('link', { name: user2 })
cy.findByRole('link', { name: user1 }).should('not.exist')
})
})
describe('user page', function () {
beforeEach(function () {
login(admin)
cy.visit('/project')
cy.findByRole('menuitem', { name: 'Admin' }).click()
cy.findByRole('menuitem', { name: 'Manage Users' }).click()
cy.findByPlaceholderText('Search users by email or id…').type(
user1 + '{enter}'
)
cy.findByRole('link', { name: user1 }).click()
cy.url().should('match', /\/admin\/user\/[a-fA-F0-9]{24}/)
})
it('displays expected tabs', function () {
const tabs = [
'User Info',
'Projects',
'Deleted Projects',
'Audit Log',
'Sessions',
'Personal Access Tokens',
]
cy.findAllByRole('tab').should('have.length', tabs.length)
tabs.forEach(tabName => {
cy.findByRole('tab', { name: tabName }).click()
})
})
describe('user info tab', function () {
beforeEach(function () {
cy.findByRole('tab', { name: 'User Info' }).click()
})
it('displays required sections', function () {
// not exhaustive list, checks the tab content is rendered
cy.findByRole('heading', { name: 'Profile' })
cy.findByRole('heading', { name: 'Editor Settings' })
})
it('should not display SaaS-only sections', function () {
cy.findByLabelText('Referred User Count').should('not.exist')
cy.findByRole('heading', { name: /Split Test Assignments/ }).should(
'not.exist'
)
cy.findByRole('heading', { name: 'Experimental Features' }).should(
'not.exist'
)
cy.findByRole('heading', { name: 'Service Integration' }).should(
'not.exist'
)
cy.findByRole('heading', { name: 'SSO Integrations' }).should(
'not.exist'
)
cy.findByRole('heading', { name: 'Security' }).should('not.exist')
})
})
it('transfer project ownership', function () {
cy.log("access project admin through owners' project list")
cy.findByRole('tablist').within(() => {
cy.findByRole('tab', { name: 'Projects' }).click()
})
cy.findByRole('link', { name: testProjectName })
.parent()
.parent()
.within(() => {
cy.findByRole('link', { name: 'Project information' }).click()
})
cy.findByRole('button', { name: 'Transfer Ownership' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('heading', { name: 'Transfer Ownership of Project' })
cy.findByRole('button', { name: 'Find' }).should('be.disabled')
cy.findByRole('button', { name: 'Confirm' }).should('be.disabled')
cy.findByPlaceholderText('User ID or Email').type(user2)
cy.findByRole('button', { name: 'Find' }).should('not.be.disabled')
cy.findByRole('button', { name: 'Find' }).click()
cy.findByText('Transfer project to this user?')
cy.findByRole('cell', { name: 'ID' })
cy.findByRole('cell', { name: 'Name' })
cy.findByRole('cell', { name: 'Email' })
cy.findByRole('cell', { name: user2 })
cy.findByRole('button', { name: 'Confirm' }).should('not.be.disabled')
cy.findByRole('button', { name: 'Confirm' }).click()
})
cy.log('check the project is displayed in the new owner projects tab')
cy.findByPlaceholderText('Search users by email or id…').type(
user2 + '{enter}'
)
cy.findByRole('link', { name: user2 }).click()
cy.findByRole('tablist').within(() => {
cy.findByRole('tab', { name: 'Projects' }).click()
})
cy.findByRole('link', { name: testProjectName })
.parent()
.parent()
.within(() => {
cy.findByRole('link', { name: 'Project information' }).click()
})
})
})
describe('project page', function () {
beforeEach(function () {
login(admin)
cy.visit(`/admin/project/${testProjectId}`)
})
it('displays expected tabs', function () {
const tabs = ['Project Info', 'Deleted Docs', 'Audit Log']
cy.findAllByRole('tab').should('have.length', tabs.length)
tabs.forEach(tabName => {
cy.findByRole('tab', { name: tabName }).click()
})
})
})
it('restore deleted projects', function () {
login(user1)
cy.visit('/project')
cy.log('select project to delete')
findProjectRow(deletedProjectName).within(() =>
cy
.findByRole('checkbox', { name: `Select ${deletedProjectName}` })
.first()
.check()
)
cy.log('delete project')
findProjectRow(deletedProjectName).within(() =>
cy.findByRole('button', { name: 'Trash' }).click()
)
cy.findByRole('button', { name: 'Confirm' }).click()
cy.findByRole('link', { name: deletedProjectName }).should('not.exist')
cy.log('navigate to thrashed projects and delete the project')
cy.findByRole('navigation', {
name: 'Project categories and tags',
})
.findByRole('button', { name: 'Trashed projects' })
.click()
findProjectRow(deletedProjectName).within(() =>
cy.findByRole('button', { name: 'Delete' }).click()
)
cy.findByRole('button', { name: 'Confirm' }).click()
cy.findByRole('link', { name: deletedProjectName }).should('not.exist')
cy.log('login as an admin and navigate to the deleted project')
login(admin)
cy.visit('/admin/user')
cy.findByPlaceholderText('Search users by email or id…').type(
user1 + '{enter}'
)
cy.findByRole('link', { name: user1 }).click()
cy.findByRole('tab', { name: 'Deleted Projects' }).click()
cy.findByRole('link', { name: deletedProjectName }).click()
cy.log('undelete the project')
cy.findByRole('button', { name: 'Undelete' }).click()
cy.findByRole('button', { name: 'Undelete' }).should('not.exist')
cy.url().should('contain', `/admin/project/${projectToDeleteId}`)
cy.log('login as the user and verify the project is restored')
login(user1)
cy.visit('/project')
cy.findByRole('navigation', {
name: 'Project categories and tags',
})
.findByRole('button', { name: 'Trashed projects' })
.click()
cy.findByRole('link', { name: `${deletedProjectName} (Restored)` })
})
})
})
@@ -1,144 +0,0 @@
import { ensureUserExists, login } from './helpers/login'
import {
createProject,
createProjectAndOpenInNewEditor,
openProjectViaInviteNotification,
} from './helpers/project'
import { isExcludedBySharding, startWith } from './helpers/config'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
const USER = 'user@example.com'
const COLLABORATOR = 'collaborator@example.com'
describe('Project creation and compilation', function () {
if (isExcludedBySharding('CE_DEFAULT')) return
startWith({})
ensureUserExists({ email: USER })
ensureUserExists({ email: COLLABORATOR })
it('users can create project and compile it', function () {
login(USER)
const { recompile, waitForCompile, waitForCompileRateLimitCoolOff } =
prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('test-project')
})
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type('\n\\section{{}Test Section}')
})
waitForCompileRateLimitCoolOff()
recompile()
cy.findByRole('region', { name: 'PDF preview' }).within(() => {
cy.findByLabelText(/Page.*1/i).should('be.visible')
cy.findByText('Test Section').should('be.visible')
})
})
it('create and edit markdown file', function () {
const fileName = `test-${Date.now()}.md`
const markdownContent = '# Markdown title'
login(USER)
createProjectAndOpenInNewEditor('test-project')
cy.findByRole('button', { name: 'New file' }).click()
cy.findByRole('dialog').within(() => {
cy.findByLabelText('File Name').as('fileName').clear()
cy.get('@fileName').type(fileName)
cy.findByRole('button', { name: 'Create' }).click()
})
cy.findByRole('treeitem', { name: fileName }).click()
// wait until we've switched to the newly created empty file
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'have.length',
1
)
cy.findByRole('textbox', { name: 'Source Editor editing' }).type(
markdownContent
)
cy.findByRole('treeitem', { name: 'main.tex' }).click()
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\maketitle'
)
cy.findByRole('treeitem', { name: fileName }).click()
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
markdownContent
)
})
it('can link and display linked image from other project', function () {
const sourceProjectName = `test-project-${Date.now()}`
const targetProjectName = `${sourceProjectName}-target`
login(USER)
createProject(sourceProjectName, {
type: 'Example project',
open: false,
newEditor: true,
}).as('sourceProjectId')
createProjectAndOpenInNewEditor(targetProjectName)
// link the image from `projectName` into this project
cy.findByRole('button', { name: 'New file' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('button', { name: 'From another project' }).click()
cy.findByLabelText('Select a Project').select(sourceProjectName)
cy.findByLabelText('Select a File').select('frog.jpg')
cy.findByRole('button', { name: 'Create' }).click()
})
cy.findByRole('treeitem', { name: 'frog.jpg' }).click()
cy.findByRole('link', { name: 'Another project' })
.should('have.attr', 'href')
.then(href => {
cy.get('@sourceProjectId').then(sourceProjectId => {
expect(href).to.equal(`/project/${sourceProjectId}`)
})
})
})
it('can refresh linked files as collaborator', function () {
const sourceProjectName = `test-project-${Date.now()}`
const targetProjectName = `${sourceProjectName}-target`
login(USER)
createProject(sourceProjectName, {
type: 'Example project',
open: false,
newEditor: true,
}).as('sourceProjectId')
createProjectAndOpenInNewEditor(targetProjectName).as('targetProjectId')
// link the image from `projectName` into this project
cy.findByRole('button', { name: 'New file' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('button', { name: 'From another project' }).click()
cy.findByLabelText('Select a Project').select(sourceProjectName)
cy.findByLabelText('Select a File').select('frog.jpg')
cy.findByRole('button', { name: 'Create' }).click()
})
cy.findByRole('navigation', { name: 'Project actions' }).within(() => {
cy.findByRole('button', { name: 'Share' }).click()
})
cy.findByRole('dialog').within(() => {
cy.findByTestId('collaborator-email-input').type(COLLABORATOR + ',')
cy.findByRole('button', { name: 'Invite' }).click()
cy.findByText('Invite not yet accepted.')
})
login(COLLABORATOR)
openProjectViaInviteNotification(targetProjectName, true)
cy.get('@targetProjectId').then(targetProjectId => {
cy.url().should('include', targetProjectId)
})
cy.findByRole('treeitem', { name: 'frog.jpg' }).click()
cy.findByRole('link', { name: 'Another project' })
.should('have.attr', 'href')
.then(href => {
cy.get('@sourceProjectId').then(sourceProjectId => {
expect(href).to.equal(`/project/${sourceProjectId}`)
})
})
})
})
-44
View File
@@ -1,44 +0,0 @@
import { isExcludedBySharding, startWith } from './helpers/config'
describe('Customization', function () {
if (isExcludedBySharding('CE_CUSTOM_1')) return
describe('default settings', function () {
startWith({})
it('should display the default right footer', function () {
cy.visit('/')
cy.findByRole('contentinfo').findByRole('link', {
name: 'Fork on GitHub!',
})
})
})
describe('custom settings', function () {
startWith({
vars: {
OVERLEAF_APP_NAME: 'CUSTOM APP NAME',
OVERLEAF_LEFT_FOOTER: JSON.stringify([{ text: 'CUSTOM LEFT FOOTER' }]),
OVERLEAF_RIGHT_FOOTER: JSON.stringify([
{ text: 'CUSTOM RIGHT FOOTER' },
]),
},
})
it('should display custom name', function () {
cy.visit('/')
cy.findByRole('navigation', { name: 'Primary' }).findByText(
'CUSTOM APP NAME'
)
})
it('should display custom left footer', function () {
cy.visit('/')
cy.findByRole('contentinfo').findByText('CUSTOM LEFT FOOTER')
})
it('should display custom right footer', function () {
cy.visit('/')
cy.findByRole('contentinfo').findByText('CUSTOM RIGHT FOOTER')
})
})
})
-67
View File
@@ -1,67 +0,0 @@
import { defineConfig } from 'cypress'
import { readFileInZip, readPdf } from './helpers/read-file'
import fs from 'node:fs'
if (process.env.CYPRESS_SHARD && !process.env.SPEC_PATTERN) {
// Running Cypress on all the specs is wasteful (~1min) when only few of them
// will have relevant tasks to run for a given shard. Filter the spec files
// based on the existence of the shard identifier in the spec source.
const files = []
for (const name of fs.readdirSync('.')) {
if (!name.endsWith('.spec.ts')) continue
const src = fs.readFileSync(name, 'utf-8')
if (!src.includes('isExcludedBySharding(')) {
throw new Error(
`Spec ${name} is not using sharding. Add an appropriate "'if (isExcludedBySharding('...')) return" call.`
)
}
if (!src.includes(process.env.CYPRESS_SHARD)) continue
files.push(name)
}
if (files.length === 0) {
throw new Error(
`Bad process.env.CYPRESS_SHARD=${process.env.CYPRESS_SHARD}; no spec files matched!`
)
}
if (files.length === 1) {
// Cypress does not like `{single-file}`. Make this a special case.
process.env.SPEC_PATTERN = `./${files[0]}`
} else {
process.env.SPEC_PATTERN = `./{${files.join(',')}}`
}
}
const specPattern = process.env.SPEC_PATTERN || './**/*.spec.ts'
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: `${process.env.MONOREPO}/node_modules/cypress-multi-reporters`,
reporterOptions: {
configFile: 'cypress/cypress-multi-reporters.cjs',
},
}
}
export default defineConfig({
defaultCommandTimeout: 10_000,
fixturesFolder: 'cypress/fixtures',
video: process.env.CYPRESS_VIDEO === 'true',
screenshotsFolder: 'cypress/results',
videosFolder: 'cypress/results',
viewportHeight: 768,
viewportWidth: 1024,
e2e: {
setupNodeEvents(on) {
on('task', {
readPdf,
readFileInZip,
})
},
specPattern,
},
retries: {
runMode: 3,
},
...reporterOptions,
})
-3
View File
@@ -1,3 +0,0 @@
downloads/
results/
data/
@@ -1,10 +0,0 @@
module.exports = {
reporterEnabled: 'spec, mocha-junit-reporter',
mochaJunitReporterReporterOptions: {
mochaFile: `reports/junit-${process.env.CYPRESS_SHARD}-[suiteFilename].xml`,
includePending: true,
jenkinsMode: true,
jenkinsClassnamePrefix: 'Server Pro E2E tests',
rootSuiteTitle: 'Server Pro E2E tests',
},
}
-12
View File
@@ -1,12 +0,0 @@
import '@testing-library/cypress/add-commands'
Cypress.on('uncaught:exception', (err, runnable) => {
if (err.message.includes('ResizeObserver')) {
// spurious error from PDF preview
return false
}
if (err.message.includes('rcube_webmail')) {
// spurious error from mailtrap
return false
}
})
-48
View File
@@ -1,48 +0,0 @@
services:
sharelatex:
ports:
- "127.0.0.1:3231:80"
environment:
OVERLEAF_SITE_URL: "http://127.0.0.1:3231"
host-admin:
ports:
- "127.0.0.1:3232:80"
environment:
NATIVE_CYPRESS: "true"
ACCESS_CONTROL_ALLOW_ORIGIN: "http://127.0.0.1:3231"
saml:
ports:
- "127.0.0.1:3233:80"
environment:
SAML_BASE_URL_PATH: "http://127.0.0.1:3233/simplesaml/"
SAML_TEST_SP_LOCATION: "http://127.0.0.1:3231/saml/callback"
mailtrap:
ports:
- "127.0.0.1:3234:80"
# For testing "native" on Linux
cypress:
build:
context: ../../dockerfiles/cypress
args:
- USER_UID
- USER_GID
volumes:
- ../../:/overleaf
- /tmp/.X11-unix:/tmp/.X11-unix
- ${XAUTHORITY:-/dev/null}:/home/node/.Xauthority
working_dir: /overleaf/server-ce/test
entrypoint: npm
command: run cypress:open
environment:
CI:
VERBOSE_LOGGING:
DISPLAY: ${DISPLAY:-:0}
CYPRESS_BASE_URL:
CYPRESS_HOST_ADMIN_URL:
CYPRESS_SAML_URL:
CYPRESS_MAILTRAP_URL:
network_mode: host
-158
View File
@@ -1,158 +0,0 @@
services:
sharelatex:
image: ${IMAGE_TAG_CE:-sharelatex/sharelatex:latest}
stop_grace_period: 0s
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_started
mailtrap:
condition: service_started
environment:
OVERLEAF_SITE_URL: "http://sharelatex"
OVERLEAF_APP_NAME: E2E test
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex?directConnection=true
OVERLEAF_REDIS_HOST: redis
REDIS_HOST: redis
OVERLEAF_EMAIL_FROM_ADDRESS: "welcome@example.com"
OVERLEAF_EMAIL_SMTP_HOST: "mailtrap"
OVERLEAF_EMAIL_SMTP_PORT: "25"
OVERLEAF_EMAIL_SMTP_IGNORE_TLS: "true"
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file"
ENABLE_CONVERSIONS: "true"
EMAIL_CONFIRMATION_DISABLED: "true"
OVERLEAF_INVITE_TOKEN_SECRET: "invite-token-secret"
healthcheck:
test: curl --fail http://localhost:3000/status
interval: 3s
timeout: 3s
retries: 30
mailtrap:
image: mailtrap
environment:
MAILTRAP_PASSWORD: "password-for-mailtrap"
mongo:
image: mongo:8.0.11
command: "--replSet overleaf"
volumes:
- ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
- ../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh
entrypoint: /mongodb-docker-entrypoint-wait.sh
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the replica set.
# This override is not needed when running the setup after starting up mongo.
- mongo:127.0.0.1
healthcheck:
test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet
interval: 3s
timeout: 3s
retries: 30
redis:
image: redis:7.2.1
git-bridge:
image: quay.io/sharelatex/git-bridge:latest
environment:
GIT_BRIDGE_API_BASE_URL: "http://sharelatex:3000/api/v0/" # "http://sharelatex/api/v0/" for version 4.1.6 and earlier
GIT_BRIDGE_OAUTH2_SERVER: "http://sharelatex"
GIT_BRIDGE_POSTBACK_BASE_URL: "http://git-bridge:8000"
GIT_BRIDGE_ROOT_DIR: "/data/git-bridge"
user: root
command: ["/server-pro-start.sh"]
e2e:
build:
context: ../../dockerfiles/cypress
args:
- USER_UID
- USER_GID
stop_grace_period: 0s
entrypoint: yarn
command: run cypress:run
# See comment in Makefile regarding matching file paths
working_dir: $PWD
volumes:
- $PWD:$PWD
- $MONOREPO/libraries:$MONOREPO/libraries
- $MONOREPO/node_modules:$MONOREPO/node_modules
- $MONOREPO/.yarn:$MONOREPO/.yarn
- $MONOREPO/.yarnrc.yml:$MONOREPO/.yarnrc.yml
- $MONOREPO/package.json:$MONOREPO/package.json
- $MONOREPO/yarn.lock:$MONOREPO/yarn.lock
environment:
MONOREPO:
CYPRESS_SHARD:
CYPRESS_BASE_URL: http://sharelatex
CYPRESS_FULL_FILESTORE_MIGRATION:
depends_on:
sharelatex:
condition: service_healthy
host-admin:
condition: service_healthy
e2e-open:
extends:
service: e2e
command: run cypress:open
volumes:
- /tmp/.X11-unix:/tmp/.X11-unix
- ${XAUTHORITY:-/dev/null}:/home/node/.Xauthority
environment:
DISPLAY: ${DISPLAY:-:0}
host-admin:
build: .
entrypoint: ["node", "--watch", "host-admin.js"]
# See comment in Makefile regarding matching file paths
working_dir: $PWD
volumes:
- $PWD:$PWD
- $MONOREPO/libraries:$MONOREPO/libraries:ro
- $MONOREPO/node_modules:$MONOREPO/node_modules:ro
- $MONOREPO/.yarn:$MONOREPO/.yarn:ro
- $MONOREPO/.yarnrc.yml:$MONOREPO/.yarnrc.yml:ro
- $MONOREPO/package.json:$MONOREPO/package.json:ro
- $MONOREPO/yarn.lock:$MONOREPO/yarn.lock:ro
- /var/run/docker.sock:/var/run/docker.sock
stop_grace_period: 0s
environment:
CI:
PWD:
MONOREPO:
CYPRESS_SHARD:
COMPOSE_PROJECT_NAME:
TEX_LIVE_DOCKER_IMAGE:
ALL_TEX_LIVE_DOCKER_IMAGES:
IMAGE_TAG_CE:
IMAGE_TAG_PRO:
IMAGE_TAG_GIT_BRIDGE:
healthcheck:
test: curl --fail http://localhost/status
interval: 3s
timeout: 3s
retries: 30
saml:
restart: always
image: us-east1-docker.pkg.dev/overleaf-ops/ol-docker/saml-test
environment:
SAML_TEST_SP_ENTITY_ID: "sharelatex-test-saml"
SAML_BASE_URL_PATH: "http://saml/simplesaml/"
SAML_TEST_SP_LOCATION: "http://sharelatex/saml/callback"
ldap:
restart: always
image: rroemhild/test-openldap:1.1
# Workaround large memory allocation (using the max-open-files-limit as socket buffer scale).
# REF: https://github.com/moby/moby/issues/8231#issuecomment-63877553
# REF: https://github.com/moby/moby/issues/8231#issuecomment-63871343
command:
- "bash"
- "-c"
- "ulimit -n 1024 && exec bash /run.sh"
-451
View File
@@ -1,451 +0,0 @@
import {
createNewFile,
createProjectAndOpenInNewEditor,
openProjectById,
openProjectByName,
shareProjectByEmailAndAcceptInviteViaDash,
testNewFileUpload,
} 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'
const USER = 'user@example.com'
const COLLABORATOR = 'collaborator@example.com'
describe('editor', function () {
if (isExcludedBySharding('PRO_DEFAULT_1')) return
startWith({ pro: true })
ensureUserExists({ email: USER })
ensureUserExists({ email: COLLABORATOR })
let projectName: string
let projectId: string
let recompile: () => void
let waitForCompile: (fn: () => void) => void
beforeWithReRunOnTestRetry(() => {
projectName = `project-${uuid()}`
login(USER)
createProjectAndOpenInNewEditor(projectName, {
type: 'Example project',
}).then(id => (projectId = id))
;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
})
beforeEach(function () {
login(USER)
waitForCompile(() => {
openProjectById(projectId, true)
})
})
describe('spelling', function () {
function changeSpellCheckLanguageTo(lng: string) {
cy.log(`change project language to '${lng}'`)
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('tab', { name: 'Spelling and language' }).click()
cy.findByLabelText('Spellcheck language').select(lng)
})
cy.get('body').type('{esc}')
}
afterEach(function () {
changeSpellCheckLanguageTo('Off')
})
it('word dictionary and spelling', function () {
changeSpellCheckLanguageTo('English (American)')
createNewFile()
const word = createRandomLetterString()
cy.log('edit project file')
cy.get('.cm-line').type(word)
cy.findByText(word).should('have.class', 'ol-cm-spelling-error')
changeSpellCheckLanguageTo('Off')
cy.findByText(word).should('not.have.class', 'ol-cm-spelling-error')
changeSpellCheckLanguageTo('Spanish')
cy.findByText(word).should('have.class', 'ol-cm-spelling-error')
cy.log('add word to dictionary')
cy.findByText(word).rightclick()
cy.findByRole('menuitem', { name: 'Add to dictionary' }).click()
cy.findByText(word).should('not.have.class', 'ol-cm-spelling-error')
cy.log('remove word from dictionary')
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('tab', { name: 'Spelling and language' }).click()
cy.findByLabelText('Dictionary').click()
})
cy.findByTestId('dictionary-modal').within(() => {
cy.findByText(word)
.parent()
.within(() =>
cy.findByRole('button', { name: 'Remove from dictionary' }).click()
)
cy.findByRole('button', { name: 'Close dialog' }).click()
})
cy.log('close modal')
cy.get('body').type('{esc}')
cy.log('rewrite word to force spelling error')
cy.get('.cm-line').type('{selectAll}{del}' + word + '{enter}')
cy.get('.ol-cm-spelling-error').should('contain.text', word)
})
})
describe('editor', function () {
it('renders jpg', function () {
cy.findByRole('treeitem', { name: 'frog.jpg' }).click({ force: true })
cy.get('[alt="frog.jpg"]')
.should('be.visible')
.and('have.prop', 'naturalWidth')
.should('be.greaterThan', 0)
})
it('symbol palette', function () {
createNewFile()
cy.get('button[aria-label="Insert symbol"]').click({
force: true,
})
cy.get('button').contains('𝜉').click()
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\xi'
)
cy.log('recompile to force flush and avoid "unsaved changes" prompt')
recompile()
})
})
describe('add new file to project', function () {
beforeEach(function () {
cy.findByRole('button', { name: 'New file' }).click()
})
testNewFileUpload()
it('should not display import from URL', function () {
cy.findByRole('button', { name: 'From external URL' }).should('not.exist')
})
})
describe('file menu', function () {
it('can download project sources', function () {
cy.findByRole('button', { name: 'File' }).click()
cy.findByRole('menuitem', { name: 'Download' }).click()
cy.findByRole('menuitem', { name: 'Download as source (.zip)' }).click()
const zipName = projectName.replaceAll('-', '_')
cy.task('readFileInZip', {
pathToZip: `cypress/downloads/${zipName}.zip`,
fileToRead: 'main.tex',
}).should('contain', 'Your introduction goes here')
})
it('can download project PDF', function () {
cy.log('ensure project is compiled')
cy.findByRole('region', { name: 'PDF preview' }).should(
'contain.text',
'Your Paper'
)
cy.findByRole('button', { name: 'File' }).click()
cy.findByRole('menuitem', { name: 'Download' }).click()
cy.findByRole('menuitem', { name: 'Download as PDF' }).click()
const pdfName = projectName.replaceAll('-', '_')
cy.task('readPdf', `cypress/downloads/${pdfName}.pdf`).should(
'contain',
'Your introduction goes here'
)
})
it('word count', function () {
cy.log('ensure project is compiled')
cy.findByRole('region', { name: 'PDF preview' }).should(
'contain.text',
'Your Paper'
)
cy.findByRole('button', { name: 'File' }).click()
cy.findByRole('menuitem', { name: 'Word count' }).click()
cy.findByTestId('word-count-modal').within(() => {
cy.findByText('Total Words:')
cy.findByText('607')
cy.findByText('Headers:')
cy.findByText('14')
cy.findByText('Math Inline:')
cy.findByText('6')
cy.findByText('Math Display:')
cy.findByText('1')
})
})
})
describe('cite key search', function () {
it('can insert citation from cite key', function () {
createNewFile()
cy.get('.cm-line').type('\\cite{{}gre')
cy.findByRole('listbox').within(() => {
cy.findByRole('option').should('contain.text', 'greenwade93').click()
})
cy.get('.cm-line').should('have.text', '\\cite{greenwade93}')
})
it('updates citation search when bib file is changed', function () {
createNewFile()
cy.get('.cm-line').type('\\cite{{}new')
// Wait a reasonable time to ensure the autocomplete would've appeared if there were any matches
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(200)
cy.findByRole('listbox').should('not.exist')
cy.findByRole('treeitem', { name: 'sample.bib' }).click()
cy.get('.cm-line')
.last()
.type(
'\n@article{{}newkey2024,\n author = {{}Doe, John},\n title = {{}A New Article},\n journal = {{}Journal of Testing},\n year = 2024\n}\n'
)
createNewFile()
cy.get('.cm-line').type('\\cite{{}new')
cy.findByRole('listbox').within(() => {
cy.findByRole('option').should('contain.text', 'newkey2024').click()
})
cy.get('.cm-line').should('have.text', '\\cite{newkey2024}')
})
})
describe('layout selector', function () {
it('show editor only and switch between editor and pdf', function () {
cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
cy.get('.cm-editor').should('be.visible')
cy.findByRole('button', { name: 'Layout options' }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /Editor only/ }).click()
})
cy.findByRole('region', { name: 'PDF preview' }).should('not.be.visible')
cy.get('.cm-editor').should('be.visible')
// force click as tooltip may cover button for some reason
cy.findByRole('button', { name: 'Switch to PDF' }).click({ force: true })
cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
cy.get('.cm-editor').should('not.be.visible')
cy.findByRole('button', { name: 'Switch to editor' }).click()
cy.findByRole('region', { name: 'PDF preview' }).should('not.be.visible')
cy.get('.cm-editor').should('be.visible')
})
it('show PDF only and go back to split view', function () {
cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
cy.get('.cm-editor').should('be.visible')
cy.findByRole('button', { name: 'Layout options' }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: /PDF only/ }).click()
})
cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
cy.get('.cm-editor').should('not.be.visible')
cy.findByRole('button', { name: 'Layout options' }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: 'Split view' }).click()
})
cy.findByRole('region', { name: 'PDF preview' }).should('be.visible')
cy.get('.cm-editor').should('be.visible')
})
it('PDF in a separate tab (tests editor only)', function () {
cy.findByTestId('pdf-viewer').should('be.visible')
cy.get('.cm-editor').should('be.visible')
cy.findByRole('button', { name: 'Layout options' }).click()
cy.findByRole('menu').within(() => {
cy.findByRole('menuitem', { name: 'Open PDF in separate tab' }).click()
})
cy.findByTestId('pdf-viewer').should('not.exist')
cy.get('.cm-editor').should('be.visible')
})
})
describe('full project search', function () {
it('can search for text in project files', function () {
cy.findByRole('tab', { name: 'Project search' }).click()
cy.findByRole('searchbox', { name: 'Search' })
.should('be.visible')
.type('Some examples to get started')
cy.get('button').contains('Search').click()
cy.findByRole('listbox').within(() => {
cy.findByRole('option', {
name: /Some examples to get started/,
}).should('be.visible')
})
})
})
describe('theming', function () {
it('can change overall theme in settings menu', function () {
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('tab', { name: 'Appearance' }).click()
cy.findByLabelText('Overall theme').select('Dark')
})
cy.get('body').should('have.attr', 'data-theme', 'default')
cy.get('body').type('{esc}')
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('tab', { name: 'Appearance' }).click()
cy.findByLabelText('Overall theme').select('Light')
})
cy.get('body').should('have.attr', 'data-theme', 'light')
cy.get('body').type('{esc}')
})
})
})
describe('editing mode', function () {
if (isExcludedBySharding('PRO_DEFAULT_1')) return
startWith({ pro: true })
const OWNER = USER
const EDITOR = COLLABORATOR
const REVIEWER = 'reviewer@example.com'
const VIEWER = 'viewer@example.com'
ensureUserExists({ email: OWNER })
ensureUserExists({ email: EDITOR })
ensureUserExists({ email: REVIEWER })
ensureUserExists({ email: VIEWER })
let projectName: string
function openEditingModeMenu() {
cy.findByRole('button', { name: 'View' }).click()
cy.findByRole('menuitem', { name: 'Editing mode' }).click()
}
beforeWithReRunOnTestRetry(() => {
projectName = `project-${uuid()}`
login(OWNER)
createProjectAndOpenInNewEditor(projectName, { type: 'Example project' })
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
EDITOR,
'Editor',
true
)
login(OWNER)
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
REVIEWER,
'Reviewer',
true
)
login(OWNER)
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
VIEWER,
'Viewer',
true
)
})
it('lets an editor switch between editing and reviewing', function () {
login(EDITOR)
openProjectByName(projectName, true)
openEditingModeMenu()
cy.findByRole('menuitem', { name: 'Editing' })
.should('have.attr', 'aria-selected', 'true')
.and('not.have.attr', 'aria-disabled')
cy.findByRole('menuitem', { name: 'Reviewing' })
.should('have.attr', 'aria-selected', 'false')
.and('not.have.attr', 'aria-disabled')
cy.findByRole('menuitem', { name: 'Viewing' }).should('not.exist')
cy.findByRole('menuitem', { name: 'Reviewing' }).click()
cy.findByRole('button', { name: 'Reviewing' }).should('exist')
openEditingModeMenu()
cy.findByRole('menuitem', { name: 'Reviewing' }).should(
'have.attr',
'aria-selected',
'true'
)
cy.findByRole('menuitem', { name: 'Editing' }).should(
'have.attr',
'aria-selected',
'false'
)
cy.findByRole('menuitem', { name: 'Editing' }).click()
cy.findByRole('button', { name: 'Editing' }).should('exist')
})
it('shows a reviewer in reviewing mode with editing disabled', function () {
login(REVIEWER)
openProjectByName(projectName, true)
openEditingModeMenu()
cy.findByRole('menuitem', { name: 'Reviewing' })
.should('have.attr', 'aria-selected', 'true')
.and('not.have.attr', 'aria-disabled')
cy.findByRole('menuitem', { name: 'Editing' }).should(
'have.attr',
'aria-disabled',
'true'
)
cy.findByRole('menuitem', { name: 'Viewing' }).should('not.exist')
})
it('shows a viewer in viewing mode with editing and reviewing disabled', function () {
login(VIEWER)
openProjectByName(projectName, true)
openEditingModeMenu()
cy.findByRole('menuitem', { name: 'Viewing' }).should(
'have.attr',
'aria-selected',
'true'
)
cy.findByRole('menuitem', { name: 'Editing' }).should(
'have.attr',
'aria-disabled',
'true'
)
cy.findByRole('menuitem', { name: 'Reviewing' }).should(
'have.attr',
'aria-disabled',
'true'
)
})
})
function createRandomLetterString() {
const chars = 'abcdefghijklmnopqrstuvwxyz'
let result = ''
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
-73
View File
@@ -1,73 +0,0 @@
import { isExcludedBySharding, startWith } from './helpers/config'
import { createProject } from './helpers/project'
describe('SAML', function () {
if (isExcludedBySharding('PRO_CUSTOM_1')) return
const samlURL = Cypress.env('SAML_URL') || 'http://saml'
startWith({
pro: true,
vars: {
EXTERNAL_AUTH: 'saml',
OVERLEAF_SAML_ENTRYPOINT: `${samlURL}/simplesaml/saml2/idp/SSOService.php`,
OVERLEAF_SAML_CALLBACK_URL: `${Cypress.config().baseUrl}/saml/callback`,
OVERLEAF_SAML_ISSUER: 'sharelatex-test-saml',
OVERLEAF_SAML_IDENTITY_SERVICE_NAME: 'SAML Test Server',
OVERLEAF_SAML_EMAIL_FIELD: 'email',
OVERLEAF_SAML_FIRST_NAME_FIELD: 'givenName',
OVERLEAF_SAML_LAST_NAME_FIELD: 'sn',
OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN: 'true',
OVERLEAF_SAML_CERT:
'MIIDXTCCAkWgAwIBAgIJAOvOeQ4xFTzsMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkdCMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMTE1MTQxMjU5WhcNMjYxMTE1MTQxMjU5WjBFMQswCQYDVQQGEwJHQjETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCT6MBe5G9VoLU8MfztOEbUhnwLp17ak8eFUqxqeXkkqtWB0b/cmIBU3xoQoO3dIF8PBzfqehqfYVhrNt/TFgcmDfmJnPJRL1RJWMW3VmiP5odJ3LwlkKbZpkeT3wZ8HEJIR1+zbpxiBNkbd2GbdR1iumcsHzMYX1A2CBj+ZMV5VijC+K4P0e9c05VsDEUtLmfeAasJAiumQoVVgAe/BpiXjICGGewa6EPFI7mKkifIRKOGxdRESwZZjxP30bI31oDN0cgKqIgSJtJ9nfCn9jgBMBkQHu42WMuaWD4jrGd7+vYdX+oIfArs9aKgAH5kUGhGdew2R9SpBefrhbNxG8QIDAQABo1AwTjAdBgNVHQ4EFgQU+aSojSyyLChP/IpZcafvSdhj7KkwHwYDVR0jBBgwFoAU+aSojSyyLChP/IpZcafvSdhj7KkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABl3+OOVLBWMKs6PjA8lPuloWDNzSr3v76oUcHqAb+cfbucjXrOVsS9RJ0X9yxvCQyfM9FfY43DbspnN3izYhdvbJD8kKLNf0LA5st+ZxLfy0ACyL2iyAwICaqndqxAjQYplFAHmpUiu1DiHckyBPekokDJd+ze95urHMOsaGS5RWPoKJVE0bkaAeZCmEu0NNpXRSBiuxXSTeSAJfv6kyE/rkdhzUKyUl/cGQFrsVYfAFQVA+W6CKOh74ErSEzSHQQYndl7nD33snD/YqdU1ROxV6aJzLKCg+sdj+wRXSP2u/UHnM4jW9TGJfhO42jzL6WVuEvr9q4l7zWzUQKKKhtQ==',
},
})
it('login', function () {
cy.visit('/')
cy.findByRole('link', { name: 'Log in with SAML Test Server' }).click()
cy.origin(samlURL, () => {
cy.get('input[name="username"]').type('sally')
cy.get('input[name="password"]').type('sally123')
cy.get('button[type="submit"]').click()
})
cy.log('wait for login to finish')
cy.url().should('contain', '/project')
createProject('via SAML')
})
})
describe('LDAP', function () {
if (isExcludedBySharding('PRO_CUSTOM_1')) return
startWith({
pro: true,
vars: {
EXTERNAL_AUTH: 'ldap',
OVERLEAF_LDAP_URL: 'ldap://ldap:389',
OVERLEAF_LDAP_SEARCH_BASE: 'ou=people,dc=planetexpress,dc=com',
OVERLEAF_LDAP_SEARCH_FILTER: '(uid={{username}})',
OVERLEAF_LDAP_BIND_DN: 'cn=admin,dc=planetexpress,dc=com',
OVERLEAF_LDAP_BIND_CREDENTIALS: 'GoodNewsEveryone',
OVERLEAF_LDAP_EMAIL_ATT: 'mail',
OVERLEAF_LDAP_NAME_ATT: 'cn',
OVERLEAF_LDAP_LAST_NAME_ATT: 'sn',
OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN: 'true',
},
})
it('login', function () {
cy.visit('/')
cy.findByRole('heading', { name: 'Log in LDAP' })
cy.findByLabelText('Username').type('fry')
cy.findByLabelText('Password').type('fry')
cy.findByRole('button', { name: 'Login' }).click()
cy.log('wait for login to finish')
cy.url().should('contain', '/project')
createProject('via LDAP')
})
})
-433
View File
@@ -1,433 +0,0 @@
import { DEFAULT_PASSWORD, ensureUserExists, login } from './helpers/login'
import {
createProject,
expectFileExists,
openProjectById,
prepareFileUploadTest,
} from './helpers/project'
import { isExcludedBySharding, startWith } from './helpers/config'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
import { v4 as uuid } from 'uuid'
import {
purgeFilestoreData,
runGruntTask,
runScript,
setMongoFeatureCompatibilityVersion,
} from './helpers/hostAdminClient'
function activateUserVersion1x(url: string, password = DEFAULT_PASSWORD) {
cy.session(url, () => {
cy.visit(url)
cy.url().then(url => {
if (url.includes('/login')) return
cy.url().should('contain', '/user/password/set')
cy.get('input[type="password"]').type(password)
cy.findByRole('button', { name: 'Set new password' }).click()
})
})
}
describe('filestore migration', function () {
if (isExcludedBySharding('PRO_CUSTOM_5')) return
const email = 'user@example.com'
// Branding of env vars changed in 5.x
const sharelatexBrandedVars = {
SHARELATEX_SITE_URL: 'http://sharelatex',
SHARELATEX_MONGO_URL: 'mongodb://mongo/sharelatex',
SHARELATEX_REDIS_HOST: 'redis',
}
const projectName = `project-${uuid()}`
let defaultImage: string
let projectId: string
let waitForCompile: (fn: () => void) => void
const previousBinaryFiles: (() => void)[] = []
function avoid502() {
// The next step will likely restart the instance and any following
// requests will fail with a 502/bad gateway. Avoid this by navigating
// away from the editor, which will reload upon receiving a
// 'forceDisconnect' socket.io message.
cy.visit('/project')
}
function addNewBinaryFileAndCheckPrevious(
universeSelector = `img[alt="${defaultImage}"]`
) {
before(function () {
login(email)
waitForCompile(() => {
cy.visit(`/project/${projectId}`)
})
previousBinaryFiles.push(prepareFileUploadTest(true))
cy.log('check binary files')
for (const check of previousBinaryFiles) {
check()
}
cy.findByRole('treeitem', { name: defaultImage }).click()
cy.get(universeSelector)
.should('be.visible')
.and('have.prop', 'naturalWidth')
.should('be.greaterThan', 0)
avoid502()
})
}
if (Cypress.env('FULL_FILESTORE_MIGRATION')) {
// --------------
// Server Pro 1.x
startWith({
pro: true,
resetData: true,
withDataDir: true,
vars: sharelatexBrandedVars,
version: '1.2.4',
mongoVersion: '5.0',
})
defaultImage = 'universe.jpg'
let activateURL: string
before(async function () {
const { stdout } = await runGruntTask({
task: 'user:create-admin',
args: ['--email', email],
})
;[activateURL] = stdout.match(
/http:\/\/.+\/user\/password\/set\?passwordResetToken=\S+/
)!
})
before(function () {
activateUserVersion1x(activateURL)
login(email)
cy.visit('/project')
// Legacy angular based UI uses links instead of buttons
cy.findByRole('link', {
name: /Create First Project|New Project/,
}).click()
cy.findByRole('link', { name: 'Example Project' }).click()
cy.findByLabelText('Project name').type(projectName)
cy.findByRole('button', { name: 'Create' }).click()
cy.url()
.should('match', /\/project\/[a-fA-F0-9]{24}/)
.then(url => (projectId = url.split('/').pop()!))
let queueReset
;({ waitForCompile, queueReset } = prepareWaitForNextCompileSlot())
queueReset()
// Create a new binary file
cy.get(`a[tooltip="Upload"]`).click()
const name = `${uuid()}.txt`
// Binary file detection is not sophisticated in version 1.x
const binName = name.replace('.txt', '.bin')
const content = `Test File Content ${name} \x00`
cy.get('input[type=file]')
.first()
.selectFile(
{
contents: Cypress.Buffer.from(content),
fileName: binName,
lastModified: Date.now(),
},
{ force: true }
)
// Rename back to .txt to enable preview
cy.findByText(binName).click()
cy.findByText(binName).dblclick()
cy.focused().type(name + '{del}'.repeat('.bin'.length) + '{enter}')
// Switch back and forth
cy.findByText('universe.jpg').click()
cy.findByText(name).click()
cy.findByText(content)
.parent()
.parent()
.should('have.class', 'text-preview')
previousBinaryFiles.push(() => expectFileExists(name, true, content))
avoid502()
})
// --------------
// Server Pro 2.x
startWith({
pro: true,
withDataDir: true,
vars: sharelatexBrandedVars,
version: '2.7.1',
mongoVersion: '5.0',
})
before(function () {
// Cypress strips the Content-Length header: https://github.com/cypress-io/cypress/issues/16469
// Server Pro 2.x does not gracefully handle a missing value.
cy.intercept(
{
method: 'HEAD',
url: `http://sharelatex/project/${projectId}/file/*`,
times: previousBinaryFiles.length + 1,
},
req => {
req.continue(res => {
res.headers['Content-Length'] = '60'
})
}
)
})
// Server Pro 2.x does not have alt tags on images.
addNewBinaryFileAndCheckPrevious('img')
// ----------------------------------
// Server Pro 3.x + history migration
startWith({
pro: true,
withDataDir: true,
vars: sharelatexBrandedVars,
version: '3.5.13',
mongoVersion: '5.0',
})
addNewBinaryFileAndCheckPrevious() // before history migration
before(async function () {
await runScript({
cwd: 'services/web',
script: 'scripts/history/migrate_history.js',
args: [
'--force-clean',
'--fix-invalid-characters',
'--convert-large-docs-to-file',
],
hasOverleafEnv: false,
user: 'root',
})
})
before(async function () {
await runScript({
cwd: 'services/web',
script: 'scripts/history/clean_sl_history_data.js',
hasOverleafEnv: false,
})
})
addNewBinaryFileAndCheckPrevious() // after history migration
// ------------------------------
// Server Pro 4.x + mongo upgrade
startWith({
pro: true,
withDataDir: true,
vars: sharelatexBrandedVars,
version: '4.2.9',
mongoVersion: '5.0',
})
startWith({
pro: true,
withDataDir: true,
vars: sharelatexBrandedVars,
version: '4.2.9',
mongoVersion: '6.0',
})
before(async function () {
await setMongoFeatureCompatibilityVersion('6.0')
})
addNewBinaryFileAndCheckPrevious()
// ------------------------------------------
// Server Pro 5.x + mongo upgrade 6 -> 7 -> 8
startWith({
version: '5.5.5',
pro: true,
withDataDir: true,
mongoVersion: '6.0',
})
startWith({
version: '5.5.5',
pro: true,
withDataDir: true,
mongoVersion: '7.0',
})
before(async function () {
await setMongoFeatureCompatibilityVersion('7.0')
})
startWith({
version: '5.5.5',
pro: true,
withDataDir: true,
// implicit mongo upgrade to 8.0
})
before(async function () {
await setMongoFeatureCompatibilityVersion('8.0')
})
} else {
// 5.x
startWith({
version: '5.5.5',
pro: true,
withDataDir: true,
resetData: true,
})
defaultImage = 'frog.jpg'
ensureUserExists({ email })
before(function () {
login(email)
createProject(projectName, { type: 'Example project', open: false }).then(
id => (projectId = id)
)
;({ waitForCompile } = prepareWaitForNextCompileSlot())
})
}
addNewBinaryFileAndCheckPrevious()
function ensureStopOnFirstErrorIsActive() {
cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
cy.findByRole('menuitem', {
name: 'Stop on first error',
}).then(el => {
// NOTE: THIS IS BAD, but the selected option is otherwise not accessible :/
if (
el.get()[0]?.querySelector('.material-symbol')?.textContent !== 'check'
) {
cy.findByRole('menuitem', {
name: 'Stop on first error',
}).click()
// Clicking on "Stop on first error" closes the mode. Open it again.
cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
}
})
cy.findByRole('menuitem', {
name: 'Stop on first error',
}).within(() => {
cy.findByText('check').should('be.visible')
})
cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
}
// -------------------
// filestore-migration
beforeEach(function () {
login(email)
waitForCompile(() => {
openProjectById(projectId)
})
ensureStopOnFirstErrorIsActive()
})
function checkFilesAreAccessible() {
it('can upload new binary file and read previous uploads', function () {
previousBinaryFiles.push(prepareFileUploadTest(true))
for (const check of previousBinaryFiles) {
check()
}
})
it('renders image of example project', function () {
cy.findByTestId('file-tree').findByText(defaultImage).click()
cy.get(`[alt="${defaultImage}"]`)
.should('be.visible')
.and('have.prop', 'naturalWidth')
.should('be.greaterThan', 0)
})
it('can recompile from scratch', function () {
const id = uuid()
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle')
.parent()
.type(`\n\\section{{}Test Section ${id}}`)
waitForCompile(() => {
cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
cy.findByRole('menuitem', {
name: 'Recompile from scratch',
}).trigger('click')
})
cy.get('.pdf-viewer').should('contain.text', `Test Section ${id}`)
})
}
describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL not set', function () {
startWith({ version: '5.5.5', pro: true, withDataDir: true, vars: {} })
checkFilesAreAccessible()
})
describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=0', function () {
startWith({
version: '5.5.5',
pro: true,
withDataDir: true,
vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '0' },
})
checkFilesAreAccessible()
describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=1', function () {
startWith({
version: '5.5.5',
pro: true,
withDataDir: true,
vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '1' },
})
checkFilesAreAccessible()
describe('OVERLEAF_FILESTORE_MIGRATION_LEVEL=2', function () {
startWith({
version: '5.5.5',
pro: true,
withDataDir: true,
vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '1' },
})
before(async function () {
await runScript({
cwd: 'services/history-v1',
script: 'storage/scripts/back_fill_file_hash.mjs',
args: ['--all'],
})
})
startWith({
version: '5.5.5',
pro: true,
withDataDir: true,
vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '2' },
})
checkFilesAreAccessible()
describe('purge filestore data', function () {
before(async function () {
const deleted = await purgeFilestoreData()
expect(deleted).to.have.length.greaterThan(
previousBinaryFiles.length
)
expect(deleted).to.include(
"removed directory '/var/lib/overleaf/data/user_files'"
)
})
checkFilesAreAccessible()
describe('after next restart', function () {
startWith({
version: '5.5.5',
pro: true,
withDataDir: true,
vars: {
OVERLEAF_APP_NAME: 'change-config',
OVERLEAF_FILESTORE_MIGRATION_LEVEL: '2',
},
})
it('should not recreate the user_files folder', async function () {
expect(await purgeFilestoreData()).to.deep.equal([])
})
})
describe('latest', function () {
startWith({
pro: true,
withDataDir: true,
vars: { OVERLEAF_FILESTORE_MIGRATION_LEVEL: '2' },
})
it('should not recreate the user_files folder', async function () {
expect(await purgeFilestoreData()).to.deep.equal([])
})
checkFilesAreAccessible()
})
})
})
})
})
})
-458
View File
@@ -1,458 +0,0 @@
import { v4 as uuid } from 'uuid'
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import {
createProject,
createProjectAndOpenInNewEditor,
enableLinkSharing,
openProjectByName,
openProjectViaLinkSharingAsUser,
shareProjectByEmailAndAcceptInviteViaDash,
} from './helpers/project'
import git from 'isomorphic-git'
import http from 'isomorphic-git/http/web'
import LightningFS from '@isomorphic-git/lightning-fs'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
const USER = 'user@example.com'
describe('git-bridge', function () {
const ENABLED_VARS = {
GIT_BRIDGE_ENABLED: 'true',
GIT_BRIDGE_HOST: 'git-bridge',
GIT_BRIDGE_PORT: '8000',
V1_HISTORY_URL: 'http://sharelatex:3100/api',
}
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
startWith({
pro: true,
vars: ENABLED_VARS,
})
ensureUserExists({ email: USER })
function clearAllTokens() {
cy.findAllByRole('button', { name: 'Remove' })
.not('[disabled]')
.each($button => {
cy.wrap($button).click()
cy.findByRole('button', { name: 'Delete token' }).click()
})
cy.findByRole('dialog').should('not.exist')
}
function maybeClearAllTokens() {
cy.visit('/user/settings')
cy.findByRole('heading', { name: 'Git integration' })
cy.findByRole('button', {
name: /Generate token|Add another token/i,
}).then(btn => {
if (btn.text() === 'Add another token') {
clearAllTokens()
}
})
}
beforeEach(function () {
login(USER)
})
it('should render the git-bridge UI in the settings', function () {
maybeClearAllTokens()
cy.visit('/user/settings')
cy.findByRole('heading', { name: 'Git integration' })
cy.findByRole('button', {
name: 'Git integration Generate token',
}).click()
cy.findByRole('dialog').within(() => {
cy.findByLabelText('Git authentication token')
.contains(/olp_[a-zA-Z0-9]{16}/)
.then(el => el.text())
.as('newToken')
cy.findByRole('button', { name: 'Close dialog' }).click()
})
cy.get('@newToken').then(token => {
// There can be more than one token with the same prefix when retrying
cy.findAllByText(
`${token.slice(0, 'olp_1234'.length)}${'*'.repeat(12)}`
).should('have.length.at.least', 1)
})
cy.findByRole('button', {
name: 'Git integration Generate token',
}).should('not.exist')
cy.findByRole('button', { name: 'Add another token' }).should('exist')
clearAllTokens()
cy.findByRole('button', {
name: 'Git integration Generate token',
}).should('exist')
cy.findByRole('button', { name: 'Add another token' }).should('not.exist')
})
it('should render the git-bridge UI in the editor', function () {
maybeClearAllTokens()
createProjectAndOpenInNewEditor('git').as('projectId')
cy.findByRole('tab', { name: 'Integrations' }).click()
cy.findByText('Git clone this project.').click()
cy.findByTestId('git-bridge-modal').within(() => {
cy.get('@projectId').then(id => {
cy.findByLabelText('Git clone project command').contains(
`git clone ${gitURL(id.toString())}`
)
})
cy.findByRole('button', {
name: 'Generate token',
}).click()
cy.findByLabelText('Git authentication token').contains(
/olp_[a-zA-Z0-9]{16}/
)
})
// Re-open
cy.url().then(url => cy.visit(url))
cy.findByText('Git clone this project.').click()
cy.findByTestId('git-bridge-modal').within(() => {
cy.get('@projectId').then(id => {
cy.findByLabelText('Git clone project command').contains(
`git clone ${gitURL(id.toString())}`
)
})
cy.findByRole('button', {
name: 'Generate token',
}).should('not.exist')
cy.findByText(/generate a new one in Account settings/)
cy.findByRole('link', { name: 'Go to settings' })
.should('have.attr', 'target', '_blank')
.and('have.attr', 'href', '/user/settings')
})
})
describe('git access', function () {
ensureUserExists({ email: 'collaborator-rw@example.com' })
ensureUserExists({ email: 'collaborator-ro@example.com' })
ensureUserExists({ email: 'collaborator-link-rw@example.com' })
ensureUserExists({ email: 'collaborator-link-ro@example.com' })
let projectName: string
let recompile: () => void
let waitForCompile: (triggerCompile: () => void) => void
beforeEach(function () {
projectName = uuid()
createProject(projectName, { open: false }).as('projectId')
;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
})
it('should expose r/w interface to owner', function () {
maybeClearAllTokens()
waitForCompile(() => {
openProjectByName(projectName, true)
})
checkGitAccess('readAndWrite')
})
it('should expose r/w interface to invited r/w collaborator', function () {
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
'collaborator-rw@example.com',
'Editor',
true
)
maybeClearAllTokens()
waitForCompile(() => {
openProjectByName(projectName, true)
})
checkGitAccess('readAndWrite')
})
it('should expose r/o interface to invited r/o collaborator', function () {
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
'collaborator-ro@example.com',
'Viewer',
true
)
maybeClearAllTokens()
waitForCompile(() => {
openProjectByName(projectName, true)
})
checkGitAccess('readOnly')
})
it('should expose r/w interface to link-sharing r/w collaborator', function () {
openProjectByName(projectName, true)
enableLinkSharing().then(({ linkSharingReadAndWrite }) => {
const email = 'collaborator-link-rw@example.com'
login(email)
maybeClearAllTokens()
waitForCompile(() => {
openProjectViaLinkSharingAsUser(
linkSharingReadAndWrite,
projectName,
email,
true
)
})
checkGitAccess('readAndWrite')
})
})
it('should expose r/o interface to link-sharing r/o collaborator', function () {
waitForCompile(() => {
openProjectByName(projectName, true)
})
enableLinkSharing().then(({ linkSharingReadOnly }) => {
const email = 'collaborator-link-ro@example.com'
login(email)
maybeClearAllTokens()
waitForCompile(() => {
openProjectViaLinkSharingAsUser(
linkSharingReadOnly,
projectName,
email,
true
)
})
checkGitAccess('readOnly')
})
})
function checkGitAccess(access: 'readOnly' | 'readAndWrite') {
cy.findByRole('tab', { name: 'Integrations' }).click()
cy.findByText('Git clone this project.').click()
cy.get('@projectId').then(projectId => {
cy.findByTestId('git-bridge-modal').within(() => {
cy.findByLabelText('Git clone project command').contains(
`git clone ${gitURL(projectId.toString())}`
)
cy.findByRole('heading', { name: 'Clone with Git' })
cy.findByRole('button', {
name: 'Generate token',
}).click()
})
cy.findByLabelText('Git authentication token')
.contains(/olp_[a-zA-Z0-9]{16}/)
.then(async tokenEl => {
const token = tokenEl.text()
// close Git modal
cy.findByRole('button', { name: 'Close dialog' }).click()
cy.findByTestId('git-bridge-modal').should('not.exist')
// close the modal
cy.get('body').type('{esc}')
cy.findByTestId('left-menu').should('not.exist')
const fs = new LightningFS('fs')
const dir = `/${projectId}`
async function readFile(path: string): Promise<string> {
return await new Promise((resolve, reject) => {
fs.readFile(path, { encoding: 'utf8' }, (err, blob) => {
if (err) return reject(err)
resolve(blob as string)
})
})
}
async function writeFile(path: string, data: string) {
return await new Promise<void>((resolve, reject) => {
fs.writeFile(path, data, undefined, err => {
if (err) return reject(err)
resolve()
})
})
}
const commonOptions = {
dir,
fs,
}
const url = gitURL(projectId.toString())
url.username = '' // basic auth is specified separately.
const httpOptions = {
http,
url: url.toString(),
headers: {
Authorization: `Basic ${Buffer.from(`git:${token}`).toString('base64')}`,
},
}
const authorOptions = {
author: { name: 'user', email: USER },
committer: { name: 'user', email: USER },
}
const mainTex = `${dir}/main.tex`
// Clone
cy.then({ timeout: 10_000 }, async () => {
await git.clone({
...commonOptions,
...httpOptions,
})
})
cy.findByText(/\\documentclass/)
.parent()
.parent()
.then(async editor => {
const onDisk = await readFile(mainTex)
expect(onDisk.replaceAll('\n', '')).to.equal(editor.text())
})
const text = `
\\documentclass{article}
\\begin{document}
Hello world
\\end{document}
`
// Make a change
cy.then(async () => {
await writeFile(mainTex, text)
await git.add({
...commonOptions,
filepath: 'main.tex',
})
await git.commit({
...commonOptions,
...authorOptions,
message: 'Swap main.tex',
})
})
if (access === 'readAndWrite') {
// check history before push
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'History' })
.click()
cy.findByText('(via Git)').should('not.exist')
cy.findAllByText('Back to editor').last().click()
cy.then(async () => {
await git.push({
...commonOptions,
...httpOptions,
})
})
} else {
cy.then(async () => {
try {
await git.push({
...commonOptions,
...httpOptions,
})
expect.fail('push should have failed')
} catch (err) {
expect(err).to.match(/branches were not updated/)
expect(err).to.match(/forbidden/)
}
})
return // return early, below are write access bits
}
// check push in editor
cy.findByText(/\\documentclass/)
.parent()
.parent()
.should('have.text', text.replaceAll('\n', ''))
// Wait for history sync - trigger flush by toggling the UI
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'History' })
.click()
cy.findAllByText('Back to editor').last().click()
// check push in history
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'History' })
.click()
cy.findByText(/Hello world/)
cy.findByText('(via Git)').should('exist')
// Back to the editor
cy.findAllByText('Back to editor').last().click()
cy.findByText(/\\documentclass/)
.parent()
.parent()
.as('editor')
.click()
cy.get('@editor').type('% via editor{enter}')
// Trigger flush via compile
recompile()
// Back into the history, check what we just added
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'History' })
.click()
cy.findByText(/% via editor/)
// Pull the change
cy.then(async () => {
await git.pull({
...commonOptions,
...httpOptions,
...authorOptions,
})
expect(await readFile(mainTex)).to.equal(
text + '% via editor\n'
)
})
})
})
}
})
})
function checkDisabled() {
ensureUserExists({ email: USER })
it('should not render the git-bridge UI in the settings', function () {
login(USER)
cy.visit('/user/settings')
cy.findByRole('heading', { name: 'Git integration' }).should('not.exist')
})
it('should not render the git-bridge UI in the editor', function () {
login(USER)
createProjectAndOpenInNewEditor('maybe git')
cy.findByRole('tab', {
name: 'File tree',
}).should('exist') // Wait for load
cy.findByRole('tab', {
name: 'Integrations',
}).should('not.exist')
})
}
describe('disabled in Server Pro', function () {
if (isExcludedBySharding('PRO_DEFAULT_1')) return
startWith({
pro: true,
})
checkDisabled()
})
describe('unavailable in CE', function () {
if (isExcludedBySharding('CE_CUSTOM_1')) return
startWith({
pro: false,
vars: ENABLED_VARS,
})
checkDisabled()
})
})
-109
View File
@@ -1,109 +0,0 @@
import { ensureUserExists, login } from './helpers/login'
import {
isExcludedBySharding,
STARTUP_TIMEOUT,
startWith,
} from './helpers/config'
import { dockerCompose, getRedisKeys } from './helpers/hostAdminClient'
import { createProjectAndOpenInNewEditor } from './helpers/project'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
const USER = 'user@example.com'
const PROJECT_NAME = 'Old Project'
function bringServerProBackUp() {
cy.log('bring server pro back up')
cy.then({ timeout: STARTUP_TIMEOUT }, async () => {
await dockerCompose('up', '--detach', '--wait', 'sharelatex')
})
}
describe('GracefulShutdown', function () {
if (isExcludedBySharding('PRO_CUSTOM_1')) return
startWith({
pro: true,
withDataDir: true,
resetData: true,
})
ensureUserExists({ email: USER })
let projectId: string
it('should display banner and flush changes out of redis', function () {
bringServerProBackUp()
login(USER)
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor(PROJECT_NAME).then(id => {
projectId = id
})
})
cy.log('add additional content')
cy.findByRole('region', { name: 'Editor' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type(`\n\\section{{}New Section}`)
})
recompile()
cy.log(
'check flush from frontend to backend: should include new section in PDF'
)
cy.findByRole('region', { name: 'PDF preview' }).should(
'contain.text',
'New Section'
)
cy.log('should have unflushed content in redis before shutdown')
cy.then(async () => {
const keys = await getRedisKeys()
expect(keys).to.contain(`DocsIn:${projectId}`)
expect(keys).to.contain(`ProjectHistory:Ops:{${projectId}}`)
})
cy.log('trigger graceful shutdown')
let pendingShutdown: Promise<any>
cy.then(() => {
pendingShutdown = dockerCompose('stop', '--timeout=60', 'sharelatex')
})
cy.log('wait for banner')
cy.findByRole('dialog').findByText(/performing maintenance/)
cy.log('wait for page reload')
cy.findByRole('heading', { name: 'Maintenance' })
cy.findByText(/is currently down for maintenance/)
cy.log('wait for shutdown to complete')
cy.then({ timeout: 60 * 1000 }, async () => {
await pendingShutdown
})
cy.log('should not have any unflushed content in redis after shutdown')
cy.then(async () => {
const keys = await getRedisKeys()
expect(keys).to.not.contain(`DocsIn:${projectId}`)
expect(keys).to.not.contain(`ProjectHistory:Ops:{${projectId}}`)
})
bringServerProBackUp()
cy.then(() => {
cy.visit(`/project/${projectId}?trick-cypress-into-page-reload=true`)
})
cy.log('check loading doc from mongo')
cy.findByRole('region', { name: 'Editor' }).findByText('New Section')
cy.log('check PDF')
cy.findByRole('region', { name: 'PDF preview' }).should(
'contain.text',
'New Section'
)
cy.log('check history')
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'History' })
.click()
cy.findByText(/\\section\{New Section}/)
})
})
@@ -1,8 +0,0 @@
export function beforeWithReRunOnTestRetry(fn: () => void | Promise<any>) {
let ranOnce = false
beforeEach(function () {
if (ranOnce && Cypress.currentRetry === 0) return
ranOnce = true
return fn()
})
}
-72
View File
@@ -1,72 +0,0 @@
/**
* 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 stopCompile(options: { delay?: number } = {}) {
const { delay = 0 } = options
cy.wait(delay)
cy.log('Stop compile')
cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
cy.findByRole('menuitem', { name: 'Stop compilation' })
.should('not.have.class', 'disabled')
.and('not.have.attr', 'aria-disabled', 'true')
.click()
}
export function prepareWaitForNextCompileSlot() {
let lastCompile = 0
function queueReset() {
cy.then(() => {
lastCompile = Date.now()
})
}
function waitForCompileRateLimitCoolOff() {
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()
})
}
function waitForCompile(triggerCompile: () => void) {
waitForCompileRateLimitCoolOff()
cy.then(() => {
let compilingVisible: () => void
const waitForCompilingVisible = new Promise<void>(resolve => {
compilingVisible = resolve
})
cy.intercept(
{
method: 'POST',
pathname: /\/project\/[a-fA-F0-9]{24}\/compile$/,
times: 1,
},
async req => {
await waitForCompilingVisible
req.continue()
}
).as('recompile')
triggerCompile()
cy.log('Wait for compile to finish')
cy.findByRole('button', { name: 'Compiling…' }).then(() =>
compilingVisible()
)
cy.wait('@recompile')
cy.findByRole('button', { name: 'Compiling…' }).should('not.exist')
cy.findByRole('button', { name: 'Recompile' }).should('be.visible')
})
}
function recompile() {
waitForCompile(() => {
cy.findByRole('button', { name: 'Recompile' }).click()
})
}
return {
queueReset,
waitForCompileRateLimitCoolOff,
waitForCompile,
recompile,
}
}
-114
View File
@@ -1,114 +0,0 @@
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:
| 'LOCAL_ONLY'
| 'CE_DEFAULT'
| 'CE_CUSTOM_1'
| 'PRO_DEFAULT_1'
| 'PRO_DEFAULT_2'
| 'PRO_CUSTOM_1'
| 'PRO_CUSTOM_2'
| 'PRO_CUSTOM_3'
| 'PRO_CUSTOM_4'
| 'PRO_CUSTOM_5'
) {
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,
mongoVersion = '',
}) {
before(async function () {
Object.assign(vars, varsFn())
const cfg = JSON.stringify({
pro,
version,
vars,
withDataDir,
resetData,
mongoVersion,
})
if (resetData) {
cy.log('resetting data and sessions')
resetCreatedUsersCache()
resetActivateUserRateLimit()
// no return here, always reconfigure when resetting data
} else if (previousConfigFrontend === cfg) {
cy.log(`already running with ${cfg}`)
return
}
cy.log(`starting with ${cfg}`)
this.timeout(STARTUP_TIMEOUT)
previousConfigFrontend = ''
const { previousConfigServer } = await reconfigure({
pro,
version,
vars,
withDataDir,
resetData,
mongoVersion,
})
if (previousConfigServer !== cfg) {
await Cypress.session.clearAllSavedSessions()
}
previousConfigFrontend = cfg
})
}
// Allow reloading the server in other places, e.g. beforeEach hooks.
export async function reloadWith({
pro = false,
version = 'latest',
vars = {},
varsFn = () => ({}),
withDataDir = false,
resetData = false,
mongoVersion = '',
}) {
Object.assign(vars, varsFn())
const cfg = JSON.stringify({
pro,
version,
vars,
withDataDir,
resetData,
mongoVersion,
})
if (resetData) {
resetCreatedUsersCache()
resetActivateUserRateLimit()
// no return here, always reconfigure when resetting data
} else if (previousConfigFrontend === cfg) {
return
}
previousConfigFrontend = ''
const { previousConfigServer } = await reconfigure({
pro,
version,
vars,
withDataDir,
resetData,
mongoVersion,
})
if (previousConfigServer !== cfg) {
await Cypress.session.clearAllSavedSessions()
}
previousConfigFrontend = cfg
}
export { reconfigure }
-44
View File
@@ -1,44 +0,0 @@
/**
* 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<Cypress.JQueryWithSelector<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')
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(1000)
cy.get('iframe[id="messagecontframe"]').then(frame => {
// runnerS='(frame, args) => { runner body }'. Extract the runnable function.
// eslint-disable-next-line no-new-func
const runner = new Function('return ' + runnerS)()
runner(cy.wrap(frame.prop('contentWindow').document.body), args)
})
}
)
}
-136
View File
@@ -1,136 +0,0 @@
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,
mongoVersion = '',
}): Promise<{ previousConfigServer: string }> {
return await fetchJSON(`${hostAdminURL}/reconfigure`, {
method: 'POST',
body: JSON.stringify({
pro,
version,
vars,
withDataDir,
resetData,
mongoVersion,
}),
})
}
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 = [],
user = 'www-data',
hasOverleafEnv = true,
}: {
cwd: string
script: string
args?: string[]
user?: string
hasOverleafEnv?: boolean
}) {
return await fetchJSON(`${hostAdminURL}/run/script`, {
method: 'POST',
body: JSON.stringify({
cwd,
script,
args,
user,
hasOverleafEnv,
}),
})
}
export async function runGruntTask({
task,
args = [],
}: {
task: string
args?: string[]
}) {
return await fetchJSON(`${hostAdminURL}/run/gruntTask`, {
method: 'POST',
body: JSON.stringify({
task,
args,
}),
})
}
export async function getRedisKeys() {
const { stdout } = await fetchJSON(`${hostAdminURL}/redis/keys`, {
method: 'GET',
})
return stdout.split('\n')
}
export async function setMongoFeatureCompatibilityVersion(
mongoVersion: string
) {
cy.log(`advancing mongo featureCompatibilityVersion to ${mongoVersion}`)
await fetchJSON(`${hostAdminURL}/mongo/setFeatureCompatibilityVersion`, {
method: 'POST',
body: JSON.stringify({
mongoVersion,
}),
})
}
export async function purgeFilestoreData() {
const { stdout } = await fetchJSON(`${hostAdminURL}/data/user_files`, {
method: 'DELETE',
})
if (!stdout.trim()) return []
return stdout.trim().split('\n')
}
async function sleep(ms: number) {
return await new Promise(resolve => {
setTimeout(resolve, ms)
})
}
-113
View File
@@ -1,113 +0,0 @@
import { runScript } from './hostAdminClient'
export 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(
['via-login', 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')
})
})
}
-402
View File
@@ -1,402 +0,0 @@
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' | 'Reviewer',
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' | 'Reviewer',
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()
})
}
-61
View File
@@ -1,61 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'
import AdmZip from 'adm-zip'
import { setTimeout } from 'node:timers/promises'
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 setTimeout(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 pdf = await getDocument(file).promise
const text = []
try {
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const content = await page.getTextContent()
for (const item of content.items) {
if ('str' in item) text.push(item.str)
}
}
return text.join('\n')
} finally {
await pdf.destroy()
}
}
await setTimeout(POLL_INTERVAL)
attempt++
}
throw new Error(`${file} not found`)
}
@@ -1,39 +0,0 @@
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()
})
})
}
-164
View File
@@ -1,164 +0,0 @@
import { createProjectAndOpenInNewEditor } from './helpers/project'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
import { ensureUserExists, login } from './helpers/login'
import { isExcludedBySharding, startWith } from './helpers/config'
describe('History', function () {
if (isExcludedBySharding('CE_DEFAULT')) return
startWith({})
ensureUserExists({ email: 'user@example.com' })
beforeEach(function () {
login('user@example.com')
})
function addLabel(name: string) {
cy.log(`add label ${JSON.stringify(name)}`)
// The input is not clickable due to being visually hidden, click its label instead
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findByRole('group', {
name: 'Show all of the project history or only labelled versions.',
}).within(() => {
cy.findByText('Labels').click()
})
cy.findByRole('radio', { name: 'Labels' }).should('be.checked')
cy.findByRole('radio', { name: 'All history' }).should('not.be.checked')
cy.findAllByTestId('history-version-details')
.first()
.within(() => {
cy.findByRole('button', { name: 'More actions' }).click()
cy.findByRole('menuitem', { name: 'Label this version' }).click()
})
})
cy.findByRole('dialog').within(() => {
cy.findByLabelText('New label name').type(`${name}{enter}`)
})
}
function downloadVersion(name: string) {
cy.log(`download version ${JSON.stringify(name)}`)
// The input is not clickable due to being visually hidden, click its label instead
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findByRole('group', {
name: 'Show all of the project history or only labelled versions.',
}).within(() => {
cy.findByText('Labels').click()
})
cy.findByRole('radio', { name: 'Labels' }).should('be.checked')
cy.findByRole('radio', { name: 'All history' }).should('not.be.checked')
cy.findByText(name)
.closest('[data-testid="history-version-details"]')
.within(() => {
cy.findByRole('button', { name: 'More actions' }).click()
cy.findByRole('menuitem', { name: 'Download this version' }).click()
})
})
}
const CLASS_ADDITION = 'ol-cm-addition-marker'
const CLASS_DELETION = 'ol-cm-deletion-marker'
it('should support labels, comparison and download', function () {
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('labels')
})
cy.log('add content, including a line that will get removed soon')
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type('\n% added')
cy.findByText('\\maketitle').parent().type('\n% to be removed')
})
recompile()
cy.findByRole('button', { name: 'History' }).click()
cy.log('expect to see additions in history')
cy.get('.document-diff-container').within(() => {
cy.findByText('% to be removed').should('have.class', CLASS_ADDITION)
cy.findByText('% added').should('have.class', CLASS_ADDITION)
})
addLabel('Before removal')
cy.log('remove content')
cy.findByRole('button', { name: 'Back to editor' }).click()
cy.findByText('% to be removed').parent().type('{end}{shift}{upArrow}{del}')
recompile()
cy.findByRole('button', { name: 'History' }).click()
cy.log('expect to see annotation for newly removed content in history')
cy.get('.document-diff-container').within(() => {
cy.findByText('% to be removed').should('have.class', CLASS_DELETION)
cy.findByText('% added').should('not.have.class', CLASS_ADDITION)
})
addLabel('After removal')
cy.log('add more content after labeling')
cy.findByRole('button', { name: 'Back to editor' }).click()
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type('\n% more')
})
recompile()
cy.log('compare non current versions')
cy.findByRole('button', { name: 'History' }).click()
// The input is not clickable due to being visually hidden, click its label instead
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findByRole('group', {
name: 'Show all of the project history or only labelled versions.',
}).within(() => {
cy.findByText('Labels').click()
})
cy.findByRole('radio', { name: 'Labels' }).should('be.checked')
cy.findByRole('radio', { name: 'All history' }).should('not.be.checked')
cy.findAllByTestId('compare-icon-version').last().click()
cy.findAllByTestId('compare-icon-version').filter(':visible').click()
cy.findByRole('menuitem', {
name: 'Compare up to this version',
}).click()
})
cy.log(
'expect to see annotation for removed content between the two versions'
)
cy.get('.document-diff-container').within(() => {
cy.findByText('% to be removed').should('have.class', CLASS_DELETION)
cy.findByText('% added').should('not.have.class', CLASS_ADDITION)
cy.findByText('% more').should('not.exist')
})
downloadVersion('Before removal')
cy.task('readFileInZip', {
pathToZip: `cypress/downloads/labels (Version 2).zip`,
fileToRead: 'main.tex',
})
.should('contain', '% added')
.should('contain', '% to be removed')
.should('not.contain', '% more')
downloadVersion('After removal')
cy.task('readFileInZip', {
pathToZip: `cypress/downloads/labels (Version 3).zip`,
fileToRead: 'main.tex',
})
.should('contain', '% added')
.should('not.contain', '% to be removed')
.should('not.contain', '% more')
downloadVersion('Current state')
cy.task('readFileInZip', {
pathToZip: `cypress/downloads/labels (Version 4).zip`,
fileToRead: 'main.tex',
})
.should('contain', '% added')
.should('not.contain', '% to be removed')
.should('contain', '% more')
})
})
-494
View File
@@ -1,494 +0,0 @@
import fs from 'node:fs'
import Path from 'node:path'
import { promisify } from 'node:util'
import { execFile as execFileCb } from 'node:child_process'
import bodyParser from 'body-parser'
import express from 'express'
import YAML from 'js-yaml'
import {
InvalidParamsError,
InvalidRequestError,
parseReq,
z,
} from '@overleaf/validation-tools'
import { expressify } from '@overleaf/promise-utils'
const execFile = promisify(execFileCb)
const DATA_DIR = Path.join(
import.meta.dirname,
'data',
// Give each shard their own data dir.
process.env.CYPRESS_SHARD || 'default'
)
const PATHS = {
DOCKER_COMPOSE_FILE: 'docker-compose.yml',
// Give each shard their own override file.
DOCKER_COMPOSE_OVERRIDE: `docker-compose.${process.env.CYPRESS_SHARD || 'override'}.yml`,
DOCKER_COMPOSE_NATIVE: 'docker-compose.native.yml',
DATA_DIR,
SANDBOXED_COMPILES_HOST_DIR: Path.join(DATA_DIR, 'compiles'),
}
const IMAGES = {
CE: process.env.IMAGE_TAG_CE.replace(/:.+/, ''),
PRO: process.env.IMAGE_TAG_PRO.replace(/:.+/, ''),
GIT_BRIDGE: process.env.IMAGE_TAG_GIT_BRIDGE.replace(/:.+/, ''),
}
const LATEST = {
CE: process.env.IMAGE_TAG_CE.replace(/.+:/, '') || 'latest',
PRO: process.env.IMAGE_TAG_PRO.replace(/.+:/, '') || 'latest',
GIT_BRIDGE: process.env.IMAGE_TAG_GIT_BRIDGE.replace(/.+:/, '') || 'latest',
}
function defaultDockerComposeOverride() {
return {
services: {
sharelatex: {
environment: {},
},
'git-bridge': {},
mongo: {},
},
}
}
let previousConfig = ''
function readDockerComposeOverride() {
try {
return YAML.load(fs.readFileSync(PATHS.DOCKER_COMPOSE_OVERRIDE, 'utf-8'))
} catch (error) {
if (error.code !== 'ENOENT') {
throw error
}
return defaultDockerComposeOverride()
}
}
function writeDockerComposeOverride(cfg) {
fs.writeFileSync(PATHS.DOCKER_COMPOSE_OVERRIDE, YAML.dump(cfg))
}
async function runDockerCompose(command, args) {
const files = ['-f', PATHS.DOCKER_COMPOSE_FILE]
if (process.env.NATIVE_CYPRESS) {
files.push('-f', PATHS.DOCKER_COMPOSE_NATIVE)
}
if (fs.existsSync(PATHS.DOCKER_COMPOSE_OVERRIDE)) {
files.push('-f', PATHS.DOCKER_COMPOSE_OVERRIDE)
}
return await execFile('docker', ['compose', ...files, command, ...args])
}
function purgeDataDir() {
fs.rmSync(PATHS.DATA_DIR, { recursive: true, force: true })
}
const app = express()
app.get('/status', (req, res) => {
res.send('host-admin is up')
})
app.use(bodyParser.json())
app.use((req, res, next) => {
// Basic access logs
if (process.env.CI !== 'true') {
console.log(req.method, req.url, req.body)
}
const json = res.json
res.json = body => {
if (process.env.CI !== 'true' || body.error) {
console.log(req.method, req.url, req.body, '->', body)
}
json.call(res, body)
}
next()
})
app.use((req, res, next) => {
// Add CORS headers
const accessControlAllowOrigin =
process.env.ACCESS_CONTROL_ALLOW_ORIGIN || 'http://sharelatex'
res.setHeader('Access-Control-Allow-Origin', accessControlAllowOrigin)
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
res.setHeader('Access-Control-Max-Age', '3600')
res.setHeader('Access-Control-Allow-Methods', 'DELETE, GET, HEAD, POST, PUT')
next()
})
app.post(
'/run/script',
expressify(async (req, res) => {
const {
body: { cwd, script, args, user, hasOverleafEnv },
} = parseReq(
req,
z.object({
body: z.object({
cwd: z.string(),
script: z.string(),
args: z.array(z.string()),
user: z.string(),
hasOverleafEnv: z.boolean(),
}),
})
)
const env = hasOverleafEnv
? 'source /etc/overleaf/env.sh || source /etc/sharelatex/env.sh'
: 'true'
try {
const { stdout, stderr } = await runDockerCompose('exec', [
'--workdir',
`/overleaf/${cwd}`,
'sharelatex',
'bash',
'-c',
`source /etc/container_environment.sh && ${env} && /sbin/setuser ${user} node ${script} ${args.map(a => JSON.stringify(a)).join(' ')}`,
])
res.json({
stdout,
stderr,
})
} catch (error) {
return res.json({ error })
}
})
)
app.post(
'/run/gruntTask',
expressify(async (req, res) => {
const {
body: { task, args },
} = parseReq(
req,
z.object({
body: z.object({
task: z.string(),
args: z.array(z.string()),
}),
})
)
try {
const { stdout, stderr } = await runDockerCompose('exec', [
'--workdir',
'/var/www/sharelatex',
'sharelatex',
'bash',
'-c',
`source /etc/container_environment.sh && /sbin/setuser www-data grunt ${JSON.stringify(task)} ${args.map(a => JSON.stringify(a)).join(' ')}`,
])
res.json({ stdout, stderr })
} catch (error) {
return res.json({ error })
}
})
)
const allowedVars = z.object(
Object.fromEntries(
[
'OVERLEAF_APP_NAME',
'OVERLEAF_LEFT_FOOTER',
'OVERLEAF_RIGHT_FOOTER',
'OVERLEAF_PROXY_LEARN',
'GIT_BRIDGE_ENABLED',
'GIT_BRIDGE_HOST',
'GIT_BRIDGE_PORT',
'V1_HISTORY_URL',
'SANDBOXED_COMPILES',
'ALL_TEX_LIVE_DOCKER_IMAGE_NAMES',
'OVERLEAF_FILESTORE_MIGRATION_LEVEL',
'OVERLEAF_TEMPLATES_USER_ID',
'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS',
'OVERLEAF_ALLOW_PUBLIC_ACCESS',
'OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING',
'OVERLEAF_DISABLE_LINK_SHARING',
'EXTERNAL_AUTH',
'OVERLEAF_SAML_ENTRYPOINT',
'OVERLEAF_SAML_CALLBACK_URL',
'OVERLEAF_SAML_ISSUER',
'OVERLEAF_SAML_IDENTITY_SERVICE_NAME',
'OVERLEAF_SAML_EMAIL_FIELD',
'OVERLEAF_SAML_FIRST_NAME_FIELD',
'OVERLEAF_SAML_LAST_NAME_FIELD',
'OVERLEAF_SAML_UPDATE_USER_DETAILS_ON_LOGIN',
'OVERLEAF_SAML_CERT',
'OVERLEAF_LDAP_URL',
'OVERLEAF_LDAP_SEARCH_BASE',
'OVERLEAF_LDAP_SEARCH_FILTER',
'OVERLEAF_LDAP_BIND_DN',
'OVERLEAF_LDAP_BIND_CREDENTIALS',
'OVERLEAF_LDAP_EMAIL_ATT',
'OVERLEAF_LDAP_NAME_ATT',
'OVERLEAF_LDAP_LAST_NAME_ATT',
'OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN',
// Old branding, used for upgrade tests
'SHARELATEX_SITE_URL',
'SHARELATEX_MONGO_URL',
'SHARELATEX_REDIS_HOST',
].map(name => [name, z.string().optional()])
)
)
function setVarsDockerCompose({
pro,
vars,
version,
withDataDir,
mongoVersion,
}) {
const cfg = readDockerComposeOverride()
cfg.services.sharelatex.image = `${pro ? IMAGES.PRO : IMAGES.CE}:${version === 'latest' ? (pro ? LATEST.PRO : LATEST.CE) : version}`
cfg.services['git-bridge'].image =
`${IMAGES.GIT_BRIDGE}:${version === 'latest' ? LATEST.GIT_BRIDGE : version}`
cfg.services.sharelatex.environment = vars
if (cfg.services.sharelatex.environment.GIT_BRIDGE_ENABLED === 'true') {
cfg.services.sharelatex.depends_on = ['git-bridge']
} else {
cfg.services.sharelatex.depends_on = []
}
if (['ldap', 'saml'].includes(vars.EXTERNAL_AUTH)) {
cfg.services.sharelatex.depends_on.push(vars.EXTERNAL_AUTH)
}
const dataDirInContainer =
version === 'latest' || version >= '5.0'
? '/var/lib/overleaf'
: '/var/lib/sharelatex'
cfg.services.sharelatex.volumes = []
if (withDataDir) {
cfg.services.sharelatex.volumes.push(
`${PATHS.DATA_DIR}:${dataDirInContainer}`
)
}
if (cfg.services.sharelatex.environment.SANDBOXED_COMPILES === 'true') {
cfg.services.sharelatex.environment.SANDBOXED_COMPILES_HOST_DIR =
PATHS.SANDBOXED_COMPILES_HOST_DIR
cfg.services.sharelatex.environment.TEX_LIVE_DOCKER_IMAGE =
process.env.TEX_LIVE_DOCKER_IMAGE
cfg.services.sharelatex.environment.ALL_TEX_LIVE_DOCKER_IMAGES =
process.env.ALL_TEX_LIVE_DOCKER_IMAGES
cfg.services.sharelatex.volumes.push(
'/var/run/docker.sock:/var/run/docker.sock'
)
if (!withDataDir) {
cfg.services.sharelatex.volumes.push(
`${PATHS.SANDBOXED_COMPILES_HOST_DIR}:${dataDirInContainer}/data/compiles`
)
}
}
if (mongoVersion) {
cfg.services.mongo.image = `mongo:${mongoVersion}`
} else {
delete cfg.services.mongo.image
}
if (version === 'latest') {
cfg.services.mongo.command = '--replSet overleaf --notablescan'
} else {
delete cfg.services.mongo.command
}
writeDockerComposeOverride(cfg)
}
app.post(
'/docker/compose/:cmd',
expressify(async (req, res) => {
const {
params: { cmd },
body: { args },
} = parseReq(
req,
z.object({
params: z.object({
cmd: z.literal(['up', 'stop', 'down', 'ps', 'logs']),
}),
body: z.object({
args: z.array(
z.literal([
'--detach',
'--wait',
'--volumes',
'--timeout=60',
'sharelatex',
'git-bridge',
'mongo',
'redis',
])
),
}),
})
)
try {
const { stdout, stderr } = await runDockerCompose(cmd, args)
res.json({ stdout, stderr })
} catch (error) {
return res.json({ error })
}
})
)
async function maybeResetData(resetData) {
if (!resetData) return
previousConfig = ''
await runDockerCompose('down', [
'--timeout=0',
'--volumes',
'mongo',
'redis',
'sharelatex',
])
purgeDataDir()
}
app.post(
'/reconfigure',
expressify(async (req, res) => {
const {
body: { pro, version, vars, withDataDir, resetData, mongoVersion },
} = parseReq(
req,
z.object({
body: z.object({
pro: z.boolean(),
version: z.string(),
vars: allowedVars,
withDataDir: z.boolean(),
resetData: z.boolean(),
mongoVersion: z.string(),
}),
})
)
try {
await maybeResetData(resetData)
} catch (error) {
return res.json({ error })
}
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 })
}
try {
const { stdout, stderr } = await runDockerCompose('up', [
'--detach',
'--wait',
'sharelatex',
])
res.json({ stdout, stderr, previousConfigServer })
} catch (error) {
return res.json({
error,
previousConfigServer,
})
} finally {
previousConfig = newConfig
}
})
)
app.post(
'/mongo/setFeatureCompatibilityVersion',
expressify(async (req, res) => {
const {
body: { mongoVersion },
} = parseReq(
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
}
try {
const { stdout, stderr } = await runDockerCompose('exec', [
'mongo',
mongosh,
'--eval',
`db.adminCommand(${JSON.stringify(params)})`,
])
res.json({ stdout, stderr })
} catch (error) {
return res.json({ error })
}
})
)
app.get(
'/redis/keys',
expressify(async (req, res) => {
try {
const { stdout, stderr } = await runDockerCompose('exec', [
'redis',
'redis-cli',
'KEYS',
'*',
])
res.json({ stdout, stderr })
} catch (error) {
return res.json({ error })
}
})
)
app.delete(
'/data/user_files',
expressify(async (req, res) => {
try {
const { stdout, stderr } = await runDockerCompose('exec', [
'sharelatex',
'rm',
'-vrf',
'/var/lib/overleaf/data/user_files',
])
res.json({ stdout, stderr })
} catch (error) {
return res.json({ error })
}
})
)
app.use((error, req, res, next) => {
if (error instanceof InvalidParamsError) {
res.status(404).json({ error })
} else if (error instanceof InvalidRequestError) {
res.status(400).json({ error: error.zodError })
} else {
next(error)
}
})
purgeDataDir()
writeDockerComposeOverride(defaultDockerComposeOverride())
app.listen(80)
-123
View File
@@ -1,123 +0,0 @@
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
describe('LearnWiki', function () {
const COPYING_A_PROJECT_URL = '/learn/how-to/Copying_a_project'
const UPLOADING_A_PROJECT_URL = '/learn/how-to/Uploading_a_project'
const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com'
const REGULAR_USER = 'user@example.com'
// Re-use value for "exists" and "does not exist" tests
const LABEL_LEARN_LATEX = 'Learn LaTeX with a tutorial'
ensureUserExists({ email: WITHOUT_PROJECTS_USER })
ensureUserExists({ email: REGULAR_USER })
describe('enabled in Pro', function () {
if (isExcludedBySharding('PRO_CUSTOM_2')) return
startWith({
pro: true,
vars: {
OVERLEAF_PROXY_LEARN: 'true',
},
})
it('should add a documentation entry to the nav bar', function () {
login(REGULAR_USER)
cy.visit('/project')
cy.findByRole('navigation', { name: 'Primary' }).findByRole('menuitem', {
name: 'Documentation',
})
})
it('should display a tutorial link in the welcome page', function () {
login(WITHOUT_PROJECTS_USER)
cy.visit('/project')
cy.findByRole('link', { name: LABEL_LEARN_LATEX }).should(
'have.attr',
'href',
'/learn/latex/Learn_LaTeX_in_30_minutes'
)
})
it('should render wiki page', function () {
login(REGULAR_USER)
cy.visit(UPLOADING_A_PROJECT_URL)
cy.findByRole('heading', { name: 'Uploading a project' })
cy.contains(/how to create an Overleaf project/)
cy.findByRole('img', { name: 'Creating a new project on Overleaf' })
.should('be.visible')
.and((el: any) => {
expect(el[0].naturalWidth, 'renders image').to.be.greaterThan(0)
})
cy.visit(COPYING_A_PROJECT_URL)
cy.findByRole('heading', { name: 'Copying a project' })
cy.findByRole('link', {
name: '1 How to copy a project (option 1)',
}).should('exist')
cy.findByRole('link', {
name: '2 How to copy a project (option 2)',
}).should('exist')
})
it('should navigate within wiki page using table of contents', function () {
login(REGULAR_USER)
cy.visit(COPYING_A_PROJECT_URL)
cy.findByRole('heading', { name: 'Copying a project' })
cy.findByRole('link', {
name: '2 How to copy a project (option 2)',
}).click()
cy.url().should('contain', COPYING_A_PROJECT_URL)
cy.findByRole('heading', {
name: 'How to copy a project (option 2)',
}).should('be.visible')
})
})
describe('disabled in Pro', function () {
if (isExcludedBySharding('PRO_DEFAULT_1')) return
startWith({ pro: true })
checkDisabled()
})
describe('unavailable in CE', function () {
if (isExcludedBySharding('CE_CUSTOM_1')) return
startWith({
pro: false,
vars: {
OVERLEAF_PROXY_LEARN: 'true',
},
})
checkDisabled()
})
function checkDisabled() {
it('should not add a documentation entry to the nav bar', function () {
login(REGULAR_USER)
cy.visit('/project')
cy.findByRole('navigation', { name: 'Primary' })
.findByRole('menuitem', {
name: 'Documentation',
})
.should('not.exist')
})
it('should not render wiki page', function () {
login(REGULAR_USER)
cy.visit(COPYING_A_PROJECT_URL, {
failOnStatusCode: false,
})
cy.findByRole('heading', { name: 'Not found' })
})
it('should not display a tutorial link in the welcome page', function () {
login(WITHOUT_PROJECTS_USER)
cy.visit('/project')
cy.findByText(LABEL_LEARN_LATEX).should('not.exist')
})
}
})
-31
View File
@@ -1,31 +0,0 @@
{
"name": "@overleaf/server-ce-test",
"description": "e2e tests for Overleaf Community Edition",
"private": true,
"type": "module",
"scripts": {
"cypress:open": "cypress open --e2e --browser chrome",
"cypress:run": "cypress run --e2e --browser chrome",
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --max-warnings 0 --format unix --ext .js,.jsx,.mjs,.ts,.tsx .",
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .js,.jsx,.mjs,.ts,.tsx ."
},
"devDependencies": {
"@isomorphic-git/lightning-fs": "^4.6.0",
"@overleaf/promise-utils": "workspace:*",
"@overleaf/validation-tools": "workspace:*",
"@testing-library/cypress": "10.1.0",
"@types/adm-zip": "^0.5.7",
"@types/uuid": "^9.0.8",
"adm-zip": "^0.5.12",
"body-parser": "1.20.4",
"cypress": "15.12.0",
"cypress-multi-reporters": "^2.0.5",
"express": "4.22.1",
"isomorphic-git": "^1.33.1",
"js-yaml": "^4.1.1",
"mocha-junit-reporter": "^2.2.1",
"pdfjs-dist": "^5.1.91",
"uuid": "^9.0.1",
"zod-validation-error": "^4.0.1"
}
}
-125
View File
@@ -1,125 +0,0 @@
import { ensureUserExists, login } from './helpers/login'
import { createProject } from './helpers/project'
import { isExcludedBySharding, startWith } from './helpers/config'
import { v4 as uuid } from 'uuid'
const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com'
const REGULAR_USER = 'user@example.com'
describe('Project List', function () {
if (isExcludedBySharding('PRO_DEFAULT_2')) return
startWith({ pro: true })
const findProjectRow = (projectName: string) => {
cy.log('find project row')
return cy.findByText(projectName).parent().parent()
}
describe('user with no projects', function () {
ensureUserExists({ email: WITHOUT_PROJECTS_USER })
it("'Import from GitHub' is not displayed in the welcome page", function () {
login(WITHOUT_PROJECTS_USER)
cy.visit('/project')
cy.findByRole('button', { name: 'Create a new project' }).click()
cy.findByRole('menuitem', { name: 'Import from GitHub' }).should(
'not.exist'
)
})
})
describe('user with projects', function () {
const projectName = `test-project-${uuid()}`
ensureUserExists({ email: REGULAR_USER })
before(function () {
login(REGULAR_USER)
createProject(projectName, { type: 'Example project', open: false })
})
beforeEach(function () {
login(REGULAR_USER)
cy.visit('/project')
})
it('Can download project sources', function () {
findProjectRow(projectName).within(() =>
cy.findByRole('button', { name: 'Download .zip file' }).click()
)
const zipName = projectName.replaceAll('-', '_')
cy.task('readFileInZip', {
pathToZip: `cypress/downloads/${zipName}.zip`,
fileToRead: 'main.tex',
}).should('contain', 'Your introduction goes here')
})
it('Can download project PDF', function () {
findProjectRow(projectName).within(() =>
cy.findByRole('button', { name: 'Download PDF' }).click()
)
const pdfName = projectName.replaceAll('-', '_')
cy.task('readPdf', `cypress/downloads/${pdfName}.pdf`).should(
'contain',
'Your introduction goes here'
)
})
it('can assign and remove tags to projects', function () {
const tagName = uuid().slice(0, 7) // long tag names are truncated in the UI, which affects selectors
cy.log('select project')
cy.findByRole('checkbox', { name: `Select ${projectName}` }).check()
cy.log('add tag to project')
cy.findByRole('button', { name: 'Tags' }).click()
cy.findByRole('menuitem', { name: 'Create new tag' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('heading', { name: 'Create new tag' })
cy.findByLabelText('New tag name').type(`${tagName}{enter}`)
})
cy.findByRole('button', { name: `Select tag ${tagName}` }) // tag label in project row
cy.log('remove tag')
cy.findByRole('button', { name: `Remove tag ${tagName}` })
.first()
.click()
cy.findByRole('button', { name: `Select tag ${tagName}` }).should(
'not.exist'
)
})
it('can filter by tag', function () {
cy.log('create a separate project to filter')
const nonTaggedProjectName = `project-${uuid()}`
createProject(nonTaggedProjectName, { open: false })
cy.log('select project')
cy.findByRole('checkbox', { name: `Select ${projectName}` }).check()
cy.log('add tag to project')
const tagName = uuid().slice(0, 7) // long tag names are truncated in the UI, which affects selectors
cy.findByRole('button', { name: 'Tags' }).click()
cy.findByRole('menuitem', { name: 'Create new tag' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('heading', { name: 'Create new tag' })
cy.findByLabelText('New tag name').type(`${tagName}{enter}`)
})
cy.log(
'check the non-tagged project is filtered out after clicking the tag'
)
cy.findByRole('link', { name: nonTaggedProjectName }).should('exist')
cy.findByRole('button', { name: `Select tag ${tagName}` }).click()
cy.findByRole('link', { name: nonTaggedProjectName }).should('not.exist')
})
it('can change theme from the account menu', function () {
cy.findByRole('menuitem', { name: 'Account' }).click()
cy.findByLabelText('Dark').click()
cy.get('body').should('have.attr', 'data-theme', 'default')
cy.findByLabelText('Light').click()
cy.get('body').should('have.attr', 'data-theme', 'light')
})
})
})
-540
View File
@@ -1,540 +0,0 @@
import { v4 as uuid } from 'uuid'
import {
isExcludedBySharding,
startWith,
reloadWith,
STARTUP_TIMEOUT,
} from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import {
createProjectAndOpenInNewEditor,
enableLinkSharing,
getSpamSafeProjectName,
openProjectByName,
openProjectViaLinkSharingAsAnon,
openProjectViaLinkSharingAsUser,
shareProjectByEmailAndAcceptInviteViaDash,
shareProjectByEmailAndAcceptInviteViaEmail,
} from './helpers/project'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
describe('Project Sharing', function () {
if (isExcludedBySharding('PRO_CUSTOM_4')) return
startWith({ withDataDir: true, pro: true, resetData: true })
ensureUserExists({ email: 'user@example.com' })
let projectName: string
let recompile: () => void
let waitForCompile: (triggerCompile: () => void) => void
// Link-sharing urls have to be created before disabling link sharing.
// We use the `beforeEach` hook to reload the server with link sharing
// disabled **after** the initial setup which happens in the `before`
// block. The `before` hook always runs prior to the `beforeEach` hook.
// Set up retained access before disabling link sharing
beforeWithReRunOnTestRetry(() => {
// undo the link sharing restriction before joining via link-sharing below
cy.wrap(reloadWith({ withDataDir: true, pro: true }), {
timeout: STARTUP_TIMEOUT,
})
projectName = getSpamSafeProjectName()
;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
setupTestProject()
})
beforeEach(function () {
// Always start with a fresh session
cy.session([uuid()], () => {})
})
let linkSharingReadOnly: string
let linkSharingReadAndWrite: string
function setupTestProject() {
login('user@example.com')
waitForCompile(() => {
createProjectAndOpenInNewEditor(projectName)
})
// Add chat message
cy.findByRole('tab', { name: 'Chat' }).click()
// wait for lazy loading of the chat pane
cy.findByText('Start the conversation by saying hello or sharing an update')
cy.get(
'textarea[placeholder="Send a message to your collaborators…"]'
).type('New Chat Message{enter}')
// Get link sharing links
enableLinkSharing().then(
({ linkSharingReadOnly: ro, linkSharingReadAndWrite: rw }) => {
linkSharingReadAndWrite = rw
linkSharingReadOnly = ro
}
)
}
function expectContentReadOnlyAccess() {
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\maketitle'
)
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'have.attr',
'contenteditable',
'false'
)
}
function expectContentWriteAccess() {
const section = `Test Section ${uuid()}`
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
// wait for the editor to finish loading
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\maketitle'
)
// the editor should be writable
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'have.attr',
'contenteditable',
'true'
)
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type(`\n\\section{{}${section}}`)
})
// should have written
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
`\\section{${section}}`
)
// check PDF
recompile()
cy.findByRole('region', { name: 'PDF preview' }).within(() => {
cy.findByLabelText(/Page.*1/i).should('be.visible')
cy.findByText(projectName).should('be.visible')
})
cy.findByRole('region', { name: 'PDF preview' }).within(() => {
cy.findByLabelText(/Page.*1/i).should('be.visible')
cy.contains(section)
})
}
function expectNoAccess() {
// try read only access link
cy.visit(linkSharingReadOnly)
cy.url().should('match', /\/login/)
// Cypress bugs: cypress resolves the link-sharing link outside the browser, and it carries over the hash of the link-sharing link to the login page redirect (bug 1).
// Effectively, cypress then instructs the browser to change the page from /login#read-only-hash to /login#read-and-write-hash.
// This is turn does not trigger a "page load", but rather just "scrolling", which in turn trips up the "page loaded" detection in cypress (bug 2).
// Work around this by navigating away from the /login page in between checks.
cy.visit('/user/password/reset')
// try read and write access link
cy.visit(linkSharingReadAndWrite)
cy.url().should('match', /\/login/)
}
function expectChatAccess() {
cy.findByRole('tab', { name: 'Chat' }).click()
cy.findByText('New Chat Message')
}
function expectHistoryAccess() {
cy.findByRole('button', { name: 'History' }).click()
// The input is not clickable due to being visually hidden, click its label instead
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findByRole('group', {
name: 'Show all of the project history or only labelled versions.',
}).within(() => {
cy.findByText('All history').click()
})
cy.findByRole('radio', { name: 'Labels' }).should('not.be.checked')
cy.findByRole('radio', { name: 'All history' }).should('be.checked')
})
cy.findByText(/\\begin\{document}/)
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findAllByTestId('history-version-metadata-users')
.last()
.should('have.text', 'user')
})
cy.findByRole('button', { name: 'Back to editor' }).click()
}
function expectNoChatAccess() {
cy.findByRole('button', { name: 'Layout options' }) // wait for lazy loading
cy.findByRole('tab', { name: 'Chat' }).should('not.exist')
}
function expectNoHistoryAccess() {
cy.findByRole('button', { name: 'Layout options' }) // wait for lazy loading
cy.findByRole('button', { name: 'History' }).should('not.exist')
}
function expectCommentAccess() {
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\maketitle'
)
cy.findByText('\\maketitle').parent().dblclick()
cy.findByRole('button', { name: 'Add comment' }).should('be.visible')
cy.findByRole('textbox', { name: 'Source Editor editing' }).click()
}
function expectNoCommentAccess() {
cy.findByRole('textbox', { name: 'Source Editor editing' }).should(
'contain.text',
'\\maketitle'
)
cy.findByText('\\maketitle').parent().dblclick()
cy.findByRole('button', { name: 'Add comment' }).should('not.exist')
cy.findByRole('textbox', { name: 'Source Editor editing' }).click()
}
function expectFullReadOnlyAccess() {
expectContentReadOnlyAccess()
expectChatAccess()
expectHistoryAccess()
expectNoCommentAccess()
}
function expectRestrictedReadOnlyAccess() {
expectContentReadOnlyAccess()
expectNoChatAccess()
expectNoHistoryAccess()
expectNoCommentAccess()
}
function expectFullReadAndWriteAccess() {
expectContentWriteAccess()
expectChatAccess()
expectHistoryAccess()
expectCommentAccess()
}
function expectAnonymousReadAndWriteAccess() {
expectContentWriteAccess()
expectChatAccess()
expectHistoryAccess()
expectNoCommentAccess()
}
function expectProjectDashboardEntry() {
cy.visit('/project')
cy.findByText(projectName)
}
function expectEditAuthoredAs(author: string) {
cy.findByRole('button', { name: 'History' }).click()
cy.findByRole('complementary', {
name: 'Project history and labels',
}).within(() => {
cy.findAllByTestId('history-version-metadata-users')
.first()
.should('contain.text', author) // might have other edits in the same group
})
}
describe('via email', function () {
const email = 'collaborator-email@example.com'
ensureUserExists({ email })
beforeEach(function () {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaEmail(
projectName,
email,
'Viewer',
true
)
})
it('should grant the collaborator read access', function () {
expectFullReadOnlyAccess()
expectProjectDashboardEntry()
})
})
describe('read only', function () {
const email = 'collaborator-ro@example.com'
ensureUserExists({ email })
beforeWithReRunOnTestRetry(() => {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
email,
'Viewer',
true
)
})
it('should grant the collaborator read access', function () {
login(email)
openProjectByName(projectName, true)
expectFullReadOnlyAccess()
expectProjectDashboardEntry()
})
})
describe('read and write', function () {
const email = 'collaborator-rw@example.com'
ensureUserExists({ email })
beforeWithReRunOnTestRetry(() => {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
email,
'Editor',
true
)
})
it('should grant the collaborator write access', function () {
login(email)
openProjectByName(projectName, true)
expectFullReadAndWriteAccess()
expectEditAuthoredAs('You')
expectProjectDashboardEntry()
})
})
describe('token access', function () {
describe('logged in', function () {
describe('read only', function () {
const email = 'collaborator-link-ro@example.com'
ensureUserExists({ email })
it('should grant restricted read access', function () {
login(email)
openProjectViaLinkSharingAsUser(
linkSharingReadOnly,
projectName,
email,
true
)
expectRestrictedReadOnlyAccess()
expectProjectDashboardEntry()
})
})
describe('read and write', function () {
const email = 'collaborator-link-rw@example.com'
ensureUserExists({ email })
it('should grant full write access', function () {
login(email)
openProjectViaLinkSharingAsUser(
linkSharingReadAndWrite,
projectName,
email,
true
)
expectFullReadAndWriteAccess()
expectEditAuthoredAs('You')
expectProjectDashboardEntry()
})
})
})
describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=false', function () {
describe('wrap startup', function () {
startWith({
pro: true,
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false',
},
withDataDir: true,
})
it('should block access', function () {
expectNoAccess()
})
})
describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', function () {
startWith({
pro: true,
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false',
OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
},
withDataDir: true,
})
it('should block access', function () {
expectNoAccess()
})
})
})
describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=true', function () {
describe('wrap startup', function () {
startWith({
pro: true,
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
},
withDataDir: true,
})
it('should grant read access with read link', function () {
openProjectViaLinkSharingAsAnon(linkSharingReadOnly, true)
expectRestrictedReadOnlyAccess()
})
it('should prompt for login with write link', function () {
cy.visit(linkSharingReadAndWrite)
cy.url().should('match', /\/login/)
})
})
describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', function () {
startWith({
pro: true,
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
},
withDataDir: true,
})
it('should grant read access with read link', function () {
openProjectViaLinkSharingAsAnon(linkSharingReadOnly, true)
expectRestrictedReadOnlyAccess()
})
it('should grant write access with write link', function () {
openProjectViaLinkSharingAsAnon(linkSharingReadAndWrite, true)
expectAnonymousReadAndWriteAccess()
expectEditAuthoredAs('Anonymous')
})
})
})
describe('with OVERLEAF_DISABLE_LINK_SHARING=true', function () {
const email = 'collaborator-email@example.com'
ensureUserExists({ email })
const invitedEmail = 'invited-email@example.com'
ensureUserExists({ email: invitedEmail })
const retainedViewerEmail = 'collaborator-retained-viewer@example.com'
ensureUserExists({ email: retainedViewerEmail })
const retainedEditorEmail = 'collaborator-retained-editor@example.com'
ensureUserExists({ email: retainedEditorEmail })
beforeWithReRunOnTestRetry(() => {
// Set up retained viewer access
login(retainedViewerEmail)
openProjectViaLinkSharingAsUser(
linkSharingReadOnly,
projectName,
retainedViewerEmail,
true
)
// Set up retained editor access
login(retainedEditorEmail)
openProjectViaLinkSharingAsUser(
linkSharingReadAndWrite,
projectName,
retainedEditorEmail,
true
)
})
beforeEach(function () {
this.timeout(STARTUP_TIMEOUT) // Increase timeout for server reload
return cy.wrap(
reloadWith({
pro: true,
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
OVERLEAF_DISABLE_LINK_SHARING: 'true',
},
withDataDir: true,
}),
{ timeout: STARTUP_TIMEOUT }
)
})
it('should not display link sharing in the sharing modal', function () {
login('user@example.com')
openProjectByName(projectName, true)
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'Share' })
.click()
cy.findByRole('button', { name: 'Turn on link sharing' }).should(
'not.exist'
)
})
it('should block new access to read-only link shared projects', function () {
login(email)
// Test read-only link returns 404
cy.request({
url: linkSharingReadOnly,
failOnStatusCode: false,
}).then(response => {
expect(response.status).to.eq(404)
})
})
it('should block new access to read-write link shared projects', function () {
login(email)
// Test read-write link returns 404
cy.request({
url: linkSharingReadAndWrite,
failOnStatusCode: false,
}).then(response => {
expect(response.status).to.eq(404)
})
})
it('should continue to allow email sharing', function () {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaEmail(
projectName,
invitedEmail,
'Viewer',
true
)
expectFullReadOnlyAccess()
expectProjectDashboardEntry()
})
it('should retain read-only access when project was joined via link before link sharing was turned off', function () {
login(retainedViewerEmail)
openProjectByName(projectName, true)
expectRestrictedReadOnlyAccess()
expectProjectDashboardEntry()
})
it('should retain read-write access when project was joined via link before link sharing was turned off', function () {
login(retainedEditorEmail)
openProjectByName(projectName, true)
expectFullReadAndWriteAccess()
expectProjectDashboardEntry()
})
})
})
})
-351
View File
@@ -1,351 +0,0 @@
import { ensureUserExists, login } from './helpers/login'
import { createProjectAndOpenInNewEditor } from './helpers/project'
import { isExcludedBySharding, startWith } from './helpers/config'
import { prepareWaitForNextCompileSlot, stopCompile } from './helpers/compile'
import { v4 as uuid } from 'uuid'
import { waitUntilScrollingFinished } from './helpers/waitUntilScrollingFinished'
const LABEL_TEX_LIVE_VERSION = 'TeX Live version'
describe('SandboxedCompiles', function () {
const enabledVars = {
SANDBOXED_COMPILES: 'true',
ALL_TEX_LIVE_DOCKER_IMAGE_NAMES: '2023,2022',
}
describe('enabled in Server Pro', function () {
if (isExcludedBySharding('PRO_CUSTOM_2')) return
startWith({
pro: true,
vars: enabledVars,
resetData: true,
})
ensureUserExists({ email: 'user@example.com' })
beforeEach(function () {
login('user@example.com')
})
it('should offer TexLive images and switch the compiler', function () {
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('sandboxed')
})
cy.log('wait for compile')
cy.findByRole('region', { name: 'PDF preview' }).should(
'contain.text',
'sandboxed'
)
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
cy.log('Check which compiler version was used, expect 2023')
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2023\) /)
})
cy.log('Switch TeXLive version from 2023 to 2022')
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('option', { name: '2023' }).should('be.selected')
cy.findByRole('combobox', {
name: LABEL_TEX_LIVE_VERSION,
}).select('2022')
})
cy.get('body').type('{esc}')
cy.findByRole('dialog').should('not.exist')
cy.log('Trigger compile with other TeX Live version')
recompile()
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
cy.log('Check which compiler version was used, expect 2022')
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2022\) /)
})
})
checkSyncTeX()
checkXeTeX()
checkRecompilesAfterErrors()
checkStopCompile()
})
function checkStopCompile() {
it('users can stop a running compile', function () {
login('user@example.com')
const { recompile, waitForCompile, waitForCompileRateLimitCoolOff } =
prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('test-project')
})
// create an infinite loop in the main document
// this will cause the compile to run indefinitely
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle')
.parent()
.type(
'\nFirst page\\clearpage' +
'\nSecond page' +
'\\def\\loop{{}\\let\\next\\loop\\next}\\loop'
)
waitForCompileRateLimitCoolOff()
cy.log('Start compile')
// We need to start the compile manually because we do not want to wait for it to finish
cy.findByRole('button', { name: 'Recompile' }).click()
// Wait for the compile to start
cy.findByRole('button', { name: 'Compiling…' }).should('exist')
// Now stop the compile and kill the latex process
stopCompile({ delay: 1000 })
cy.findByRole('region', { name: 'PDF preview' })
.invoke('text')
.should('match', /Compilation cancelled/)
// Check that the previous compile is not running in the background by
// disabling the infinite loop and recompiling
cy.findByText('\\def').parent().click()
cy.findByText('\\def').parent().type('{home}disabled loop% ')
recompile()
cy.findByRole('region', { name: 'PDF preview' })
.should('contain.text', 'disabled loop')
.should('not.contain.text', 'A previous compile is still running')
})
}
function checkSyncTeX() {
describe('SyncTeX', function () {
let projectName: string
beforeEach(function () {
projectName = `Project ${uuid()}`
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor(projectName)
})
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(
() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle')
.parent()
.type(
`\n\\pagebreak\n\\section{{}Section A}\n\\pagebreak\n\\section{{}Section B}\n\\pagebreak`
)
}
)
recompile()
cy.log('wait for pdf-rendering')
cy.findByRole('region', { name: 'PDF preview' }).findByText(projectName)
})
it('should sync to code', function () {
cy.log('navigate to \\maketitle using double click in PDF')
cy.findByRole('region', { name: 'PDF preview' })
.findByText(projectName)
.dblclick()
cy.get('.cm-activeLine').should('have.text', '\\maketitle')
cy.log('navigate to Section A using double click in PDF')
cy.findByRole('region', { name: 'PDF preview' })
.findByText('Section A')
.dblclick()
cy.get('.cm-activeLine').should('have.text', '\\section{Section A}')
cy.log('navigate to Section B using arrow button')
cy.findByTestId('pdfjs-viewer-inner')
.should('have.prop', 'scrollTop')
.as('start')
cy.findByRole('region', { name: 'PDF preview' })
.findByText('Section B')
.scrollIntoView()
cy.get('@start').then((start: any) => {
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
start
)
})
// The sync button is swapped as the position in the PDF changes.
// Cypress appears to click on a button that references a stale position.
// Adding a cy.wait() statement is the most reliable "fix" so far :/
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(1000)
cy.findByRole('button', {
name: 'Go to PDF location in code (Tip: double click on the PDF for best results)',
}).click()
cy.get('.cm-activeLine').should('have.text', '\\section{Section B}')
})
it('should sync to pdf', function () {
cy.log('zoom in')
cy.findByRole('button', { name: 'PDF zoom level' }).click()
cy.findByRole('menuitem', { name: '400%' }).click()
cy.log('scroll to top')
cy.findByTestId('pdfjs-viewer-inner').scrollTo('top')
waitUntilScrollingFinished('[data-testid="pdfjs-viewer-inner"]', -1).as(
'start'
)
cy.log('navigate to title')
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(
() => {
cy.findByText('\\maketitle').parent().click()
}
)
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
cy.get('@start').then((start: any) => {
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
start
)
.as('title')
.should('be.greaterThan', start)
})
cy.log('navigate to Section A')
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() =>
cy.findByText('Section A').click()
)
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
cy.get('@title').then((title: any) => {
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
title
)
.as('sectionA')
.should('be.greaterThan', title)
})
cy.log('navigate to Section B')
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() =>
cy.findByText('Section B').click()
)
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
cy.get('@sectionA').then((title: any) => {
waitUntilScrollingFinished(
'[data-testid="pdfjs-viewer-inner"]',
title
)
.as('sectionB')
.should('be.greaterThan', title)
})
})
})
}
function checkRecompilesAfterErrors() {
it('recompiles even if there are Latex errors', function () {
login('user@example.com')
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('test-project')
})
cy.findByRole('textbox', { name: 'Source Editor editing' }).within(() => {
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle')
.parent()
.type('\n\\fakeCommand{} \n\\section{{}Test Section}')
})
recompile()
recompile()
cy.findByRole('region', { name: 'PDF preview' })
.findByText('Test Section')
.should('not.contain.text', 'No PDF')
})
}
function checkXeTeX() {
it('should be able to use XeLaTeX', function () {
const { recompile, waitForCompile } = prepareWaitForNextCompileSlot()
waitForCompile(() => {
createProjectAndOpenInNewEditor('XeLaTeX')
})
cy.log('wait for compile')
cy.findByRole('region', { name: 'PDF preview' }).findByText('XeLaTeX')
cy.log('Check which compiler was used, expect pdfLaTeX')
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
cy.log('Check which compiler was used, expect pdfLaTeX')
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByText(/This is pdfTeX/)
})
cy.log('Switch compiler to from pdfLaTeX to XeLaTeX')
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('option', { name: 'pdfLaTeX' }).should('be.selected')
cy.findByRole('combobox', {
name: 'Compiler',
}).select('XeLaTeX')
})
cy.get('body').type('{esc}')
cy.findByRole('dialog').should('not.exist')
cy.log('Trigger compile with other compiler')
recompile()
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
cy.log('Check which compiler was used, expect XeLaTeX')
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByText(/This is XeTeX/)
})
})
}
function checkUsesDefaultCompiler() {
beforeEach(function () {
login('user@example.com')
})
it('should not offer TexLive images and use default compiler', function () {
createProjectAndOpenInNewEditor('sandboxed')
cy.log('wait for compile')
cy.findByRole('region', { name: 'PDF preview' }).findByText('sandboxed')
cy.log('Check which compiler version was used, expect 2026')
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByLabelText('Raw logs from the LaTeX compiler').within(() => {
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2026\) /)
})
cy.log('Check that there is no TeX Live version toggle')
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').findByRole('tab', { name: 'Compiler' }).click()
cy.findByRole('dialog').within(() => {
cy.findByText('Main document').should('exist')
cy.findByText(LABEL_TEX_LIVE_VERSION).should('not.exist')
})
})
}
describe('disabled in Server Pro', function () {
if (isExcludedBySharding('PRO_DEFAULT_2')) return
startWith({ pro: true })
ensureUserExists({ email: 'user@example.com' })
beforeEach(function () {
login('user@example.com')
})
checkUsesDefaultCompiler()
checkSyncTeX()
checkXeTeX()
checkRecompilesAfterErrors()
checkStopCompile()
})
// https://github.com/overleaf/internal/issues/20216
// eslint-disable-next-line mocha/no-pending-tests
describe.skip('unavailable in CE', function () {
if (isExcludedBySharding('CE_CUSTOM_1')) return
startWith({ pro: false, vars: enabledVars, resetData: true })
ensureUserExists({ email: 'user@example.com' })
beforeEach(function () {
login('user@example.com')
})
checkUsesDefaultCompiler()
checkSyncTeX()
checkXeTeX()
checkRecompilesAfterErrors()
checkStopCompile()
})
})
-281
View File
@@ -1,281 +0,0 @@
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import {
createProjectAndOpenInNewEditor,
NEW_PROJECT_BUTTON_MATCHER,
redirectEditorUrlWithQueryParams,
} from './helpers/project'
const WITHOUT_PROJECTS_USER = 'user-without-projects@example.com'
const ADMIN_USER = 'admin@example.com'
const REGULAR_USER = 'user@example.com'
const TEMPLATES_USER = 'templates@example.com'
// Re-use value for "exists" and "does not exist" tests
const LABEL_BROWSE_TEMPLATES = 'Browse templates'
describe('Templates', function () {
ensureUserExists({ email: TEMPLATES_USER })
ensureUserExists({ email: WITHOUT_PROJECTS_USER })
let OVERLEAF_TEMPLATES_USER_ID: string
before(function () {
login(TEMPLATES_USER)
cy.visit('/')
cy.get('meta[name="ol-user_id"]').then(el => {
OVERLEAF_TEMPLATES_USER_ID = el.attr('content')!
})
})
function varsFn() {
return {
OVERLEAF_TEMPLATES_USER_ID,
OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS:
'[{"name":"All Templates","url":"/templates/all"}]',
}
}
describe('enabled in Server Pro', function () {
if (isExcludedBySharding('PRO_CUSTOM_2')) return
startWith({
pro: true,
varsFn,
})
ensureUserExists({ email: REGULAR_USER })
ensureUserExists({ email: ADMIN_USER, isAdmin: true })
it('should show templates link on welcome page', function () {
login(WITHOUT_PROJECTS_USER)
cy.visit('/')
cy.findByRole('link', { name: LABEL_BROWSE_TEMPLATES }).click()
cy.url().should('match', /\/templates$/)
})
it('should have templates feature', function () {
login(TEMPLATES_USER)
const name = `Template ${Date.now()}`
const description = `Template Description ${Date.now()}`
cy.visit('/')
createProjectAndOpenInNewEditor(name, { type: 'Example project' }).as(
'templateProjectId'
)
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Manage template' }).click()
cy.findByLabelText('Template Description').type(description)
cy.findByRole('button', { name: 'Publish' }).click()
cy.findByRole('button', { name: 'Publishing…' }).should('be.disabled')
cy.findByRole('button', { name: 'Publish' }).should('not.exist')
cy.findByRole('button', { name: 'Unpublish', timeout: 60_000 })
cy.findByRole('button', { name: 'Republish' })
cy.findByRole('link', { name: 'View it in the template gallery' }).click()
cy.url()
.should('match', /\/templates\/[a-f0-9]{24}$/)
.as('templateURL')
cy.findByRole('heading', { level: 2 }).findByText(name)
cy.findByText(description)
cy.findByRole('link', { name: 'Open as Template' })
cy.findByRole('button', { name: 'Unpublish' })
cy.findByRole('button', { name: 'Republish' })
cy.get('img')
.should('have.attr', 'src')
.and('match', /\/v\/0\//)
cy.findByRole('button', { name: 'Republish' }).click()
cy.findByRole('button', { name: 'Publishing…' }).should('be.disabled')
cy.findByRole('button', { name: 'Republish', timeout: 60_000 })
cy.get('img', { timeout: 60_000 })
.should('have.attr', 'src')
.and('match', /\/v\/1\//)
// custom tag
const tagName = `${Date.now()}`
cy.visit('/')
cy.findByRole('checkbox', { name: `Select ${name}` }).check()
cy.findByRole('navigation', { name: 'Project categories and tags' })
.findByRole('button', { name: 'New tag' })
.click()
cy.focused().type(tagName)
cy.findByRole('button', { name: 'Create' }).click()
cy.findByRole('navigation', {
name: 'Project categories and tags',
}).should('contain', `${tagName} (1)`)
// Check listing
cy.visit('/templates')
cy.findByRole('link', { name: tagName })
cy.visit('/templates/all')
cy.findByRole('heading', { name })
cy.visit(`/templates/${tagName}`)
cy.findByRole('heading', { name })
// Unpublish via template page
cy.get('@templateURL').then(url => cy.visit(`${url}`))
cy.findByRole('button', { name: 'Unpublish' }).click()
cy.url().should('match', /\/templates$/)
cy.get('@templateURL').then(url =>
cy.visit(`${url}`, {
failOnStatusCode: false,
})
)
cy.findByRole('heading', { name: 'Not found' })
cy.visit('/templates/all')
cy.findByRole('heading', { name }).should('not.exist')
cy.visit(`/templates/${tagName}`)
cy.findByRole('heading', { name }).should('not.exist')
// Publish again
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Manage template' }).click()
cy.findByRole('button', { name: 'Publish' }).click()
cy.findByRole('button', { name: 'Unpublish', timeout: 60_000 })
// Should assign a new template id
cy.findByRole('link', { name: 'View it in the template gallery' }).click()
cy.url()
.should('match', /\/templates\/[a-f0-9]{24}$/)
.as('newTemplateURL')
cy.get('@newTemplateURL').then(newURL => {
cy.get('@templateURL').then(prevURL => {
expect(newURL).to.match(/\/templates\/[a-f0-9]{24}$/)
expect(prevURL).to.not.equal(newURL)
})
})
// Open project from template
login(REGULAR_USER)
cy.visit('/templates')
cy.findByRole('link', { name: tagName }).click()
cy.findByRole('link', { name }).click()
cy.findByRole('link', { name: 'Open as Template' }).click()
cy.findByRole('navigation', { name: 'Project actions' }).findByText(
/Your Paper/i
) // might have (1) suffix
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Word count' }).click() // wait for lazy loading
cy.findByRole('menuitem', { name: 'Manage template' }).should('not.exist')
// Check management as regular user
cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
cy.findByRole('link', { name: 'Open as Template' })
cy.findByRole('button', { name: 'Unpublish' }).should('not.exist')
cy.findByRole('button', { name: 'Republish' }).should('not.exist')
// Check management as admin user
login(ADMIN_USER)
redirectEditorUrlWithQueryParams(true)
cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
cy.findByRole('link', { name: 'Open as Template' })
cy.findByRole('button', { name: 'Unpublish' })
cy.findByRole('button', { name: 'Republish' })
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Manage template' }).click()
cy.findByRole('button', { name: 'Unpublish' })
// Back to templates user
login(TEMPLATES_USER)
// Unpublish via editor
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Manage template' }).click()
cy.findByRole('button', { name: 'Unpublish' }).click()
cy.findByRole('button', { name: 'Publish' })
cy.visit('/templates/all')
cy.findByRole('link', { name }).should('not.exist')
// check for template links, after creating the first project
cy.visit('/')
cy.findAllByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }).click()
cy.findByRole('menuitem', { name: /All Templates/ }).should(
'have.attr',
'href',
'/templates/all'
)
})
})
function checkDisabled() {
it('should not have templates feature', function () {
login(TEMPLATES_USER)
cy.visit('/')
createProjectAndOpenInNewEditor('maybe templates')
cy.findByRole('navigation', {
name: 'Project actions',
})
.findByRole('button', { name: 'File' })
.click()
cy.findByRole('menuitem', { name: 'Word count' }) // wait for lazy loading
cy.findByRole('menuitem', { name: 'Manage template' }).should('not.exist')
cy.visit('/templates', { failOnStatusCode: false })
cy.findByRole('heading', { name: 'Not found' })
cy.visit('/templates/all', { failOnStatusCode: false })
cy.findByRole('heading', { name: 'Not found' })
// check for template links, after creating the first project
cy.visit('/')
cy.findAllByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }).click()
cy.findByRole('menuitem', { name: /All Templates/ }).should('not.exist')
})
it('should not show templates link on welcome page', function () {
login(WITHOUT_PROJECTS_USER)
cy.visit('/')
cy.findByRole('button', { name: NEW_PROJECT_BUTTON_MATCHER }) // wait for lazy loading
cy.findByRole('link', { name: LABEL_BROWSE_TEMPLATES }).should(
'not.exist'
)
})
}
describe('disabled Server Pro', function () {
if (isExcludedBySharding('PRO_DEFAULT_2')) return
startWith({ pro: true })
checkDisabled()
})
describe('unavailable in CE', function () {
if (isExcludedBySharding('CE_CUSTOM_1')) return
startWith({
pro: false,
varsFn,
})
checkDisabled()
})
})
-18
View File
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "esnext" /* Specify ECMAScript target version */,
"module": "es2020" /* Specify module code generation */,
"allowJs": true /* Allow JavaScript files to be compiled. */,
// "checkJs": true /* Report errors in .js files. */,
"jsx": "preserve" /* Specify JSX code generation */,
"noEmit": true /* Do not emit outputs. */,
"strict": true /* Enable all strict type-checking options. */,
"moduleResolution": "node" /* Specify module resolution strategy */,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
"skipLibCheck": true /* Skip type checking of declaration files. */,
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
"types": ["cypress", "node", "@testing-library/cypress"],
"allowImportingTsExtensions": true
},
"include": ["**/*.ts", "**/*.js"]
}
-191
View File
@@ -1,191 +0,0 @@
import { ensureUserExists, login } from './helpers/login'
import { isExcludedBySharding, startWith } from './helpers/config'
import { runScript } from './helpers/hostAdminClient'
import { createProject, openProjectByName } from './helpers/project'
import { prepareWaitForNextCompileSlot } from './helpers/compile'
import { v4 as uuid } from 'uuid'
const USER = 'user@example.com'
const PROJECT_NAME = 'Old Project'
describe('Upgrading', function () {
if (isExcludedBySharding('PRO_CUSTOM_3')) return
let recompile: () => void
let waitForCompile: (triggerCompile: () => void) => void
function testUpgrade(
steps: {
version: string
vars?: Object
newProjectButtonMatcher?: RegExp
hook?: () => void
}[]
) {
const startOptions = steps.shift()!
before(async function () {
cy.log('Create old instance')
})
startWith({
pro: true,
version: startOptions.version,
withDataDir: true,
resetData: true,
vars: startOptions.vars,
})
before(function () {
cy.log('Create initial user after deleting it')
})
ensureUserExists({ email: USER })
before(function () {
cy.log('Populate old instance')
login(USER)
;({ recompile, waitForCompile } = prepareWaitForNextCompileSlot())
waitForCompile(() => {
createProject(PROJECT_NAME, {
newProjectButtonMatcher: startOptions.newProjectButtonMatcher,
})
})
cy.log('Wait for successful compile')
cy.findByLabelText(/Page.*1/i).findByText(PROJECT_NAME)
cy.log('Increment the doc version three times')
for (let i = 0; i < 3; i++) {
cy.log('Add content')
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle')
.parent()
.type(`\n\\section{{}Old Section ${i}}`)
cy.log('Trigger full flush')
recompile()
cy.findByRole('navigation', {
name: 'Project Layout, Sharing, and Submission',
})
.findByRole('button', { name: 'Menu' })
.click()
cy.findByRole('link', { name: 'Source' }).click()
cy.get('body').type('{esc}')
}
cy.log('Check compile and history')
for (let i = 0; i < 3; i++) {
cy.findByLabelText(/Page.*1/i).findByText(`Old Section ${i}`)
}
cy.findByRole('button', { name: 'History' }).click()
for (let i = 0; i < 3; i++) {
cy.findByText(new RegExp(`\\\\section{Old Section ${i}}`))
}
})
for (const step of steps) {
before(function () {
cy.log(`Upgrade to version ${step.version}`)
// Navigate way from editor to avoid redirect to /login when the next instance comes up (which slows down tests)
cy.visit('/project', {})
})
before(async function () {
cy.log('Graceful shutdown: flush all the things')
this.timeout(20 * 1000)
// Ideally we could use the container shutdown procedure, but it's too slow and unreliable for tests.
// TODO(das7pad): adopt the below after speeding up the graceful shutdown procedure on all supported releases
// await dockerCompose('stop', 'sharelatex')
// For now, we are stuck with manually flushing things
await runScript({
cwd: 'services/document-updater',
script: 'scripts/flush_all.js',
})
await runScript({
cwd: 'services/project-history',
script: 'scripts/flush_all.js',
})
})
startWith({
pro: true,
version: step.version,
vars: step.vars,
withDataDir: true,
})
step.hook?.()
}
beforeEach(function () {
login(USER)
})
it('should list the old project', function () {
cy.visit('/project')
cy.findByRole('link', { name: PROJECT_NAME })
})
it('should open the old project', function () {
waitForCompile(() => {
openProjectByName(PROJECT_NAME)
})
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
cy.findByRole('navigation', {
name: 'Project actions',
}).within(() => {
cy.findByText(PROJECT_NAME)
})
cy.log('wait for successful compile')
cy.findByLabelText(/Page.*1/i).findByText(PROJECT_NAME)
cy.findByLabelText(/Page.*1/i).findByText('Old Section 2')
cy.log('Add more content')
const newSection = `New Section ${uuid()}`
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type(`\n\\section{{}${newSection}}`)
cy.log('Check compile and history')
recompile()
cy.findByLabelText(/Page.*1/i).findByText(newSection)
cy.findByRole('button', { name: 'History' }).click()
cy.findByText(/\\section\{Old Section 2}/)
cy.findByText(new RegExp(`\\\\section\\{${newSection}}`))
})
}
const optionsFourDotTwo = {
version: '4.2',
vars: {
// Add core vars with old branding
SHARELATEX_SITE_URL: 'http://sharelatex',
SHARELATEX_MONGO_URL: 'mongodb://mongo/sharelatex',
SHARELATEX_REDIS_HOST: 'redis',
},
newProjectButtonMatcher: /create first project/i,
}
const optionsBinaryFilesMigration = {
version: '5.5.5',
hook() {
before(async function () {
await runScript({
cwd: 'services/history-v1',
script: 'storage/scripts/back_fill_file_hash.mjs',
args: ['--all'],
})
})
},
}
describe('from 4.2 to latest', function () {
testUpgrade([
optionsFourDotTwo,
optionsBinaryFilesMigration,
{ version: 'latest' },
])
})
describe('from 5.0 to latest', function () {
testUpgrade([
{ version: '5.0' },
optionsBinaryFilesMigration,
{ version: 'latest' },
])
})
})
-1
View File
@@ -1 +0,0 @@
24.14.1
-50
View File
@@ -1,50 +0,0 @@
# This file was auto-generated, do not edit it directly.
# Instead run bin/update_build_scripts from
# https://github.com/overleaf/internal/
FROM node:24.14.1 AS base
# Corepack setup, shared between all the images.
ENV PATH="/overleaf/node_modules/.bin:$PATH"
ENV COREPACK_HOME=/opt/corepack
RUN corepack enable && corepack install -g yarn@4.14.1
ENV COREPACK_ENABLE_NETWORK=0
WORKDIR /overleaf/services/chat
# Google Cloud Storage needs a writable $HOME/.config for resumable uploads
# (see https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream)
RUN mkdir /home/node/.config && chown node:node /home/node/.config
FROM base AS app
COPY package.json yarn.lock .yarnrc.yml /overleaf/
COPY libraries/eslint-plugin/package.json /overleaf/libraries/eslint-plugin/package.json
COPY libraries/fetch-utils/package.json /overleaf/libraries/fetch-utils/package.json
COPY libraries/logger/package.json /overleaf/libraries/logger/package.json
COPY libraries/metrics/package.json /overleaf/libraries/metrics/package.json
COPY libraries/mongo-utils/package.json /overleaf/libraries/mongo-utils/package.json
COPY libraries/o-error/package.json /overleaf/libraries/o-error/package.json
COPY libraries/promise-utils/package.json /overleaf/libraries/promise-utils/package.json
COPY libraries/settings/package.json /overleaf/libraries/settings/package.json
COPY services/chat/package.json /overleaf/services/chat/package.json
COPY tools/migrations/package.json /overleaf/tools/migrations/package.json
COPY .yarn/patches/ /overleaf/.yarn/patches/
COPY tools/migrations/ /overleaf/tools/migrations/
RUN cd /overleaf && yarn workspaces focus @overleaf/chat overleaf
COPY libraries/eslint-plugin/ /overleaf/libraries/eslint-plugin/
COPY libraries/fetch-utils/ /overleaf/libraries/fetch-utils/
COPY libraries/logger/ /overleaf/libraries/logger/
COPY libraries/metrics/ /overleaf/libraries/metrics/
COPY libraries/mongo-utils/ /overleaf/libraries/mongo-utils/
COPY libraries/o-error/ /overleaf/libraries/o-error/
COPY libraries/promise-utils/ /overleaf/libraries/promise-utils/
COPY libraries/settings/ /overleaf/libraries/settings/
COPY services/chat/ /overleaf/services/chat/
COPY tools/migrations/ /overleaf/tools/migrations/
FROM app
USER node
CMD ["node", "--expose-gc", "app.js"]
-205
View File
@@ -1,205 +0,0 @@
# This file was auto-generated, do not edit it directly.
# Instead run bin/update_build_scripts from
# https://github.com/overleaf/internal/
export BUILD_NUMBER ?= local
export BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD)
export BRANCH_NAME_TAG_SAFE = $(shell echo $(BRANCH_NAME) | sed 's/\//\-\-/g' | cut -c1-96)
export PROJECT_NAME = chat
BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]')
HERE=$(shell pwd)
export MONOREPO ?= $(shell cd ../../ && pwd)
export RETRIES ?= $(if $(CI),3,0)
IMAGE_CI ?= ci/$(PROJECT_NAME):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER)
IMAGE_REPO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/$(PROJECT_NAME)
IMAGE_REPO_FINAL ?= $(IMAGE_REPO):$(BRANCH_NAME_TAG_SAFE)-$(BUILD_NUMBER)
IMAGE_CACHE ?= $(IMAGE_REPO):cache-$(shell cat \
$(MONOREPO)/package.json \
$(MONOREPO)/yarn.lock \
$(MONOREPO)/libraries/eslint-plugin/package.json \
$(MONOREPO)/libraries/fetch-utils/package.json \
$(MONOREPO)/libraries/logger/package.json \
$(MONOREPO)/libraries/metrics/package.json \
$(MONOREPO)/libraries/mongo-utils/package.json \
$(MONOREPO)/libraries/o-error/package.json \
$(MONOREPO)/libraries/promise-utils/package.json \
$(MONOREPO)/libraries/settings/package.json \
$(MONOREPO)/services/chat/package.json \
$(MONOREPO)/tools/migrations/package.json \
$(MONOREPO)/.yarn/patches/* \
| sha256sum | cut -d '-' -f1)
export MOCHA_GREP ?=
DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml
DOCKER_COMPOSE := docker compose ${DOCKER_COMPOSE_FLAGS}
# Common flags for docker compose run.
# --rm Tear down test runner container
# --build Make sure that we use the latest base image
# --quiet-build Hide build output
ifeq ($(CI),true)
DC_RUN_FLAGS := --rm
else
DC_RUN_FLAGS := --rm --build --quiet-build
endif
COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE ?= test_acceptance_$(BUILD_DIR_NAME)
DOCKER_COMPOSE_TEST_ACCEPTANCE = \
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_ACCEPTANCE) $(DOCKER_COMPOSE)
COMPOSE_PROJECT_NAME_TEST_UNIT ?= test_unit_$(BUILD_DIR_NAME)
DOCKER_COMPOSE_TEST_UNIT = \
COMPOSE_PROJECT_NAME=$(COMPOSE_PROJECT_NAME_TEST_UNIT) $(DOCKER_COMPOSE)
.PHONY: print-branch-tag-safe
print-branch-tag-safe:
@echo $(BRANCH_NAME_TAG_SAFE)
clean:
-docker rmi $(IMAGE_CI)
-docker rmi $(IMAGE_REPO_FINAL)
-$(DOCKER_COMPOSE_TEST_UNIT) down --remove-orphans --rmi local --timeout 0 --volumes
-$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --remove-orphans --rmi local --timeout 0 --volumes
-rm -rf reports/
# Run the linting commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/services/$(PROJECT_NAME) monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
RUN_LINTING_CI = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/.cache:/overleaf/.cache $(IMAGE_CI) yarn run --silent
RUN_LINTING_CI_MONOREPO = docker run --rm --volume $(MONOREPO)/.editorconfig:/overleaf/.editorconfig --volume $(MONOREPO)/eslint.config.mjs:/overleaf/eslint.config.mjs --volume $(MONOREPO)/.prettierignore:/overleaf/.prettierignore --volume $(MONOREPO)/.prettierrc.cjs:/overleaf/.prettierrc.cjs --volume $(MONOREPO)/tsconfig.backend.json:/overleaf/tsconfig.backend.json --volume $(MONOREPO)/services/chat/reports:/overleaf/services/chat/reports --volume $(MONOREPO)/.cache:/overleaf/.cache -w /overleaf $(IMAGE_CI) yarn run --silent
SHELLCHECK_OPTS = \
--shell=bash \
--external-sources
SHELLCHECK_COLOR := $(if $(CI),--color=never,--color)
SHELLCHECK_FILES := { git ls-files "*.sh" -z; git grep -Plz "\A\#\!.*bash"; } | sort -zu
shellcheck:
@$(SHELLCHECK_FILES) | xargs -0 -r docker run --rm -v $(HERE):/mnt -w /mnt \
koalaman/shellcheck:stable $(SHELLCHECK_OPTS) $(SHELLCHECK_COLOR)
shellcheck_fix:
@$(SHELLCHECK_FILES) | while IFS= read -r -d '' file; do \
diff=$$(docker run --rm -v $(HERE):/mnt -w /mnt koalaman/shellcheck:stable $(SHELLCHECK_OPTS) --format=diff "$$file" 2>/dev/null); \
if [ -n "$$diff" ] && ! echo "$$diff" | patch -p1 >/dev/null 2>&1; then echo "\033[31m$$file\033[0m"; \
elif [ -n "$$diff" ]; then echo "$$file"; \
else echo "\033[2m$$file\033[0m"; fi \
done
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../.cache/prettier/
../../.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../.cache/eslint/
../../.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format services/chat
format_ci:
$(RUN_LINTING_CI_MONOREPO) format services/chat
format_fix:
$(RUN_LINTING_MONOREPO) format:fix services/chat
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING_CI) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING_CI) types:check
test: format lint typecheck shellcheck test_unit test_acceptance
test_unit:
ifneq (,$(wildcard test/unit))
$(DOCKER_COMPOSE_TEST_UNIT) run $(DC_RUN_FLAGS) test_unit
endif
test_clean: test_unit_clean
test_unit_clean:
ifneq (,$(wildcard test/unit))
$(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0
endif
test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run
$(MAKE) test_acceptance_clean
test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug
$(MAKE) test_acceptance_clean
test_acceptance_run:
ifneq (,$(wildcard test/acceptance))
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance
endif
test_acceptance_run_debug:
ifneq (,$(wildcard test/acceptance))
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 $(DC_RUN_FLAGS) test_acceptance yarn run --inspect=0.0.0.0:19999 --inspect-brk test:acceptance
endif
test_clean: test_acceptance_clean
test_acceptance_clean:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0
test_acceptance_pre_run:
ifneq (,$(wildcard test/acceptance/js/scripts/pre-run))
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance test/acceptance/js/scripts/pre-run
endif
benchmarks:
$(DOCKER_COMPOSE_TEST_ACCEPTANCE) run $(DC_RUN_FLAGS) test_acceptance yarn run benchmarks
build:
docker build \
--pull \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--tag $(IMAGE_CI) \
--tag $(IMAGE_CACHE) \
--tag $(IMAGE_REPO_FINAL) \
--cache-from $(IMAGE_CACHE) \
--file Dockerfile \
../..
tar:
$(DOCKER_COMPOSE) up tar
push:
docker push $(IMAGE_REPO_FINAL)
push_branch:
docker push $(IMAGE_CACHE)
.PHONY: clean \
format format_fix \
lint lint_fix \
build_types typecheck \
lint_ci format_ci typecheck_ci \
shellcheck shellcheck_fix \
test test_clean test_unit test_unit_clean \
test_acceptance test_acceptance_debug test_acceptance_pre_run \
test_acceptance_run test_acceptance_run_debug test_acceptance_clean \
benchmarks \
build tar publish \
-9
View File
@@ -1,9 +0,0 @@
chat
--dependencies=mongo
--deploy-pipeline=chat
--env-add=
--env-pass-through=
--esmock-loader=False
--node-version=24.14.1
--pipeline-owner=44
--public-repo=False
-64
View File
@@ -1,64 +0,0 @@
# This file was auto-generated, do not edit it directly.
# Instead run bin/update_build_scripts from
# https://github.com/overleaf/internal/
services:
test_unit:
image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER
user: node
volumes:
- ./reports:/overleaf/services/chat/reports
- ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it
entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 --
command: yarn run test:unit:_run
environment:
CI:
MONGO_CONNECTION_STRING: mongodb://mongo/test-overleaf
NODE_ENV: test
NODE_OPTIONS: "--unhandled-rejections=strict"
depends_on:
mongo:
condition: service_started
test_acceptance:
image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER
environment:
CI:
RETRIES:
MONGO_HOST: mongo
POSTGRES_HOST: postgres
MOCHA_GREP: ${MOCHA_GREP}
NODE_ENV: test
NODE_OPTIONS: "--unhandled-rejections=strict"
volumes:
- ./reports:/overleaf/services/chat/reports
- ../../bin/shared/wait_for_it:/overleaf/bin/shared/wait_for_it
depends_on:
mongo:
condition: service_started
user: node
entrypoint: /overleaf/bin/shared/wait_for_it mongo:27017 --timeout=60 --
command: yarn run test:acceptance
tar:
build: .
image: ci/$PROJECT_NAME:$BRANCH_NAME_TAG_SAFE-$BUILD_NUMBER
volumes:
- ./:/tmp/build/
command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs .
user: root
mongo:
image: mongo:8.0.11
command: --replSet overleaf --notablescan
volumes:
- ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
- ../../bin/shared/mongodb-docker-entrypoint-wait.sh:/mongodb-docker-entrypoint-wait.sh
entrypoint: /mongodb-docker-entrypoint-wait.sh
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the
# replica set. This override is not needed when running the setup after
# starting up mongo.
- mongo:127.0.0.1

Some files were not shown because too many files have changed in this diff Show More