first commit

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

2
server-ce/test/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
data/
docker-mailtrap/

View File

@@ -0,0 +1,8 @@
FROM node:20.18.2
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/*

63
server-ce/test/Makefile Normal file
View File

@@ -0,0 +1,63 @@
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 TEX_LIVE_DOCKER_IMAGE ?= gcr.io/overleaf-ops/texlive-full:2023.1
export ALL_TEX_LIVE_DOCKER_IMAGES ?= gcr.io/overleaf-ops/texlive-full:2023.1,gcr.io/overleaf-ops/texlive-full:2022.1
export IMAGE_TAG_PRO ?= us-east1-docker.pkg.dev/overleaf-ops/ol-docker/pro:latest
export CYPRESS_SHARD ?=
export COMPOSE_PROJECT_NAME ?= test
test-e2e-native:
docker compose -f docker-compose.yml -f docker-compose.native.yml up --no-log-prefix sharelatex host-admin -d
CYPRESS_HOST_ADMIN_URL='http://localhost:8081' \
CYPRESS_SAML_URL='http://localhost:8082' \
CYPRESS_MAILTRAP_URL='http://localhost:8083' \
npm run cypress:open
test-e2e:
docker compose up --no-log-prefix --exit-code-from=e2e e2e
test-e2e-open:
docker compose up --no-log-prefix --exit-code-from=e2e-open e2e-open
clean:
docker compose down --volumes --timeout 0
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
prefetch: prefetch_custom
prefetch_custom: prefetch_custom_compose_pull
prefetch_custom_compose_pull:
docker compose pull saml ldap
prefetch_custom: prefetch_custom_texlive
prefetch_custom_texlive:
echo "$$ALL_TEX_LIVE_DOCKER_IMAGES" | tr ',' '\n' | xargs -I% \
sh -exc 'tag=%; re_tag=quay.io/sharelatex/$${tag#*/*/}; docker pull $$tag; docker tag $$tag $$re_tag'
prefetch_custom: prefetch_old
prefetch_old:
docker pull $(IMAGE_TAG_PRO:latest=4.2)
docker pull $(IMAGE_TAG_PRO:latest=5.0.1-RC1)
docker pull $(IMAGE_TAG_PRO:latest=5.0)
# 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
.PHONY: test-e2e test-e2e-open

View File

@@ -0,0 +1,38 @@
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.findByText('Account').click()
cy.findByText('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', () => {
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.findByText('Please set a password')
cy.get('input[autocomplete="username"]').should(
'have.attr',
'value',
email
)
cy.get('input[name="password"]')
cy.findByRole('button', { name: 'Activate' })
})
})
})

View File

