mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
[web] optimize process notifications and add monitoring metrics (#34699)
GitOrigin-RevId: 411355cbdbb25d871b9cfb2afbb0ad235041337f
This commit is contained in:
@@ -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,
|
||||
},
|
||||
])
|
||||
@@ -1,2 +0,0 @@
|
||||
data/
|
||||
docker-mailtrap/
|
||||
@@ -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/*
|
||||
@@ -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
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user