@@ -0,0 +1,327 @@
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', () => {
const user = `${uuid()}@example.com`
cy.get('input[name="email"]').type(user + '{enter}')
cy.get('td')
.contains(/\/user\/activate/)
.then($td => {
const url = $td.text().trim()
activateUser(url)
})
})
it('via GUI and email', () => {
const user = `${uuid()}@example.com`
cy.get('input[name="email"]').type(user + '{enter}')
let url: string
cy.get('td')
.contains(/\/user\/activate/)
.then($td => {
url = $td.text().trim()
})
cy.then(() => {
openEmail(
'Activate your Overleaf Community Edition Account',
(frame, { url }) => {
frame.contains('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', () => {
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', () => {
const user = `${uuid()}@example.com`
let url: string
cy.then(async () => {
;({ url } = await createMongoUser({ email: user }))
})
cy.then(() => {
openEmail(
'Activate your Overleaf Community Edition Account',
(frame, { url }) => {
frame.contains('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', () => {
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', () => {
beforeEach(() => {
login(admin)
cy.visit('/project')
cy.get('nav').findByText('Admin').click()
cy.get('nav').findByText('Manage Users').click()
})
registrationTests()
})
})
describe('in server pro', () => {
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('manage site', () => {
beforeEach(() => {
login(admin)
cy.visit('/project')
cy.get('nav').findByText('Admin').click()
cy.get('nav').findByText('Manage Site').click()
})
it('publish and clear admin messages', () => {
const message = 'Admin Message ' + uuid()
cy.log('create system message')
cy.get('[role="tab"]').contains('System Messages').click()
cy.get('input[name="content"]').type(message)
cy.get('button').contains('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.get('nav').findByText('Admin').click()
cy.get('nav').findByText('Manage Site').click()
cy.get('[role="tab"]').contains('System Messages').click()
cy.get('button').contains('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', () => {
beforeEach(() => {
login(admin)
cy.visit('/project')
cy.get('nav').findByText('Admin').click()
cy.get('nav').findByText('Manage Users').click()
})
describe('create users', () => {
beforeEach(() => {
cy.get('a').contains('New User').click()
})
registrationTests()
})
it('user list RegExp search', () => {
cy.get('input[name="isRegExpSearch"]').click()
cy.get('input[name="email"]').type('user[0-9]{enter}')
cy.findByText(user2)
cy.findByText(user1).should('not.exist')
})
})
describe('user page', () => {
beforeEach(() => {
login(admin)
cy.visit('/project')
cy.get('nav').findByText('Admin').click()
cy.get('nav').findByText('Manage Users').click()
cy.get('input[name="email"]').type(user1 + '{enter}')
cy.findByText(user1).click()
cy.url().should('match', /\/admin\/user\/[a-fA-F0-9]{24}/)
})
it('displays expected tabs', () => {
const tabs = [
'User Info',
'Projects',
'Deleted Projects',
'Audit Log',
'Sessions',
]
cy.get('[role="tab"]').each((el, index) => {
cy.wrap(el).findByText(tabs[index]).click()
})
cy.get('[role="tab"]').should('have.length', tabs.length)
})
describe('user info tab', () => {
beforeEach(() => {
cy.get('[role="tab"]').contains('User Info').click()
})
it('displays required sections', () => {
// not exhaustive list, checks the tab content is rendered
cy.findByText('Profile')
cy.findByText('Editor Settings')
})
it('should not display SaaS-only sections', () => {
cy.findByText('Referred User Count').should('not.exist')
cy.findByText('Split Test Assignments').should('not.exist')
cy.findByText('Experimental Features').should('not.exist')
cy.findByText('Service Integration').should('not.exist')
cy.findByText('SSO Integrations').should('not.exist')
cy.findByText('Security').should('not.exist')
})
})
it('transfer project ownership', () => {
cy.log("access project admin through owners' project list")
cy.get('[role="tab"]').contains('Projects').click()
cy.get(`a[href="/admin/project/${testProjectId}"]`).click()
cy.findByText('Transfer Ownership').click()
cy.get('button[type="submit"]').should('be.disabled')
cy.get('input[name="user_id"]').type(user2)
cy.get('button[type="submit"]').should('not.be.disabled')
cy.get('button[type="submit"]').click()
cy.findByText('Transfer project to this user?')
cy.get('button').contains('Confirm').click()
cy.log('check the project is displayed in the new owner projects tab')
cy.get('input[name="email"]').type(user2 + '{enter}')
cy.findByText(user2).click()
cy.get('[role="tab"]').contains('Projects').click()
cy.get(`a[href="/admin/project/${testProjectId}"]`)
})
})
describe('project page', () => {
beforeEach(() => {
login(admin)
cy.visit(`/admin/project/${testProjectId}`)
})
it('displays expected tabs', () => {
const tabs = ['Project Info', 'Deleted Docs', 'Audit Log']
cy.get('[role="tab"]').each((el, index) => {
cy.wrap(el).findByText(tabs[index]).click()
})
cy.get('[role="tab"]').should('have.length', tabs.length)
})
})
it('restore deleted projects', () => {
login(user1)
cy.visit('/project')
cy.log('select project to delete')
findProjectRow(deletedProjectName).within(() =>
cy.get('input[type="checkbox"]').first().check()
)
cy.log('delete project')
findProjectRow(deletedProjectName).within(() =>
cy.findByRole('button', { name: 'Trash' }).click()
)
cy.get('button').contains('Confirm').click()
cy.findByText(deletedProjectName).should('not.exist')
cy.log('navigate to thrashed projects and delete the project')
cy.get('.project-list-sidebar-react').within(() => {
cy.findByText('Trashed Projects').click()
})
findProjectRow(deletedProjectName).within(() =>
cy.findByRole('button', { name: 'Delete' }).click()
)
cy.get('button').contains('Confirm').click()
cy.findByText(deletedProjectName).should('not.exist')
cy.log('login as an admin and navigate to the deleted project')
login(admin)
cy.visit('/admin/user')
cy.get('input[name="email"]').type(user1 + '{enter}')
cy.get('a').contains(user1).click()
cy.findByText('Deleted Projects').click()
cy.get('a').contains(deletedProjectName).click()
cy.log('undelete the project')
cy.findByText('Undelete').click()
cy.findByText('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.get('.project-list-sidebar-react').within(() => {
cy.findByText('Trashed Projects').click()
})
cy.findByText(`${deletedProjectName} (Restored)`)
})
})
})

View File

@@ -0,0 +1,124 @@
import { ensureUserExists, login } from './helpers/login'
import {
createProject,
openProjectViaInviteNotification,
} from './helpers/project'
import { isExcludedBySharding, startWith } from './helpers/config'
import { throttledRecompile } from './helpers/compile'
describe('Project creation and compilation', function () {
if (isExcludedBySharding('CE_DEFAULT')) return
startWith({})
ensureUserExists({ email: 'user@example.com' })
ensureUserExists({ email: 'collaborator@example.com' })
it('users can create project and compile it', function () {
login('user@example.com')
createProject('test-project')
const recompile = throttledRecompile()
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type('\n\\section{{}Test Section}')
recompile()
cy.get('.pdf-viewer').should('contain.text', 'Test Section')
})
it('create and edit markdown file', function () {
const fileName = `test-${Date.now()}.md`
const markdownContent = '# Markdown title'
login('user@example.com')
createProject('test-project')
// FIXME: Add aria-label maybe? or at least data-test-id
cy.findByText('New file').click({ force: true })
cy.findByRole('dialog').within(() => {
cy.get('input').clear()
cy.get('input').type(fileName)
cy.findByText('Create').click()
})
cy.findByText(fileName).click()
// wait until we've switched to the newly created empty file
cy.get('.cm-line').should('have.length', 1)
cy.get('.cm-line').type(markdownContent)
cy.findByText('main.tex').click()
cy.get('.cm-content').should('contain.text', '\\maketitle')
cy.findByText(fileName).click()
cy.get('.cm-content').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@example.com')
createProject(sourceProjectName, {
type: 'Example Project',
open: false,
}).as('sourceProjectId')
createProject(targetProjectName)
// link the image from `projectName` into this project
cy.findByText('New file').click({ force: true })
cy.findByRole('dialog').within(() => {
cy.findByText('From another project').click()
cy.findByLabelText('Select a Project').select(sourceProjectName)
cy.findByLabelText('Select a File').select('frog.jpg')
cy.findByText('Create').click()
})
cy.findByTestId('file-tree').findByText('frog.jpg').click()
cy.findByText('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@example.com')
createProject(sourceProjectName, {
type: 'Example Project',
open: false,
}).as('sourceProjectId')
createProject(targetProjectName).as('targetProjectId')
// link the image from `projectName` into this project
cy.findByText('New file').click({ force: true })
cy.findByRole('dialog').within(() => {
cy.findByText('From another project').click()
cy.findByLabelText('Select a Project').select(sourceProjectName)
cy.findByLabelText('Select a File').select('frog.jpg')
cy.findByText('Create').click()
})
cy.findByText('Share').click()
cy.findByRole('dialog').within(() => {
cy.findByTestId('collaborator-email-input').type(
'collaborator@example.com,'
)
cy.findByText('Invite').click({ force: true })
cy.findByText('Invite not yet accepted.')
})
cy.visit('/project')
cy.findByText('Account').click()
cy.findByText('Log Out').click()
login('collaborator@example.com')
openProjectViaInviteNotification(targetProjectName)
cy.get('@targetProjectId').then(targetProjectId => {
cy.url().should('include', targetProjectId)
})
cy.findByTestId('file-tree').findByText('frog.jpg').click()
cy.findByText('Another project')
.should('have.attr', 'href')
.then(href => {
cy.get('@sourceProjectId').then(sourceProjectId => {
expect(href).to.equal(`/project/${sourceProjectId}`)
})
})
})
})

View File

@@ -0,0 +1,26 @@
import { isExcludedBySharding, startWith } from './helpers/config'
describe('Customization', () => {
if (isExcludedBySharding('CE_CUSTOM_1')) return
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', () => {
cy.visit('/')
cy.get('nav').findByText('CUSTOM APP NAME')
})
it('should display custom left footer', () => {
cy.visit('/')
cy.get('footer').findByText('CUSTOM LEFT FOOTER')
})
it('should display custom right footer', () => {
cy.visit('/')
cy.get('footer').findByText('CUSTOM RIGHT FOOTER')
})
})

View File

@@ -0,0 +1,28 @@
const { defineConfig } = require('cypress')
const { readPdf, readFileInZip } = require('./helpers/read-file')
const specPattern = process.env.SPEC_PATTERN || './**/*.spec.{js,ts,tsx}'
module.exports = defineConfig({
defaultCommandTimeout: 10_000,
fixturesFolder: 'cypress/fixtures',
video: process.env.CYPRESS_VIDEO === 'true',
screenshotsFolder: 'cypress/results',
videosFolder: 'cypress/results',
videoUploadOnPasses: false,
viewportHeight: 768,
viewportWidth: 1024,
e2e: {
baseUrl: 'http://localhost',
setupNodeEvents(on, config) {
on('task', {
readPdf,
readFileInZip,
})
},
specPattern,
},
retries: {
runMode: 3,
},
})

3
server-ce/test/cypress/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
downloads/
results/
data/

View File

@@ -0,0 +1,12 @@
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
}
})

View File

@@ -0,0 +1,25 @@
version: '2.2'
services:
sharelatex:
ports:
- "127.0.0.1:80:80"
environment:
OVERLEAF_SITE_URL: 'http://localhost'
host-admin:
ports:
- "127.0.0.1:8081:80"
environment:
NATIVE_CYPRESS: 'true'
ACCESS_CONTROL_ALLOW_ORIGIN: 'http://localhost'
saml:
ports:
- 127.0.0.1:8082:80
environment:
SAML_BASE_URL_PATH: 'http://localhost:8082/simplesaml/'
SAML_TEST_SP_LOCATION: 'http://localhost/saml/callback'
mailtrap:
ports:
- 127.0.0.1:8083:80

View File

@@ -0,0 +1,149 @@
version: '2.2'
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: Overleaf Community Edition
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'
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:6.0
command: '--replSet overleaf'
volumes:
- ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the replica set.
# This override is not needed when running the setup after starting up mongo.
- mongo:127.0.0.1
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:
image: cypress/included:13.13.2
stop_grace_period: 0s
entrypoint: npm
command: run cypress:run
working_dir: /e2e
volumes:
- ./:/e2e
environment:
CYPRESS_SHARD:
CYPRESS_BASE_URL: http://sharelatex
SPEC_PATTERN: '**/*.spec.{js,jsx,ts,tsx}'
depends_on:
sharelatex:
condition: service_healthy
host-admin:
condition: service_healthy
e2e-open:
image: cypress/included:13.13.2
stop_grace_period: 0s
entrypoint: npm
command: run cypress:open
working_dir: /e2e
volumes:
- ./:/e2e
- /tmp/.X11-unix:/tmp/.X11-unix
user: "${DOCKER_USER:-1000:1000}"
environment:
CYPRESS_SHARD:
CYPRESS_BASE_URL: http://sharelatex
SPEC_PATTERN: '**/*.spec.{js,jsx,ts,tsx}'
DISPLAY: ${DISPLAY:-:0}
depends_on:
sharelatex:
condition: service_healthy
host-admin:
condition: service_healthy
host-admin:
build: .
entrypoint: ["node", "--watch", "host-admin.js"]
# See comment in Makefile regarding matching file paths
working_dir: $PWD
volumes:
- $PWD:$PWD
- /var/run/docker.sock:/var/run/docker.sock
stop_grace_period: 0s
environment:
PWD:
CYPRESS_SHARD:
COMPOSE_PROJECT_NAME:
TEX_LIVE_DOCKER_IMAGE:
ALL_TEX_LIVE_DOCKER_IMAGES:
IMAGE_TAG_CE: ${IMAGE_TAG_CE:-sharelatex/sharelatex:latest}
IMAGE_TAG_PRO: ${IMAGE_TAG_PRO:-quay.io/sharelatex/sharelatex-pro:latest}
depends_on:
mongo:
condition: service_healthy
healthcheck:
test: curl --fail http://localhost/status
interval: 3s
timeout: 3s
retries: 30
saml:
restart: always
image: gcr.io/overleaf-ops/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'

View File

@@ -0,0 +1,382 @@
import {
createNewFile,
createProject,
enableLinkSharing,
openFile,
openProjectById,
openProjectViaLinkSharingAsUser,
toggleTrackChanges,
} 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'
describe('editor', () => {
if (isExcludedBySharding('PRO_DEFAULT_1')) return
startWith({ pro: true })
ensureUserExists({ email: 'user@example.com' })
ensureUserExists({ email: 'collaborator@example.com' })
let projectName: string
let projectId: string
let recompile: () => void
let waitForCompileRateLimitCoolOff: (fn: () => void) => void
beforeWithReRunOnTestRetry(function () {
projectName = `project-${uuid()}`
login('user@example.com')
createProject(projectName, { type: 'Example Project', open: false }).then(
id => (projectId = id)
)
;({ recompile, waitForCompileRateLimitCoolOff } =
prepareWaitForNextCompileSlot())
})
beforeEach(() => {
login('user@example.com')
waitForCompileRateLimitCoolOff(() => {
openProjectById(projectId)
})
})
describe('spelling', function () {
function changeSpellCheckLanguageTo(lng: string) {
cy.log(`change project language to '${lng}'`)
cy.get('button').contains('Menu').click()
cy.get('select[id=settings-menu-spellCheckLanguage]').select(lng)
cy.get('[id="left-menu"]').type('{esc}') // close left menu
}
afterEach(function () {
changeSpellCheckLanguageTo('Off')
})
it('word dictionary and spelling', () => {
changeSpellCheckLanguageTo('English (American)')
createNewFile()
const word = createRandomLetterString()
cy.log('edit project file')
cy.get('.cm-line').type(word)
cy.get('.ol-cm-spelling-error').should('exist')
changeSpellCheckLanguageTo('Spanish')
cy.log('add word to dictionary')
cy.get('.ol-cm-spelling-error').contains(word).rightclick()
cy.findByText('Add to Dictionary').click()
cy.get('.ol-cm-spelling-error').should('not.exist')
cy.log('remove word from dictionary')
cy.get('button').contains('Menu').click()
cy.get('button').contains('Edit').click()
cy.get('[id="dictionary-modal"]').within(() => {
cy.findByText(word)
.parent()
.within(() => cy.get('button').click())
// the modal has 2 close buttons, this ensures the one with the visible label is
// clicked, otherwise it would need `force: true`
cy.get('.btn').contains('Close').click()
})
cy.log('close left panel')
cy.get('[id="left-menu"]').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('collaboration', () => {
beforeWithReRunOnTestRetry(function () {
enableLinkSharing().then(({ linkSharingReadAndWrite }) => {
const email = 'collaborator@example.com'
login(email)
openProjectViaLinkSharingAsUser(
linkSharingReadAndWrite,
projectName,
email
)
})
login('user@example.com')
waitForCompileRateLimitCoolOff(() => {
openProjectById(projectId)
})
})
it('track-changes', () => {
cy.log('disable track-changes before populating doc')
toggleTrackChanges(false)
const fileName = createNewFile()
const oldContent = 'oldContent'
cy.get('.cm-line').type(`${oldContent}\n\nstatic`)
cy.log('recompile to force flush')
recompile()
cy.log('enable track-changes for everyone')
toggleTrackChanges(true)
login('collaborator@example.com')
waitForCompileRateLimitCoolOff(() => {
openProjectById(projectId)
})
openFile(fileName, 'static')
cy.log('make changes in main file')
// cy.type() "clicks" in the center of the selected element before typing. This "click" discards the text as selected by the dblclick.
// Go down to the lower level event based typing, the frontend tests in web use similar events.
cy.get('.cm-editor').as('editor')
cy.get('@editor').findByText(oldContent).dblclick()
cy.get('@editor').trigger('keydown', { key: 'Delete' })
cy.get('@editor').trigger('keydown', { key: 'Enter' })
cy.get('@editor').trigger('keydown', { key: 'Enter' })
cy.log('recompile to force flush')
recompile()
login('user@example.com')
waitForCompileRateLimitCoolOff(() => {
openProjectById(projectId)
})
openFile(fileName, 'static')
cy.log('reject changes')
cy.contains('.toolbar-item', 'Review').click()
cy.get('.cm-content').should('not.contain.text', oldContent)
cy.findByText('Reject change').click({ force: true })
cy.contains('.toolbar-item', 'Review').click()
cy.log('recompile to force flush')
recompile()
cy.log('verify the changes are applied')
cy.get('.cm-content').should('contain.text', oldContent)
cy.log('disable track-changes for everyone again')
toggleTrackChanges(false)
})
it('track-changes rich text', () => {
cy.log('disable track-changes before populating doc')
toggleTrackChanges(false)
const fileName = createNewFile()
const oldContent = 'oldContent'
cy.get('.cm-line').type(`\\section{{}${oldContent}}\n\nstatic`)
cy.log('recompile to force flush')
recompile()
cy.log('enable track-changes for everyone')
toggleTrackChanges(true)
login('collaborator@example.com')
waitForCompileRateLimitCoolOff(() => {
openProjectById(projectId)
})
cy.log('enable visual editor and make changes in main file')
cy.findByText('Visual Editor').click()
openFile(fileName, 'static')
// cy.type() "clicks" in the center of the selected element before typing. This "click" discards the text as selected by the dblclick.
// Go down to the lower level event based typing, the frontend tests in web use similar events.
cy.get('.cm-editor').as('editor')
cy.get('@editor').findByText(oldContent).dblclick()
cy.get('@editor').trigger('keydown', { key: 'Delete' })
cy.get('@editor').trigger('keydown', { key: 'Enter' })
cy.get('@editor').trigger('keydown', { key: 'Enter' })
cy.log('recompile to force flush')
recompile()
login('user@example.com')
waitForCompileRateLimitCoolOff(() => {
openProjectById(projectId)
})
openFile(fileName, 'static')
cy.log('reject changes')
cy.contains('.toolbar-item', 'Review').click()
cy.get('.cm-content').should('not.contain.text', oldContent)
cy.findAllByText('Reject change').first().click({ force: true })
cy.contains('.toolbar-item', 'Review').click()
cy.log('recompile to force flush')
recompile()
cy.log('verify the changes are applied in the visual editor')
cy.findByText('Visual Editor').click()
cy.get('.cm-content').should('contain.text', oldContent)
cy.log('disable track-changes for everyone again')
toggleTrackChanges(false)
})
})
describe('editor', () => {
it('renders jpg', () => {
cy.findByTestId('file-tree').findByText('frog.jpg').click()
cy.get('[alt="frog.jpg"]')
.should('be.visible')
.and('have.prop', 'naturalWidth')
.should('be.greaterThan', 0)
})
it('symbol palette', () => {
createNewFile()
cy.get('button[aria-label="Toggle Symbol Palette"]').click({
force: true,
})
cy.get('button').contains('𝜉').click()
cy.get('.cm-content').should('contain.text', '\\xi')
cy.log('recompile to force flush and avoid "unsaved changes" prompt')
recompile()
})
})
describe('add new file to project', () => {
beforeEach(() => {
cy.get('button').contains('New file').click({ force: true })
})
it('can upload file', () => {
const name = `${uuid()}.txt`
const content = `Test File Content ${name}`
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 }
)
// force: The file-tree pane is too narrow to display the full name.
cy.findByTestId('file-tree').findByText(name).click({ force: true })
cy.findByText(content)
})
it('should not display import from URL', () => {
cy.findByText('From external URL').should('not.exist')
})
})
describe('left menu', () => {
beforeEach(() => {
cy.get('button').contains('Menu').click()
})
it('can download project sources', () => {
cy.get('a').contains('Source').click()
cy.task('readFileInZip', {
pathToZip: `cypress/downloads/${projectName}.zip`,
fileToRead: 'main.tex',
}).should('contain', 'Your introduction goes here')
})
it('can download project PDF', () => {
cy.log('ensure project is compiled')
cy.get('.pdf-viewer').should('contain.text', 'Your Paper')
cy.get('.nav-downloads').within(() => {
cy.findByText('PDF').click()
const pdfName = projectName.replaceAll('-', '_')
cy.task('readPdf', `cypress/downloads/${pdfName}.pdf`).should(
'contain',
'Your introduction goes here'
)
})
})
it('word count', () => {
cy.log('ensure project is compiled')
cy.get('.pdf-viewer').should('contain.text', 'Your Paper')
cy.findByText('Word Count').click()
cy.get('#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('layout selector', () => {
it('show editor only and switch between editor and pdf', () => {
cy.get('.pdf-viewer').should('be.visible')
cy.get('.cm-editor').should('be.visible')
cy.findByText('Layout').click()
cy.findByText('Editor only').click()
cy.get('.pdf-viewer').should('not.be.visible')
cy.get('.cm-editor').should('be.visible')
cy.findByText('Switch to PDF').click()
cy.get('.pdf-viewer').should('be.visible')
cy.get('.cm-editor').should('not.be.visible')
cy.findByText('Switch to editor').click()
cy.get('.pdf-viewer').should('not.be.visible')
cy.get('.cm-editor').should('be.visible')
})
it('show PDF only and go back to Editor & PDF', () => {
cy.get('.pdf-viewer').should('be.visible')
cy.get('.cm-editor').should('be.visible')
cy.findByText('Layout').click()
cy.findByText('PDF only').click()
cy.get('.pdf-viewer').should('be.visible')
cy.get('.cm-editor').should('not.be.visible')
cy.findByText('Layout').click()
cy.findByText('Editor & PDF').click()
cy.get('.pdf-viewer').should('be.visible')
cy.get('.cm-editor').should('be.visible')
})
it('PDF in a separate tab (tests editor only)', () => {
cy.get('.pdf-viewer').should('be.visible')
cy.get('.cm-editor').should('be.visible')
cy.findByText('Layout').click()
cy.findByText('PDF in separate tab').click()
cy.get('.pdf-viewer').should('not.exist')
cy.get('.cm-editor').should('be.visible')
})
})
})
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
}

View File

@@ -0,0 +1,73 @@
import { isExcludedBySharding, startWith } from './helpers/config'
import { createProject } from './helpers/project'
describe('SAML', () => {
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', () => {
cy.visit('/')
cy.findByText('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', () => {
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', () => {
cy.visit('/')
cy.findByText('Log in LDAP')
cy.get('input[name="login"]').type('fry')
cy.get('input[name="password"]').type('fry')
cy.get('button[type="submit"]').click()
cy.log('wait for login to finish')
cy.url().should('contain', '/project')
createProject('via LDAP')
})
})

View File

@@ -0,0 +1,396 @@
import { v4 as uuid } from 'uuid'
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import {
createProject,
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 { throttledRecompile } from './helpers/compile'
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@example.com' })
function clearAllTokens() {
cy.get('button.linking-git-bridge-revoke-button').each(el => {
cy.wrap(el).click()
cy.findByText('Delete token').click()
})
}
function maybeClearAllTokens() {
cy.visit('/user/settings')
cy.findByText('Git Integration')
cy.get('button')
.contains(/Generate token|Add another token/)
.then(btn => {
if (btn.text() === 'Add another token') {
clearAllTokens()
}
})
}
beforeEach(function () {
login('user@example.com')
})
it('should render the git-bridge UI in the settings', () => {
maybeClearAllTokens()
cy.visit('/user/settings')
cy.findByText('Git Integration')
cy.get('button').contains('Generate token').click()
cy.get('code')
.contains(/olp_[a-zA-Z0-9]{16}/)
.as('newToken')
cy.findAllByText('Close').last().click()
cy.get('@newToken').then(token => {
// There can be more than one token with the same prefix when retrying
cy.findAllByText(
`${token.text().slice(0, 'olp_1234'.length)}${'*'.repeat(12)}`
).should('have.length.at.least', 1)
})
cy.get('button').contains('Generate token').should('not.exist')
cy.get('button').contains('Add another token').should('exist')
clearAllTokens()
cy.get('button').contains('Generate token').should('exist')
cy.get('button').contains('Add another token').should('not.exist')
})
it('should render the git-bridge UI in the editor', function () {
maybeClearAllTokens()
createProject('git').as('projectId')
cy.get('header').findByText('Menu').click()
cy.findByText('Sync')
cy.findByText('Git').click()
cy.findByTestId('git-bridge-modal').within(() => {
cy.get('@projectId').then(id => {
cy.get('code').contains(`git clone ${gitURL(id.toString())}`)
})
cy.findByRole('button', {
name: 'Generate token',
}).click()
cy.get('code').contains(/olp_[a-zA-Z0-9]{16}/)
})
// Re-open
cy.url().then(url => cy.visit(url))
cy.get('header').findByText('Menu').click()
cy.findByText('Git').click()
cy.findByTestId('git-bridge-modal').within(() => {
cy.get('@projectId').then(id => {
cy.get('code').contains(`git clone ${gitURL(id.toString())}`)
})
cy.findByText('Generate token').should('not.exist')
cy.findByText(/generate a new one in Account Settings/)
cy.findByText('Go to settings')
.should('have.attr', 'target', '_blank')
.and('have.attr', 'href', '/user/settings')
})
})
describe('git access', () => {
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
beforeEach(() => {
projectName = uuid()
createProject(projectName, { open: false }).as('projectId')
})
it('should expose r/w interface to owner', () => {
maybeClearAllTokens()
openProjectByName(projectName)
checkGitAccess('readAndWrite')
})
it('should expose r/w interface to invited r/w collaborator', () => {
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
'collaborator-rw@example.com',
'Editor'
)
maybeClearAllTokens()
openProjectByName(projectName)
checkGitAccess('readAndWrite')
})
it('should expose r/o interface to invited r/o collaborator', () => {
shareProjectByEmailAndAcceptInviteViaDash(
projectName,
'collaborator-ro@example.com',
'Viewer'
)
maybeClearAllTokens()
openProjectByName(projectName)
checkGitAccess('readOnly')
})
it('should expose r/w interface to link-sharing r/w collaborator', () => {
openProjectByName(projectName)
enableLinkSharing().then(({ linkSharingReadAndWrite }) => {
const email = 'collaborator-link-rw@example.com'
login(email)
maybeClearAllTokens()
openProjectViaLinkSharingAsUser(
linkSharingReadAndWrite,
projectName,
email
)
checkGitAccess('readAndWrite')
})
})
it('should expose r/o interface to link-sharing r/o collaborator', () => {
openProjectByName(projectName)
enableLinkSharing().then(({ linkSharingReadOnly }) => {
const email = 'collaborator-link-ro@example.com'
login(email)
maybeClearAllTokens()
openProjectViaLinkSharingAsUser(
linkSharingReadOnly,
projectName,
email
)
checkGitAccess('readOnly')
})
})
})
function checkGitAccess(access: 'readOnly' | 'readAndWrite') {
const recompile = throttledRecompile()
cy.get('header').findByText('Menu').click()
cy.findByText('Sync')
cy.findByText('Git').click()
cy.get('@projectId').then(projectId => {
cy.findByTestId('git-bridge-modal').within(() => {
cy.get('code').contains(`git clone ${gitURL(projectId.toString())}`)
})
cy.findByRole('button', {
name: 'Generate token',
}).click()
cy.get('code')
.contains(/olp_[a-zA-Z0-9]{16}/)
.then(async tokenEl => {
const token = tokenEl.text()
// close Git modal
cy.findAllByText('Close').last().click()
// close editor menu
cy.get('.left-menu-modal-backdrop').click()
const fs = new LightningFS('fs')
const dir = `/${projectId}`
async function readFile(path: string): Promise<string> {
return 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 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@example.com' },
committer: { name: 'user', email: 'user@example.com' },
}
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.findAllByText('History').last().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.findAllByText('History').last().click()
cy.findAllByText('Back to editor').last().click()
// check push in history
cy.findAllByText('History').last().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()
.click()
.type('% via editor{enter}')
// Trigger flush via compile
recompile()
// Back into the history, check what we just added
cy.findAllByText('History').last().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@example.com' })
it('should not render the git-bridge UI in the settings', () => {
login('user@example.com')
cy.visit('/user/settings')
cy.findByText('Git Integration').should('not.exist')
})
it('should not render the git-bridge UI in the editor', function () {
login('user@example.com')
createProject('maybe git')
cy.get('header').findByText('Menu').click()
cy.findByText('Word Count') // wait for lazy loading
cy.findByText('Sync').should('not.exist')
cy.findByText('Git').should('not.exist')
})
}
describe('disabled in Server Pro', () => {
if (isExcludedBySharding('PRO_DEFAULT_1')) return
startWith({
pro: true,
})
checkDisabled()
})
describe('unavailable in CE', () => {
if (isExcludedBySharding('CE_CUSTOM_1')) return
startWith({
pro: false,
vars: ENABLED_VARS,
})
checkDisabled()
})
})

View File

@@ -0,0 +1,95 @@
import { ensureUserExists, login } from './helpers/login'
import {
isExcludedBySharding,
STARTUP_TIMEOUT,
startWith,
} from './helpers/config'
import { dockerCompose, getRedisKeys } from './helpers/hostAdminClient'
import { createProject } from './helpers/project'
import { throttledRecompile } 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', () => {
bringServerProBackUp()
login(USER)
createProject(PROJECT_NAME).then(id => {
projectId = id
})
const recompile = throttledRecompile()
cy.log('add additional content')
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.get('.pdf-viewer').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.findByText(/performing maintenance/)
cy.log('wait for page reload')
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.findByText('New Section')
cy.log('check PDF')
cy.get('.pdf-viewer').should('contain.text', 'New Section')
cy.log('check history')
cy.findByText('History').click()
cy.findByText(/\\section\{New Section}/)
})
})

View File

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

View File

@@ -0,0 +1,40 @@
/**
* Helper function for throttling clicks on the recompile button to avoid hitting server side rate limits.
* The naive approach is waiting a fixed a mount of time (3s) just before clicking the button.
* This helper takes into account that other UI interactions take time. We can deduce that latency from the fixed delay (3s minus other latency). This can bring down the effective waiting time to 0s.
*/
export function throttledRecompile() {
const { queueReset, recompile } = prepareWaitForNextCompileSlot()
queueReset()
return recompile
}
export function prepareWaitForNextCompileSlot() {
let lastCompile = 0
function queueReset() {
cy.then(() => {
lastCompile = Date.now()
})
}
function waitForCompileRateLimitCoolOff(triggerCompile: () => void) {
cy.then(() => {
cy.log('Wait for recompile rate-limit to cool off')
const msSinceLastCompile = Date.now() - lastCompile
cy.wait(Math.max(0, 1_000 - msSinceLastCompile))
queueReset()
triggerCompile()
cy.log('Wait for compile to finish')
cy.findByText('Recompile').should('be.visible')
})
}
function recompile() {
waitForCompileRateLimitCoolOff(() => {
cy.findByText('Recompile').click()
})
}
return {
queueReset,
waitForCompileRateLimitCoolOff,
recompile,
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,251 @@
import { login } from './login'
import { openEmail } from './email'
import { v4 as uuid } from 'uuid'
export function createProject(
name: string,
{
type = 'Blank Project',
newProjectButtonMatcher = /new project/i,
open = true,
}: {
type?: 'Blank Project' | 'Example Project'
newProjectButtonMatcher?: RegExp
open?: boolean
} = {}
): Cypress.Chainable<string> {
cy.url().then(url => {
if (!url.endsWith('/project')) {
cy.visit('/project')
}
})
const interceptId = uuid()
let projectId = ''
if (!open) {
cy.then(() => {
// Register intercept just before creating the project, otherwise we might
// intercept a request from a prior createProject invocation.
cy.intercept(
{ method: 'GET', url: /\/project\/[a-fA-F0-9]{24}$/, times: 1 },
req => {
projectId = req.url.split('/').pop()!
// Redirect back to the project dashboard, effectively reload the page.
req.redirect('/project')
}
).as(interceptId)
})
}
cy.findAllByRole('button').contains(newProjectButtonMatcher).click()
// FIXME: This should only look in the left menu
cy.findAllByText(type).first().click()
cy.findByRole('dialog').within(() => {
cy.get('input').type(name)
cy.findByText('Create').click()
})
if (open) {
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
waitForMainDocToLoad()
return cy
.url()
.should('match', /\/project\/[a-fA-F0-9]{24}/)
.then(url => url.split('/').pop())
} else {
const alias = `@${interceptId}` // IDEs do not like computed values in cy.wait().
cy.wait(alias)
return cy.then(() => projectId)
}
}
export function openProjectByName(projectName: string) {
cy.visit('/project')
cy.findByText(projectName).click()
waitForMainDocToLoad()
}
export function openProjectById(projectId: string) {
cy.visit(`/project/${projectId}`)
waitForMainDocToLoad()
}
export function openProjectViaLinkSharingAsAnon(url: string) {
cy.visit(url)
waitForMainDocToLoad()
}
export function openProjectViaLinkSharingAsUser(
url: string,
projectName: string,
email: string
) {
cy.visit(url)
cy.findByText(projectName) // wait for lazy loading
cy.contains(`as ${email}`)
cy.findByText('OK, join project').click()
waitForMainDocToLoad()
}
export function openProjectViaInviteNotification(projectName: string) {
cy.visit('/project')
cy.findByText(projectName)
.parent()
.parent()
.within(() => {
cy.findByText('Join Project').click()
})
cy.findByText('Open Project').click()
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
waitForMainDocToLoad()
}
function shareProjectByEmail(
projectName: string,
email: string,
level: 'Viewer' | 'Editor'
) {
openProjectByName(projectName)
cy.findByText('Share').click()
cy.findByRole('dialog').within(() => {
cy.findByLabelText('Add people', { selector: 'input' }).type(`${email},`)
cy.findByLabelText('Add people', { selector: 'input' })
.parents('form')
.within(() => {
cy.findByTestId('add-collaborator-select')
.click()
.then(() => {
cy.findByText(level).click()
})
})
cy.findByText('Invite').click({ force: true })
cy.findByText('Invite not yet accepted.')
})
}
export function shareProjectByEmailAndAcceptInviteViaDash(
projectName: string,
email: string,
level: 'Viewer' | 'Editor'
) {
shareProjectByEmail(projectName, email, level)
login(email)
openProjectViaInviteNotification(projectName)
}
export function shareProjectByEmailAndAcceptInviteViaEmail(
projectName: string,
email: string,
level: 'Viewer' | 'Editor'
) {
shareProjectByEmail(projectName, email, level)
login(email)
openEmail(projectName, frame => {
frame.contains('View project').then(a => {
cy.log(
'bypass target=_blank and navigate current browser tab/cypress-iframe to project invite'
)
cy.visit(a.attr('href')!)
})
})
cy.url().should('match', /\/project\/[a-f0-9]+\/invite\/token\/[a-f0-9]+/)
cy.findByText(/user would like you to join/)
cy.contains(new RegExp(`You are accepting this invite as ${email}`))
cy.findByText('Join Project').click()
waitForMainDocToLoad()
}
export function enableLinkSharing() {
let linkSharingReadOnly: string
let linkSharingReadAndWrite: string
const origin = new URL(Cypress.config().baseUrl!).origin
waitForMainDocToLoad()
cy.findByText('Share').click()
cy.findByText('Turn on link sharing').click()
cy.findByText('Anyone with this link can view this project')
.next()
.should('contain.text', origin + '/read')
.then(el => {
linkSharingReadOnly = el.text()
})
cy.findByText('Anyone with this link can edit this project')
.next()
.should('contain.text', origin + '/')
.then(el => {
linkSharingReadAndWrite = el.text()
})
return cy.then(() => {
return { linkSharingReadOnly, linkSharingReadAndWrite }
})
}
export function waitForMainDocToLoad() {
cy.log('Wait for main doc to load; it will steal the focus after loading')
cy.get('.cm-content').should('contain.text', 'Introduction')
}
export function openFile(fileName: string, waitFor: string) {
// force: The file-tree pane is too narrow to display the full name.
cy.findByTestId('file-tree').findByText(fileName).click({ force: true })
// wait until we've switched to the selected file
cy.findByText('Loading…').should('not.exist')
cy.findByText(waitFor)
}
export function createNewFile() {
const fileName = `${uuid()}.tex`
cy.log('create new project file')
cy.get('button').contains('New file').click({ force: true })
cy.findByRole('dialog').within(() => {
cy.get('input').clear()
cy.get('input').type(fileName)
cy.findByText('Create').click()
})
// force: The file-tree pane is too narrow to display the full name.
cy.findByTestId('file-tree').findByText(fileName).click({ force: true })
// wait until we've switched to the newly created empty file
cy.findByText('Loading…').should('not.exist')
cy.get('.cm-line').should('have.length', 1)
return fileName
}
export function toggleTrackChanges(state: boolean) {
cy.findByText('Review').click()
cy.get('.track-changes-menu-button').then(el => {
// when the menu is expanded renders the `expand_more` icon,
// and the `chevron_right` icon when it's collapsed
if (!el.text().includes('expand_more')) {
el.click()
}
})
cy.findByText('Everyone')
.parent()
.within(() => {
cy.get('.form-check-input').then(el => {
if (el.prop('checked') === state) return
const id = uuid()
const alias = `@${id}`
cy.intercept({
method: 'POST',
url: '**/track_changes',
times: 1,
}).as(id)
if (state) {
cy.get('.form-check-input').check()
} else {
cy.get('.form-check-input').uncheck()
}
cy.wait(alias)
})
})
cy.contains('.toolbar-item', 'Review').click()
}

View File

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

View File

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

View File

@@ -0,0 +1,124 @@
import { createProject } from './helpers/project'
import { throttledRecompile } 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)}`)
cy.findByText('Labels').click()
cy.findAllByTestId('history-version-details')
.first()
.within(() => {
cy.get('button').click() // TODO: add test-id or aria-label
cy.findByText('Label this version').click()
})
cy.findByRole('dialog').within(() => {
cy.get('input[placeholder="New label name"]').type(`${name}{enter}`)
})
}
function downloadVersion(name: string) {
cy.log(`download version ${JSON.stringify(name)}`)
cy.findByText('Labels').click()
cy.findByText(name)
.closest('[data-testid="history-version-details"]')
.within(() => {
cy.get('.history-version-dropdown-menu-btn').click()
cy.findByText('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', () => {
createProject('labels')
const recompile = throttledRecompile()
cy.log('add content, including a line that will get removed soon')
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type('\n% added')
cy.findByText('\\maketitle').parent().type('\n% to be removed')
recompile()
cy.findByText('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.findByText('Back to editor').click()
cy.findByText('% to be removed').parent().type('{end}{shift}{upArrow}{del}')
recompile()
cy.findByText('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.findByText('Back to editor').click()
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type('\n% more')
recompile()
cy.log('compare non current versions')
cy.findByText('History').click()
cy.findByText('Labels').click()
cy.findAllByTestId('compare-icon-version').last().click()
cy.findAllByTestId('compare-icon-version').filter(':visible').click()
cy.findByText('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')
})
})

View File

@@ -0,0 +1,331 @@
const fs = require('fs')
const Path = require('path')
const { execFile } = require('child_process')
const express = require('express')
const bodyParser = require('body-parser')
const {
celebrate: validate,
Joi,
errors: handleValidationErrors,
} = require('celebrate')
const YAML = require('js-yaml')
const DATA_DIR = Path.join(
__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(/:.+/, ''),
}
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 {
services: {
sharelatex: {
environment: {},
},
'git-bridge': {},
},
}
}
}
function writeDockerComposeOverride(cfg) {
fs.writeFileSync(PATHS.DOCKER_COMPOSE_OVERRIDE, YAML.dump(cfg))
}
function runDockerCompose(command, args, callback) {
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)
}
execFile('docker', ['compose', ...files, command, ...args], callback)
}
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
console.log(req.method, req.url, req.body)
// 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')
next()
})
app.post(
'/run/script',
validate(
{
body: {
cwd: Joi.string().required(),
script: Joi.string().required(),
args: Joi.array().items(Joi.string()),
},
},
{ allowUnknown: false }
),
(req, res) => {
const { cwd, script, args } = req.body
runDockerCompose(
'exec',
[
'sharelatex',
'bash',
'-c',
`source /etc/container_environment.sh && source /etc/overleaf/env.sh || source /etc/sharelatex/env.sh && cd ${JSON.stringify(cwd)} && node ${JSON.stringify(script)} ${args.map(a => JSON.stringify(a)).join(' ')}`,
],
(error, stdout, stderr) => {
res.json({
error,
stdout,
stderr,
})
}
)
}
)
const allowedVars = Joi.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',
'DOCKER_RUNNER',
'SANDBOXED_COMPILES',
'SANDBOXED_COMPILES_SIBLING_CONTAINERS',
'ALL_TEX_LIVE_DOCKER_IMAGE_NAMES',
'OVERLEAF_TEMPLATES_USER_ID',
'OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS',
'OVERLEAF_ALLOW_PUBLIC_ACCESS',
'OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_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, Joi.string()])
)
)
function setVarsDockerCompose({ pro, vars, version, withDataDir }) {
const cfg = readDockerComposeOverride()
cfg.services.sharelatex.image = `${pro ? IMAGES.PRO : IMAGES.CE}:${version}`
cfg.services['git-bridge'].image = `quay.io/sharelatex/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/data'
: '/var/lib/sharelatex/data'
cfg.services.sharelatex.volumes = []
if (withDataDir) {
cfg.services.sharelatex.volumes.push(
`${PATHS.DATA_DIR}:${dataDirInContainer}`
)
}
if (
cfg.services.sharelatex.environment
.SANDBOXED_COMPILES_SIBLING_CONTAINERS === '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}/compiles`
)
}
}
writeDockerComposeOverride(cfg)
}
app.post(
'/docker/compose/:cmd',
validate(
{
body: {
args: Joi.array().allow(
'--detach',
'--wait',
'--volumes',
'--timeout=60',
'sharelatex',
'git-bridge',
'mongo',
'redis'
),
},
params: {
cmd: Joi.allow('up', 'stop', 'down', 'ps', 'logs'),
},
},
{ allowUnknown: false }
),
(req, res) => {
const { cmd } = req.params
const { args } = req.body
runDockerCompose(cmd, args, (error, stdout, stderr) => {
res.json({ error, stdout, stderr })
})
}
)
function maybeResetData(resetData, callback) {
if (!resetData) return callback()
previousConfig = ''
runDockerCompose(
'down',
['--timeout=0', '--volumes', 'mongo', 'redis', 'sharelatex'],
(error, stdout, stderr) => {
if (error) return callback(error, stdout, stderr)
try {
purgeDataDir()
} catch (error) {
return callback(error)
}
callback()
}
)
}
app.post(
'/reconfigure',
validate(
{
body: {
pro: Joi.boolean().required(),
version: Joi.string().required(),
vars: allowedVars,
withDataDir: Joi.boolean().optional(),
resetData: Joi.boolean().optional(),
},
},
{ allowUnknown: false }
),
(req, res) => {
const { pro, version, vars, withDataDir, resetData } = req.body
maybeResetData(resetData, (error, stdout, stderr) => {
if (error) return res.json({ error, stdout, stderr })
const previousConfigServer = previousConfig
const newConfig = JSON.stringify(req.body)
if (previousConfig === newConfig) {
return res.json({ previousConfigServer })
}
try {
setVarsDockerCompose({ pro, version, vars, withDataDir })
} catch (error) {
return res.json({ error })
}
if (error) return res.json({ error, stdout, stderr })
runDockerCompose(
'up',
['--detach', '--wait', 'sharelatex'],
(error, stdout, stderr) => {
previousConfig = newConfig
res.json({ error, stdout, stderr, previousConfigServer })
}
)
})
}
)
app.get('/redis/keys', (req, res) => {
runDockerCompose(
'exec',
['redis', 'redis-cli', 'KEYS', '*'],
(error, stdout, stderr) => {
res.json({ error, stdout, stderr })
}
)
})
app.use(handleValidationErrors())
purgeDataDir()
app.listen(80)

View File

@@ -0,0 +1,106 @@
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import { v4 as uuid } from 'uuid'
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', () => {
if (isExcludedBySharding('PRO_CUSTOM_2')) return
startWith({
pro: true,
vars: {
OVERLEAF_PROXY_LEARN: 'true',
},
})
it('should add a documentation entry to the nav bar', () => {
login(REGULAR_USER)
cy.visit('/project')
cy.get('nav').findByText('Documentation')
})
it('should display a tutorial link in the welcome page', () => {
login(WITHOUT_PROJECTS_USER)
cy.visit('/project')
cy.findByText(LABEL_LEARN_LATEX)
})
it('should render wiki page', () => {
login(REGULAR_USER)
cy.visit(UPLOADING_A_PROJECT_URL)
// Wiki content
cy.get('.page').findByText('Uploading a project')
cy.get('.page').contains(/how to create an Overleaf project/)
cy.get('img[alt="Creating a new project on Overleaf"]')
.should('be.visible')
.and((el: any) => {
expect(el[0].naturalWidth, 'renders image').to.be.greaterThan(0)
})
// Wiki navigation
cy.get('.contents').findByText('Copying a project')
})
it('should navigate back and forth', function () {
login(REGULAR_USER)
cy.visit(COPYING_A_PROJECT_URL)
cy.get('.page').findByText('Copying a project')
cy.get('.contents').findByText('Uploading a project').click()
cy.url().should('contain', UPLOADING_A_PROJECT_URL)
cy.get('.page').findByText('Uploading a project')
cy.get('.contents').findByText('Copying a project').click()
cy.url().should('contain', COPYING_A_PROJECT_URL)
cy.get('.page').findByText('Copying a project')
})
})
describe('disabled in Pro', () => {
if (isExcludedBySharding('PRO_DEFAULT_1')) return
startWith({ pro: true })
checkDisabled()
})
describe('unavailable in CE', () => {
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', () => {
login(REGULAR_USER)
cy.visit('/project')
cy.findByText('Documentation').should('not.exist')
})
it('should not render wiki page', () => {
login(REGULAR_USER)
cy.visit(COPYING_A_PROJECT_URL, {
failOnStatusCode: false,
})
cy.findByText('Not found')
})
it('should not display a tutorial link in the welcome page', () => {
login(WITHOUT_PROJECTS_USER)
cy.visit('/project')
cy.findByText(LABEL_LEARN_LATEX).should('not.exist')
})
}
})

3538
server-ce/test/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "@overleaf/server-ce/test",
"description": "e2e tests for Overleaf Community Edition",
"private": true,
"scripts": {
"cypress:open": "cypress open --e2e --browser chrome",
"cypress:run": "cypress run --e2e --browser chrome",
"format": "prettier --list-different $PWD/'**/*.{js,mjs,ts,tsx,json}'",
"format:fix": "prettier --write $PWD/'**/*.{js,mjs,ts,tsx,json}'"
},
"dependencies": {
"@isomorphic-git/lightning-fs": "^4.6.0",
"@testing-library/cypress": "^10.0.1",
"@types/adm-zip": "^0.5.5",
"@types/pdf-parse": "^1.1.4",
"@types/uuid": "^9.0.8",
"adm-zip": "^0.5.12",
"body-parser": "^1.20.3",
"celebrate": "^15.0.3",
"cypress": "13.13.2",
"express": "^4.21.2",
"isomorphic-git": "^1.25.10",
"js-yaml": "^4.1.0",
"pdf-parse": "^1.1.1",
"typescript": "^5.0.4",
"uuid": "^9.0.1"
}
}

View File

@@ -0,0 +1,106 @@
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', () => {
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', () => {
ensureUserExists({ email: WITHOUT_PROJECTS_USER })
it("'Import from GitHub' is not displayed in the welcome page", () => {
login(WITHOUT_PROJECTS_USER)
cy.visit('/project')
cy.findByText('Create a new project').click()
cy.findByText(/Import from GitHub/i).should('not.exist')
})
})
describe('user with projects', () => {
const projectName = `test-project-${uuid()}`
ensureUserExists({ email: REGULAR_USER })
before(() => {
login(REGULAR_USER)
createProject(projectName, { type: 'Example Project', open: false })
})
beforeEach(function () {
login(REGULAR_USER)
cy.visit('/project')
})
it('Can download project sources', () => {
findProjectRow(projectName).within(() =>
cy.findByRole('button', { name: 'Download .zip file' }).click()
)
cy.task('readFileInZip', {
pathToZip: `cypress/downloads/${projectName}.zip`,
fileToRead: 'main.tex',
}).should('contain', 'Your introduction goes here')
})
it('Can download project PDF', () => {
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', () => {
const tagName = uuid().slice(0, 7) // long tag names are truncated in the UI, which affects selectors
cy.log('select project')
cy.get(`[aria-label="Select ${projectName}"]`).click()
cy.log('add tag to project')
cy.get('button[aria-label="Tags"]').click()
cy.findByText('Create new tag').click()
cy.get('input[name="new-tag-form-name"]').type(`${tagName}{enter}`)
cy.get(`button[aria-label="Select tag ${tagName}"]`) // tag label in project row
cy.log('remove tag')
cy.get(`button[aria-label="Remove tag ${tagName}"]`)
.first()
.click({ force: true })
cy.get(`button[aria-label="Select tag ${tagName}"]`).should('not.exist')
})
it('can filter by tag', () => {
cy.log('create a separate project to filter')
const nonTaggedProjectName = `project-${uuid()}`
login(REGULAR_USER)
createProject(nonTaggedProjectName, { open: false })
cy.log('select project')
cy.get(`[aria-label="Select ${projectName}"]`).click()
cy.log('add tag to project')
const tagName = uuid().slice(0, 7) // long tag names are truncated in the UI, which affects selectors
cy.get('button[aria-label="Tags"]').click()
cy.findByText('Create new tag').click()
cy.get('input[name="new-tag-form-name"]').type(`${tagName}{enter}`)
cy.log(
'check the non-tagged project is filtered out after clicking the tag'
)
cy.findByText(nonTaggedProjectName).should('exist')
cy.get('button').contains(tagName).click({ force: true })
cy.findByText(nonTaggedProjectName).should('not.exist')
})
})
})

View File

@@ -0,0 +1,305 @@
import { v4 as uuid } from 'uuid'
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import {
createProject,
enableLinkSharing,
openProjectByName,
openProjectViaLinkSharingAsAnon,
openProjectViaLinkSharingAsUser,
shareProjectByEmailAndAcceptInviteViaDash,
shareProjectByEmailAndAcceptInviteViaEmail,
} from './helpers/project'
import { throttledRecompile } from './helpers/compile'
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
describe('Project Sharing', function () {
if (isExcludedBySharding('CE_CUSTOM_2')) return
ensureUserExists({ email: 'user@example.com' })
startWith({ withDataDir: true })
let projectName: string
beforeWithReRunOnTestRetry(function () {
projectName = `Project ${uuid()}`
setupTestProject()
})
beforeEach(() => {
// Always start with a fresh session
cy.session([uuid()], () => {})
})
let linkSharingReadOnly: string
let linkSharingReadAndWrite: string
function setupTestProject() {
login('user@example.com')
createProject(projectName)
// Add chat message
cy.findByText('Chat').click()
// wait for lazy loading of the chat pane
cy.findByText('Send your first message to your collaborators')
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.get('.cm-content').should('contain.text', '\\maketitle')
cy.get('.cm-content').should('have.attr', 'contenteditable', 'false')
}
function expectContentWriteAccess() {
const section = `Test Section ${uuid()}`
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
const recompile = throttledRecompile()
// wait for the editor to finish loading
cy.get('.cm-content').should('contain.text', '\\maketitle')
// the editor should be writable
cy.get('.cm-content').should('have.attr', 'contenteditable', 'true')
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle').parent().type(`\n\\section{{}${section}}`)
// should have written
cy.get('.cm-content').should('contain.text', `\\section{${section}}`)
// check PDF
recompile()
cy.get('.pdf-viewer').should('contain.text', projectName)
cy.get('.pdf-viewer').should('contain.text', 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.findByText('Chat').click()
cy.findByText('New Chat Message')
}
function expectHistoryAccess() {
cy.findByText('History').click()
cy.findByText('Labels')
cy.findByText(/\\begin\{document}/)
cy.findAllByTestId('history-version-metadata-users')
.last()
.should('have.text', 'user')
cy.findByText('Back to editor').click()
}
function expectNoChatAccess() {
cy.findByText('Layout') // wait for lazy loading
cy.findByText('Chat').should('not.exist')
}
function expectNoHistoryAccess() {
cy.findByText('Layout') // wait for lazy loading
cy.findByText('History').should('not.exist')
}
function expectFullReadOnlyAccess() {
expectContentReadOnlyAccess()
expectChatAccess()
expectHistoryAccess()
}
function expectRestrictedReadOnlyAccess() {
expectContentReadOnlyAccess()
expectNoChatAccess()
expectNoHistoryAccess()
}
function expectReadAndWriteAccess() {
expectContentWriteAccess()
expectChatAccess()
expectHistoryAccess()
}
function expectProjectDashboardEntry() {
cy.visit('/project')
cy.findByText(projectName)
}
function expectEditAuthoredAs(author: string) {
cy.findByText('History').click()
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')
})
it('should grant the collaborator read access', () => {
expectFullReadOnlyAccess()
expectProjectDashboardEntry()
})
})
describe('read only', () => {
const email = 'collaborator-ro@example.com'
ensureUserExists({ email })
beforeWithReRunOnTestRetry(function () {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaDash(projectName, email, 'Viewer')
})
it('should grant the collaborator read access', () => {
login(email)
openProjectByName(projectName)
expectFullReadOnlyAccess()
expectProjectDashboardEntry()
})
})
describe('read and write', () => {
const email = 'collaborator-rw@example.com'
ensureUserExists({ email })
beforeWithReRunOnTestRetry(function () {
login('user@example.com')
shareProjectByEmailAndAcceptInviteViaDash(projectName, email, 'Editor')
})
it('should grant the collaborator write access', () => {
login(email)
openProjectByName(projectName)
expectReadAndWriteAccess()
expectEditAuthoredAs('You')
expectProjectDashboardEntry()
})
})
describe('token access', () => {
describe('logged in', () => {
describe('read only', () => {
const email = 'collaborator-link-ro@example.com'
ensureUserExists({ email })
it('should grant restricted read access', () => {
login(email)
openProjectViaLinkSharingAsUser(
linkSharingReadOnly,
projectName,
email
)
expectRestrictedReadOnlyAccess()
expectProjectDashboardEntry()
})
})
describe('read and write', () => {
const email = 'collaborator-link-rw@example.com'
ensureUserExists({ email })
it('should grant full write access', () => {
login(email)
openProjectViaLinkSharingAsUser(
linkSharingReadAndWrite,
projectName,
email
)
expectReadAndWriteAccess()
expectEditAuthoredAs('You')
expectProjectDashboardEntry()
})
})
})
describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=false', () => {
describe('wrap startup', () => {
startWith({
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false',
},
withDataDir: true,
})
it('should block access', () => {
expectNoAccess()
})
})
describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', () => {
startWith({
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'false',
OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
},
withDataDir: true,
})
it('should block access', () => {
expectNoAccess()
})
})
})
describe('with OVERLEAF_ALLOW_PUBLIC_ACCESS=true', () => {
describe('wrap startup', () => {
startWith({
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
},
withDataDir: true,
})
it('should grant read access with read link', () => {
openProjectViaLinkSharingAsAnon(linkSharingReadOnly)
expectRestrictedReadOnlyAccess()
})
it('should prompt for login with write link', () => {
cy.visit(linkSharingReadAndWrite)
cy.url().should('match', /\/login/)
})
})
describe('with OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true', () => {
startWith({
vars: {
OVERLEAF_ALLOW_PUBLIC_ACCESS: 'true',
OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING: 'true',
},
withDataDir: true,
})
it('should grant read access with read link', () => {
openProjectViaLinkSharingAsAnon(linkSharingReadOnly)
expectRestrictedReadOnlyAccess()
})
it('should grant write access with write link', () => {
openProjectViaLinkSharingAsAnon(linkSharingReadAndWrite)
expectReadAndWriteAccess()
expectEditAuthoredAs('Anonymous')
})
})
})
})
})

View File

@@ -0,0 +1,245 @@
import { ensureUserExists, login } from './helpers/login'
import { createProject } from './helpers/project'
import { isExcludedBySharding, startWith } from './helpers/config'
import { throttledRecompile } from './helpers/compile'
import { v4 as uuid } from 'uuid'
import { waitUntilScrollingFinished } from './helpers/waitUntilScrollingFinished'
import { beforeWithReRunOnTestRetry } from './helpers/beforeWithReRunOnTestRetry'
const LABEL_TEX_LIVE_VERSION = 'TeX Live version'
describe('SandboxedCompiles', function () {
const enabledVars = {
DOCKER_RUNNER: 'true',
SANDBOXED_COMPILES: 'true',
SANDBOXED_COMPILES_SIBLING_CONTAINERS: '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 () {
createProject('sandboxed')
const recompile = throttledRecompile()
cy.log('wait for compile')
cy.get('.pdf-viewer').should('contain.text', 'sandboxed')
cy.log('Check which compiler version was used, expect 2023')
cy.get('[aria-label="View logs"]').click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2023\) /)
cy.log('Switch TeXLive version from 2023 to 2022')
cy.get('header').findByText('Menu').click()
cy.findByText(LABEL_TEX_LIVE_VERSION)
.parent()
.findByText('2023')
.parent()
.select('2022')
cy.get('.left-menu-modal-backdrop').click()
cy.log('Trigger compile with other TeX Live version')
recompile()
cy.log('Check which compiler version was used, expect 2022')
cy.get('[aria-label="View logs"]').click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2022\) /)
})
checkSyncTeX()
checkXeTeX()
checkRecompilesAfterErrors()
})
function checkSyncTeX() {
describe('SyncTeX', function () {
let projectName: string
beforeEach(function () {
projectName = `Project ${uuid()}`
createProject(projectName)
const recompile = throttledRecompile()
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.get('.pdf-viewer').within(() => {
cy.findByText(projectName)
})
})
it('should sync to code', function () {
cy.log('navigate to \\maketitle using double click in PDF')
cy.get('.pdf-viewer').within(() => {
cy.findByText(projectName).dblclick()
})
cy.get('.cm-activeLine').should('have.text', '\\maketitle')
cy.log('navigate to Section A using double click in PDF')
cy.get('.pdf-viewer').within(() => {
cy.findByText('Section A').dblclick()
})
cy.get('.cm-activeLine').should('have.text', '\\section{Section A}')
cy.log('navigate to Section B using arrow button')
cy.get('.pdfjs-viewer-inner')
.should('have.prop', 'scrollTop')
.as('start')
cy.get('.pdf-viewer').within(() => {
cy.findByText('Section B').scrollIntoView()
})
cy.get('@start').then((start: any) => {
waitUntilScrollingFinished('.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 :/
cy.wait(1000)
cy.get('[aria-label^="Go to PDF location in code"]').click()
cy.get('.cm-activeLine').should('have.text', '\\section{Section B}')
})
it('should sync to pdf', function () {
cy.log('zoom in')
cy.findByText('45%').click()
cy.findByText('400%').click()
cy.log('scroll to top')
cy.get('.pdfjs-viewer-inner').scrollTo('top')
waitUntilScrollingFinished('.pdfjs-viewer-inner', -1).as('start')
cy.log('navigate to title')
cy.findByText('\\maketitle').parent().click()
cy.get('[aria-label="Go to code location in PDF"]').click()
cy.get('@start').then((start: any) => {
waitUntilScrollingFinished('.pdfjs-viewer-inner', start)
.as('title')
.should('be.greaterThan', start)
})
cy.log('navigate to Section A')
cy.get('.cm-content').within(() => cy.findByText('Section A').click())
cy.get('[aria-label="Go to code location in PDF"]').click()
cy.get('@title').then((title: any) => {
waitUntilScrollingFinished('.pdfjs-viewer-inner', title)
.as('sectionA')
.should('be.greaterThan', title)
})
cy.log('navigate to Section B')
cy.get('.cm-content').within(() => cy.findByText('Section B').click())
cy.get('[aria-label="Go to code location in PDF"]').click()
cy.get('@sectionA').then((title: any) => {
waitUntilScrollingFinished('.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')
createProject('test-project')
const recompile = throttledRecompile()
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle')
.parent()
.type('\n\\fakeCommand{} \n\\section{{}Test Section}')
recompile()
recompile()
cy.get('.pdf-viewer').should('contain.text', 'Test Section')
cy.get('.logs-pane').should('not.contain.text', 'No PDF')
})
}
function checkXeTeX() {
it('should be able to use XeLaTeX', function () {
createProject('XeLaTeX')
const recompile = throttledRecompile()
cy.log('wait for compile')
cy.get('.pdf-viewer').should('contain.text', 'XeLaTeX')
cy.log('Check which compiler was used, expect pdfLaTeX')
cy.get('[aria-label="View logs"]').click()
cy.findByText(/This is pdfTeX/)
cy.log('Switch compiler to from pdfLaTeX to XeLaTeX')
cy.get('header').findByText('Menu').click()
cy.findByText('Compiler')
.parent()
.findByText('pdfLaTeX')
.parent()
.select('XeLaTeX')
cy.get('.left-menu-modal-backdrop').click()
cy.log('Trigger compile with other compiler')
recompile()
cy.log('Check which compiler was used, expect XeLaTeX')
cy.get('[aria-label="View logs"]').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 () {
createProject('sandboxed')
cy.log('wait for compile')
cy.get('.pdf-viewer').should('contain.text', 'sandboxed')
cy.log('Check which compiler version was used, expect 2025')
cy.get('[aria-label="View logs"]').click()
cy.findByText(/This is pdfTeX, Version .+ \(TeX Live 2025\) /)
cy.log('Check that there is no TeX Live version toggle')
cy.get('header').findByText('Menu').click()
cy.findByText('Word Count') // wait for lazy loading
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()
})
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()
})
})

View File

@@ -0,0 +1,257 @@
import { isExcludedBySharding, startWith } from './helpers/config'
import { ensureUserExists, login } from './helpers/login'
import { createProject } 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', () => {
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', () => {
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', () => {
login(WITHOUT_PROJECTS_USER)
cy.visit('/')
cy.findByText(LABEL_BROWSE_TEMPLATES).click()
cy.url().should('match', /\/templates$/)
})
it('should have templates feature', () => {
login(TEMPLATES_USER)
const name = `Template ${Date.now()}`
const description = `Template Description ${Date.now()}`
cy.visit('/')
createProject(name).as('templateProjectId')
cy.get('header').findByText('Menu').click()
cy.findByText('Manage Template').click()
cy.findByText('Template Description')
.click()
.parent()
.get('textarea')
.type(description)
cy.findByText('Publish').click()
cy.findByText('Publishing…').parent().should('be.disabled')
cy.findByText('Publish').should('not.exist')
cy.findByText('Unpublish', { timeout: 10_000 })
cy.findByText('Republish')
cy.findByText('View it in the template gallery').click()
cy.url()
.should('match', /\/templates\/[a-f0-9]{24}$/)
.as('templateURL')
cy.findAllByText(name).first().should('exist')
cy.findByText(description)
cy.findByText('Open as Template')
cy.findByText('Unpublish')
cy.findByText('Republish')
cy.get('img')
.should('have.attr', 'src')
.and('match', /\/v\/0\//)
cy.findByText('Republish').click()
cy.findByText('Publishing…').parent().should('be.disabled')
cy.findByText('Republish', { timeout: 10_000 })
cy.get('img', { timeout: 10_000 })
.should('have.attr', 'src')
.and('match', /\/v\/1\//)
// custom tag
const tagName = `${Date.now()}`
cy.visit('/')
cy.findByText(name)
.parent()
.parent()
.within(() => cy.get('input[type="checkbox"]').first().check())
cy.get('.project-list-sidebar-react').within(() => {
cy.findAllByText('New Tag').first().click()
})
cy.focused().type(tagName)
cy.findByText('Create').click()
cy.get('.project-list-sidebar-react').within(() => {
cy.findByText(tagName)
.parent()
.within(() => cy.get('.name').should('have.text', `${tagName} (1)`))
})
// Check listing
cy.visit('/templates')
cy.findByText(tagName)
cy.visit('/templates/all')
cy.findByText(name)
cy.visit(`/templates/${tagName}`)
cy.findByText(name)
// Unpublish via template page
cy.get('@templateURL').then(url => cy.visit(`${url}`))
cy.findByText('Unpublish').click()
cy.url().should('match', /\/templates$/)
cy.get('@templateURL').then(url =>
cy.visit(`${url}`, {
failOnStatusCode: false,
})
)
cy.findByText('Not found')
cy.visit('/templates/all')
cy.findByText(name).should('not.exist')
cy.visit(`/templates/${tagName}`)
cy.findByText(name).should('not.exist')
// Publish again
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
cy.get('header').findByText('Menu').click()
cy.findByText('Manage Template').click()
cy.findByText('Publish').click()
cy.findByText('Unpublish', { timeout: 10_000 })
// Should assign a new template id
cy.findByText('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.findByText(tagName).click()
cy.findByText(name).click()
cy.findByText('Open as Template').click()
cy.url().should('match', /\/project\/[a-f0-9]{24}$/)
cy.get('.project-name').findByText(name)
cy.get('header').findByText('Menu').click()
cy.findByText('Word Count') // wait for lazy loading
cy.findByText('Manage Template').should('not.exist')
// Check management as regular user
cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
cy.findByText('Open as Template')
cy.findByText('Unpublish').should('not.exist')
cy.findByText('Republish').should('not.exist')
// Check management as admin user
login(ADMIN_USER)
cy.get('@newTemplateURL').then(url => cy.visit(`${url}`))
cy.findByText('Open as Template')
cy.findByText('Unpublish')
cy.findByText('Republish')
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
cy.get('header').findByText('Menu').click()
cy.findByText('Manage Template').click()
cy.findByText('Unpublish')
// Back to templates user
login(TEMPLATES_USER)
// Unpublish via editor
cy.get('@templateProjectId').then(projectId =>
cy.visit(`/project/${projectId}`)
)
cy.get('header').findByText('Menu').click()
cy.findByText('Manage Template').click()
cy.findByText('Unpublish').click()
cy.findByText('Publish')
cy.visit('/templates/all')
cy.findByText(name).should('not.exist')
// check for template links, after creating the first project
cy.visit('/')
cy.findAllByRole('button')
.contains(/new project/i)
.click()
cy.findAllByText('All Templates')
.first()
.parent()
.should('have.attr', 'href', '/templates/all')
})
})
function checkDisabled() {
it('should not have templates feature', () => {
login(TEMPLATES_USER)
cy.visit('/')
createProject('maybe templates')
cy.get('header').findByText('Menu').click()
cy.findByText('Word Count') // wait for lazy loading
cy.findByText('Manage Template').should('not.exist')
cy.visit('/templates', { failOnStatusCode: false })
cy.findByText('Not found')
cy.visit('/templates/all', { failOnStatusCode: false })
cy.findByText('Not found')
// check for template links, after creating the first project
cy.visit('/')
cy.findAllByRole('button')
.contains(/new project/i)
.click()
cy.findAllByText('All Templates').should('not.exist')
})
it('should not show templates link on welcome page', () => {
login(WITHOUT_PROJECTS_USER)
cy.visit('/')
cy.findByText(/new project/i) // wait for lazy loading
cy.findByText(LABEL_BROWSE_TEMPLATES).should('not.exist')
})
}
describe('disabled Server Pro', () => {
if (isExcludedBySharding('PRO_DEFAULT_2')) return
startWith({ pro: true })
checkDisabled()
})
describe('unavailable in CE', () => {
if (isExcludedBySharding('CE_CUSTOM_1')) return
startWith({
pro: false,
varsFn,
})
checkDisabled()
})
})

View File

@@ -0,0 +1,17 @@
{
"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"]
},
"include": ["**/*.ts", "**/*.tsx"]
}

View File

@@ -0,0 +1,226 @@
import { ensureUserExists, login } from './helpers/login'
import { isExcludedBySharding, startWith } from './helpers/config'
import { dockerCompose, runScript } from './helpers/hostAdminClient'
import { createProject, openProjectByName } from './helpers/project'
import { throttledRecompile } 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
function testUpgrade(
steps: {
version: string
vars?: Object
newProjectButtonMatcher?: RegExp
hook?: () => void
}[]
) {
const startOptions = steps.shift()!
before(async () => {
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(() => {
cy.log('Populate old instance')
login(USER)
createProject(PROJECT_NAME, {
newProjectButtonMatcher: startOptions.newProjectButtonMatcher,
})
const recompile = throttledRecompile()
cy.log('Wait for successful compile')
cy.get('.pdf-viewer').should('contain.text', 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.get('header').findByText('Menu').click()
cy.findByText('Source').click()
cy.get('.left-menu-modal-backdrop').click({ force: true })
}
cy.log('Check compile and history')
for (let i = 0; i < 3; i++) {
cy.get('.pdf-viewer').should('contain.text', `Old Section ${i}`)
}
cy.findByText('History').click()
for (let i = 0; i < 3; i++) {
cy.findByText(new RegExp(`\\\\section\{Old Section ${i}}`))
}
})
for (const step of steps) {
before(() => {
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(() => {
login(USER)
})
it('should list the old project', () => {
cy.visit('/project')
cy.findByText(PROJECT_NAME)
})
it('should open the old project', () => {
openProjectByName(PROJECT_NAME)
cy.url().should('match', /\/project\/[a-fA-F0-9]{24}/)
cy.findByRole('navigation').within(() => {
cy.findByText(PROJECT_NAME)
})
const recompile = throttledRecompile()
cy.log('wait for successful compile')
cy.get('.pdf-viewer').should('contain.text', PROJECT_NAME)
cy.get('.pdf-viewer').should('contain.text', '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.get('.pdf-viewer').should('contain.text', newSection)
cy.findByText('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,
}
describe('from 4.2 to latest', () => {
testUpgrade([optionsFourDotTwo, { version: 'latest' }])
})
describe('from 5.0 to latest', () => {
testUpgrade([{ version: '5.0' }, { version: 'latest' }])
})
describe('doc version recovery', () => {
testUpgrade([
optionsFourDotTwo,
{
version: '5.0.1-RC1',
hook() {
before(function () {
login(USER)
cy.visit('/')
cy.findByText(PROJECT_NAME).click()
const recompile = throttledRecompile()
cy.log('Make a change')
cy.findByText('\\maketitle').parent().click()
cy.findByText('\\maketitle')
.parent()
.type('\n\\section{{}FiveOOne Section}')
cy.log('Trigger flush')
recompile()
cy.get('.pdf-viewer').should('contain.text', 'FiveOOne Section')
cy.log('Check for broken history, i.e. not synced with latest edit')
cy.findByText('History').click()
cy.findByText(/\\section\{Old Section 2}/) // wait for lazy loading
cy.findByText(/\\section\{FiveOOne Section}/).should('not.exist')
})
},
},
{
version: 'latest',
hook() {
before(async function () {
this.timeout(20_000)
const needle = 'Finished resyncing history for all projects.'
for (let i = 0; i < 30; i++) {
const { stdout } = await dockerCompose('logs', 'sharelatex')
if (stdout.includes(needle)) {
return
}
await new Promise(resolve => setTimeout(resolve, 500))
}
const { stdout } = await dockerCompose('logs', 'sharelatex')
expect(stdout).to.contain(
needle,
'Doc version recovery did not finish yet.'
)
})
before(function () {
login(USER)
cy.visit('/')
cy.findByText(PROJECT_NAME).click()
cy.log(
'The edit that was made while the history was broken should be there now.'
)
cy.findByText('History').click()
cy.findByText(/\\section\{FiveOOne Section}/)
// TODO(das7pad): restore after https://github.com/overleaf/internal/issues/19588 is fixed.
// cy.log('Check indicator of force resync')
// cy.findByText('Overleaf History System')
})
},
},
])
})
})