first commit

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

View File

@@ -0,0 +1,84 @@
import { EditorProviders } from '../../helpers/editor-providers'
import DetachCompileButtonWrapper from '../../../../frontend/js/features/pdf-preview/components/detach-compile-button-wrapper'
import { mockScope } from './scope'
import { testDetachChannel } from '../../helpers/detach-channel'
describe('<DetachCompileButtonWrapper />', function () {
beforeEach(function () {
cy.interceptEvents()
})
it('detacher mode and not linked: does not show button ', function () {
cy.interceptCompile()
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detacher')
})
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<DetachCompileButtonWrapper />
</EditorProviders>
)
cy.waitForCompile()
cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
})
it('detacher mode and linked: show button', function () {
cy.interceptCompile()
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detacher')
})
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<DetachCompileButtonWrapper />
</EditorProviders>
)
cy.waitForCompile()
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detached',
event: 'connected',
})
})
cy.findByRole('button', { name: 'Recompile' })
})
it('not detacher mode and linked: does not show button ', function () {
cy.interceptCompile()
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<DetachCompileButtonWrapper />
</EditorProviders>
)
cy.waitForCompile()
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'connected',
})
})
cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
})
})

View File

@@ -0,0 +1,103 @@
import { EditorProviders } from '../../helpers/editor-providers'
import PdfJsViewer from '../../../../frontend/js/features/pdf-preview/components/pdf-js-viewer'
import { mockScope } from './scope'
import { getContainerEl } from 'cypress/react'
import { unmountComponentAtNode } from 'react-dom'
import { PdfPreviewProvider } from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-provider'
// Unicode directional isolates, added around placeables by @fluent/bundle/esm/resolver
const FSI = '\u2068'
const PDI = '\u2069'
describe('<PdfJSViewer/>', function () {
beforeEach(function () {
cy.interceptEvents()
})
it('loads all PDF pages', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<PdfPreviewProvider>
<div className="pdf-viewer">
<PdfJsViewer url="/build/123/output.pdf?clsiserverid=foo" />
</div>
</PdfPreviewProvider>
</EditorProviders>
)
cy.waitForCompile({ pdf: true })
cy.findByRole('region', { name: `Page ${FSI}1${PDI}` })
cy.findByRole('region', { name: `Page ${FSI}2${PDI}` })
cy.findByRole('region', { name: `Page ${FSI}3${PDI}` })
cy.findByRole('region', { name: `Page ${FSI}4${PDI}` }).should('not.exist')
cy.contains('Your Paper')
})
it('renders pages in a "loading" state', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<PdfPreviewProvider>
<div className="pdf-viewer">
<PdfJsViewer url="/build/123/output.pdf" />
</div>
</PdfPreviewProvider>
</EditorProviders>
)
cy.waitForCompile()
cy.get('.page.loading')
})
it('can be unmounted while loading a document', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<PdfPreviewProvider>
<div className="pdf-viewer">
<PdfJsViewer url="/build/123/output.pdf?clsiserverid=foo" />
</div>
</PdfPreviewProvider>
</EditorProviders>
)
cy.waitForCompile()
cy.then(() => unmountComponentAtNode(getContainerEl()))
})
it('can be unmounted after loading a document', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<PdfPreviewProvider>
<div className="pdf-viewer">
<PdfJsViewer url="/build/123/output.pdf?clsiserverid=foo" />
</div>
</PdfPreviewProvider>
</EditorProviders>
)
cy.waitForCompile({ pdf: true })
cy.findByRole('region', { name: `Page ${FSI}1${PDI}` })
cy.then(() => unmountComponentAtNode(getContainerEl()))
})
})

View File

@@ -0,0 +1,186 @@
import { EditorProviders } from '../../helpers/editor-providers'
import PdfLogsEntries from '../../../../frontend/js/features/pdf-preview/components/pdf-logs-entries'
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
import { FileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { FindResult } from '@/features/file-tree/util/path'
import { FC } from 'react'
import {
EditorManager,
EditorManagerContext,
} from '@/features/ide-react/context/editor-manager-context'
import { EditorView } from '@codemirror/view'
import { OpenDocuments } from '@/features/ide-react/editor/open-documents'
import { LogEntry } from '@/features/pdf-preview/util/types'
describe('<PdfLogsEntries/>', function () {
const fakeFindEntityResult: FindResult = {
type: 'doc',
entity: { _id: '123', name: '123 Doc' },
}
const FileTreePathProvider: FC = ({ children }) => (
<FileTreePathContext.Provider
value={{
dirname: cy.stub(),
findEntityByPath: cy
.stub()
.as('findEntityByPath')
.returns(fakeFindEntityResult),
pathInFolder: cy.stub(),
previewByPath: cy.stub(),
}}
>
{children}
</FileTreePathContext.Provider>
)
const EditorManagerProvider: FC = ({ children }) => {
const value = {
openDocWithId: cy.spy().as('openDocWithId'),
// @ts-ignore
openDocs: new OpenDocuments(),
} as unknown as EditorManager
return (
<EditorManagerContext.Provider value={value}>
{children}
</EditorManagerContext.Provider>
)
}
const logEntries: LogEntry[] = [
{
file: 'main.tex',
line: 9,
column: 8,
level: 'error',
message: 'LaTeX Error',
content: 'See the LaTeX manual',
raw: '',
ruleId: 'hint_misplaced_alignment_tab_character',
key: '',
},
]
const scope = {
'editor.view': new EditorView({ doc: '\\documentclass{article}' }),
}
beforeEach(function () {
cy.interceptCompile()
cy.interceptEvents()
})
it('displays human readable hint', function () {
cy.mount(
<EditorProviders scope={scope}>
<PdfLogsEntries entries={logEntries} />
</EditorProviders>
)
cy.contains('You have placed an alignment tab character')
})
it('opens doc on click', function () {
cy.mount(
<EditorProviders
scope={scope}
providers={{ EditorManagerProvider, FileTreePathProvider }}
>
<PdfLogsEntries entries={logEntries} />
</EditorProviders>
)
cy.findByRole('button', {
name: 'Navigate to log position in source code: main.tex, 9',
}).click()
cy.get('@findEntityByPath').should('have.been.calledOnceWith', 'main.tex')
cy.get('@openDocWithId').should(
'have.been.calledOnceWith',
fakeFindEntityResult.entity._id,
{
gotoLine: 9,
gotoColumn: 8,
keepCurrentView: false,
}
)
})
it('opens doc via detached action', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detacher')
})
cy.mount(
<EditorProviders
scope={scope}
providers={{ EditorManagerProvider, FileTreePathProvider }}
>
<PdfLogsEntries entries={logEntries} />
</EditorProviders>
).then(() => {
testDetachChannel.postMessage({
role: 'detached',
event: 'action-sync-to-entry',
data: {
args: [
{
file: 'main.tex',
line: 7,
column: 6,
},
],
},
})
})
cy.get('@findEntityByPath').should('have.been.calledOnce')
cy.get('@openDocWithId').should(
'have.been.calledOnceWith',
fakeFindEntityResult.entity._id,
{
gotoLine: 7,
gotoColumn: 6,
keepCurrentView: false,
}
)
})
it('sends open doc clicks via detached action', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
cy.mount(
<EditorProviders
scope={scope}
providers={{ EditorManagerProvider, FileTreePathProvider }}
>
<PdfLogsEntries entries={logEntries} />
</EditorProviders>
)
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.findByRole('button', {
name: 'Navigate to log position in source code: main.tex, 9',
}).click()
cy.get('@findEntityByPath').should('not.have.been.called')
cy.get('@openDocWithId').should('not.have.been.called')
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detached',
event: 'action-sync-to-entry',
data: {
args: [
{
file: 'main.tex',
line: 9,
column: 8,
},
],
},
})
})
})

View File

@@ -0,0 +1,73 @@
import PdfPreviewDetachedRoot from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-detached-root'
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
describe('<PdfPreviewDetachedRoot/>', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-user', { id: 'user1' })
window.metaAttributesCache.set('ol-project_id', 'project1')
window.metaAttributesCache.set('ol-detachRole', 'detached')
window.metaAttributesCache.set('ol-projectName', 'Project Name')
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
it('syncs compiling state', function () {
cy.interceptCompile()
cy.mount(<PdfPreviewDetachedRoot />)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'connected',
})
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-compiling',
data: { value: true },
})
})
cy.findByRole('button', { name: 'Compiling…' })
cy.findByRole('button', { name: 'Recompile' }).should('not.exist')
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-compiling',
data: { value: false },
})
})
cy.findByRole('button', { name: 'Recompile' })
cy.findByRole('button', { name: 'Compiling…' }).should('not.exist')
})
it('sends a clear cache request when the button is pressed', function () {
cy.interceptCompile()
cy.mount(<PdfPreviewDetachedRoot />)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-showLogs',
data: { value: true },
})
})
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.findByRole('button', { name: 'Clear cached files' })
.should('not.be.disabled')
.click()
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detached',
event: 'action-clearCache',
data: {
args: [],
},
})
})
})

View File

@@ -0,0 +1,100 @@
import { EditorProviders } from '../../helpers/editor-providers'
import PdfPreviewHybridToolbar from '../../../../frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
import { testDetachChannel } from '../../helpers/detach-channel'
describe('<PdfPreviewHybridToolbar/>', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
cy.interceptEvents()
})
it('shows normal mode', function () {
cy.mount(
<EditorProviders>
<PdfPreviewHybridToolbar />
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' })
})
describe('orphan mode', function () {
it('shows connecting message on load', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
cy.mount(
<EditorProviders>
<PdfPreviewHybridToolbar />
</EditorProviders>
)
cy.contains('Connecting with the editor')
})
it('shows compile UI when connected', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
cy.mount(
<EditorProviders>
<PdfPreviewHybridToolbar />
</EditorProviders>
)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'connected',
})
})
cy.findByRole('button', { name: 'Recompile' })
})
it('shows connecting message when disconnected', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
cy.mount(
<EditorProviders>
<PdfPreviewHybridToolbar />
</EditorProviders>
)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'connected',
})
testDetachChannel.postMessage({
role: 'detacher',
event: 'closed',
})
})
cy.contains('Connecting with the editor')
})
it('shows redirect button after timeout', function () {
cy.window().then(win => {
win.metaAttributesCache.set('ol-detachRole', 'detached')
})
cy.clock()
cy.mount(
<EditorProviders>
<PdfPreviewHybridToolbar />
</EditorProviders>
)
cy.tick(6000)
cy.findByRole('button', { name: 'Redirect to editor' })
})
})
})

View File

@@ -0,0 +1,872 @@
import localStorage from '@/infrastructure/local-storage'
import PdfPreview from '../../../../frontend/js/features/pdf-preview/components/pdf-preview'
import { EditorProviders } from '../../helpers/editor-providers'
import { mockScope } from './scope'
import {
IdeLayout,
IdeView,
useLayoutContext,
} from '../../../../frontend/js/shared/context/layout-context'
import { FC, useEffect } from 'react'
const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => {
localStorage.setItem(key, value)
win.dispatchEvent(new StorageEvent('storage', { key }))
}
const Layout: FC<{ layout: IdeLayout; view?: IdeView }> = ({
layout,
view,
}) => {
const { changeLayout } = useLayoutContext()
useEffect(() => {
changeLayout(layout, view)
}, [changeLayout, layout, view])
return null
}
describe('<PdfPreview/>', function () {
let projectId: string
beforeEach(function () {
/**
* There are time sensitive tests in this test suite. They need to wait for a Promise before resolving a request.
*
* Using a promise across the test-env (browser) vs stub-env (server) causes additional latency.
*
* This latency seems to stack up when adding more intercepts for the same path. Using static responses for some of these intercepts does not help.
*
* All of that seems like a bug in Cypress. For now just work around it by using a unique projectId for each intercept.
*/
projectId = Math.random().toString().slice(2)
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
window.metaAttributesCache.set(
'ol-compilesUserContentDomain',
'https://compiles-user.dev-overleaf.com'
)
window.metaAttributesCache.set('ol-splitTestVariants', {
'initial-compile-from-clsi-cache': 'enabled',
})
cy.interceptEvents()
})
it('renders the PDF preview', function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// wait for "compile on load" to finish
cy.waitForCompile({ pdf: true })
cy.findByRole('button', { name: 'Recompile' })
})
it('uses the cache when available', function () {
cy.interceptCompile({
prefix: 'compile',
times: 1,
cached: true,
regular: false,
})
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// wait for "compile from cache on load" to finish
cy.waitForCompile({ pdf: true, cached: true, regular: false })
cy.contains('Your Paper')
})
it('uses the cache when available then compiles', function () {
cy.interceptCompile({
prefix: 'compile',
times: 1,
cached: true,
regular: false,
})
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// wait for "compile from cache on load" to finish
cy.waitForCompile({ pdf: true, cached: true, regular: false })
cy.contains('Your Paper')
// Then trigger a new compile
cy.interceptCompile({
prefix: 'recompile',
times: 1,
cached: false,
outputPDFFixture: 'output-2.pdf',
})
// press the Recompile button => compile
cy.findByRole('button', { name: 'Recompile' }).click()
// wait for compile to finish
cy.waitForCompile({ prefix: 'recompile', pdf: true })
cy.contains('Modern Authoring Tools for Science')
})
describe('racing compile from cache and regular compile trigger', function () {
for (const [timing] of ['before rendering', 'after rendering']) {
it(`replaces the compile from cache with a regular compile - ${timing}`, function () {
const requestedOnce = new Set()
;['log', 'pdf', 'blg'].forEach(ext => {
cy.intercept({ pathname: `/build/*/output.${ext}` }, req => {
if (requestedOnce.has(ext)) {
throw new Error(
`compile from cache triggered extra ${ext} request: ${req.url}`
)
}
requestedOnce.add(ext)
req.reply({ fixture: `build/output.${ext},null` })
}).as(`compile-${ext}`)
})
const { promise, resolve } = Promise.withResolvers<void>()
cy.interceptCompileFromCacheRequest({
promise,
times: 1,
}).as('cached-compile')
cy.interceptCompileRequest().as('compile')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope} projectId={projectId}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// press the Recompile button => compile
cy.findByRole('button', { name: 'Recompile' }).click()
if (timing === 'before rendering') {
cy.then(() => resolve())
cy.wait('@cached-compile')
}
// wait for rendering to finish
cy.waitForCompile({ pdf: true, cached: false })
if (timing === 'after rendering') {
cy.then(() => resolve())
cy.wait('@cached-compile')
}
cy.contains('Your Paper')
cy.then(() => Array.from(requestedOnce).sort().join(',')).should(
'equal',
'blg,log,pdf'
)
})
}
})
describe('clsi-cache project settings validation', function () {
const cases = {
// Flaky, skip for now
'uses compile from cache when nothing changed': {
cached: true,
setup: () => {},
props: {},
},
'ignores the compile from cache when imageName changed': {
cached: false,
setup: () => {},
props: {
imageName: 'texlive-full:2025.1',
},
},
'ignores the compile from cache when compiler changed': {
cached: false,
setup: () => {},
props: {
compiler: 'lualatex',
},
},
'ignores the compile from cache when draft mode changed': {
cached: false,
setup: () => {
cy.window().then(w =>
w.localStorage.setItem(`draft:${projectId}`, 'true')
)
},
props: {},
},
'ignores the compile from cache when stopOnFirstError mode changed': {
cached: false,
setup: () => {
cy.window().then(w =>
w.localStorage.setItem(`stop_on_first_error:${projectId}`, 'true')
)
},
props: {},
},
'ignores the compile from cache when rootDoc changed': {
cached: false,
setup: () => {},
props: {
rootDocId: 'new-root-doc-id',
rootFolder: [
{
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: '_root_doc_id',
name: 'main.tex',
},
{
_id: 'new-root-doc-id',
name: 'new-main.tex',
},
],
folders: [],
fileRefs: [],
},
],
},
},
}
Object.entries(cases).forEach(([name, { cached, setup, props }]) => {
it(name, function () {
cy.interceptCompile({
cached: true,
regular: !cached,
})
const scope = mockScope()
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
setup()
cy.mount(
<EditorProviders scope={scope} projectId={projectId} {...props}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// wait for compile to finish
cy.waitForCompile({ pdf: true, cached, regular: !cached })
cy.contains('Your Paper')
})
})
})
it('runs a compile when the Recompile button is pressed', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// press the Recompile button => compile
cy.findByRole('button', { name: 'Recompile' }).click()
// wait for compile to finish
cy.waitForCompile({ pdf: true })
cy.contains('Your Paper')
})
it('runs a compile on `pdf:recompile` event', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.window().then(win => {
win.dispatchEvent(new CustomEvent('pdf:recompile'))
})
// wait for compile to finish
cy.waitForCompile({ pdf: true })
cy.contains('Your Paper')
})
it('does not compile while compiling', function () {
let counter = 0
cy.interceptDeferredCompile(() => counter++).then(
resolveDeferredCompile => {
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
// start compiling
cy.findByRole('button', { name: 'Recompile' }).click()
cy.findByRole('button', { name: 'Compiling…' }).then(() => {
// trigger a recompile
cy.window().then(win => {
win.dispatchEvent(new CustomEvent('pdf:recompile'))
})
// finish the original compile
resolveDeferredCompile()
// wait for the original compile to finish
cy.waitForCompile().then(() => {
// NOTE: difficult to assert that a second request won't be sent, at some point
expect(counter).to.equal(1)
})
})
}
)
})
it('disables compile button while compile is running', function () {
cy.interceptDeferredCompile().then(resolveDeferredCompile => {
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.findByRole('button', { name: 'Compiling…' })
.should('be.disabled')
.then(() => resolveDeferredCompile())
cy.waitForCompile()
cy.findByRole('button', { name: 'Recompile' }).should('not.be.disabled')
})
})
it('runs a compile on doc change if autocompile is enabled', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.window().then(win => {
cy.clock()
// switch on auto compile
storeAndFireEvent(win, 'autocompile_enabled:project123', true)
// fire a doc:changed event => compile
win.dispatchEvent(new CustomEvent('doc:changed'))
// wait enough time for the compile to start
cy.tick(6000) // > AUTO_COMPILE_DEBOUNCE
cy.clock().invoke('restore')
})
// wait for compile to finish
cy.waitForCompile({ pdf: true })
cy.findByRole('button', { name: 'Recompile' })
})
it('does not run a compile on doc change if autocompile is disabled', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.window().then(win => {
cy.clock()
// make sure auto compile is switched off
storeAndFireEvent(win, 'autocompile_enabled:project123', false)
// fire a doc:changed event => no compile
win.dispatchEvent(new CustomEvent('doc:changed'))
// wait enough time for the compile to start
cy.tick(6000) // AUTO_COMPILE_DEBOUNCE
cy.clock().invoke('restore')
})
// NOTE: difficult to assert that a request hasn't been sent
cy.findByRole('button', { name: 'Recompile' })
})
it('does not run a compile on doc change if autocompile is blocked by syntax check', function () {
cy.interceptCompile()
const scope = mockScope()
// enable linting in the editor
const userSettings = { syntaxValidation: true }
// mock a linting error
scope.hasLintingError = true
cy.mount(
<EditorProviders scope={scope} userSettings={userSettings}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.window().then(win => {
cy.clock()
// switch on auto compile
storeAndFireEvent(win, 'autocompile_enabled:project123', true)
// switch on syntax checking
storeAndFireEvent(win, 'stop_on_validation_error', true)
// fire a doc:changed event => no compile
win.dispatchEvent(new CustomEvent('doc:changed'))
// wait enough time for the compile to start
cy.tick(6000) // AUTO_COMPILE_DEBOUNCE
cy.clock().invoke('restore')
})
// NOTE: difficult to assert that a request hasn't been sent
cy.findByRole('button', { name: 'Recompile' })
cy.findByText('Code check failed')
})
it('does not run a compile on doc change if the PDF preview is not open', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<Layout layout="flat" view="editor" />
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.window().then(win => {
cy.clock()
// switch on auto compile
storeAndFireEvent(win, 'autocompile_enabled:project123', true)
// fire a doc:changed event => compile
win.dispatchEvent(new CustomEvent('doc:changed'))
// wait enough time for the compile to start
cy.tick(6000) // > AUTO_COMPILE_DEBOUNCE
cy.clock().invoke('restore')
})
// NOTE: difficult to assert that a request hasn't been sent
cy.findByRole('button', { name: 'Recompile' })
})
describe('error messages', function () {
const compileErrorStatuses = {
'clear-cache':
'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
'clsi-maintenance':
'The compile servers are down for maintenance, and will be back shortly.',
'compile-in-progress':
'A previous compile is still running. Please wait a minute and try compiling again.',
exited: 'Server Error',
failure: 'No PDF',
generic: 'Server Error',
'project-too-large': 'Project too large',
'rate-limited': 'Compile rate limit hit',
terminated: 'Compilation cancelled',
timedout: 'Timed out',
'too-recently-compiled':
'This project was compiled very recently, so this compile has been skipped.',
unavailable:
'Sorry, the compile server for your project was temporarily unavailable. Please try again in a few moments.',
foo: 'Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.',
}
for (const [status, message] of Object.entries(compileErrorStatuses)) {
it(`displays error message for '${status}' status`, function () {
cy.intercept('POST', '/project/*/compile*', {
body: {
status,
clsiServerId: 'foo',
compileGroup: 'priority',
},
}).as('compile')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.wait('@compile')
cy.findByText(message)
})
}
})
it('displays expandable raw logs', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile({ pdf: true })
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByRole('button', { name: 'View PDF' })
cy.findByRole('button', { name: 'Expand' }).click()
cy.findByRole('button', { name: 'Collapse' }).click()
})
it('displays error messages if there were validation problems', function () {
const validationProblems = {
sizeCheck: {
resources: [
{ path: 'foo/bar', kbSize: 76221 },
{ path: 'bar/baz', kbSize: 2342 },
],
},
mainFile: true,
conflictedPaths: [
{
path: 'foo/bar',
},
{
path: 'foo/baz',
},
],
}
cy.intercept('POST', '/project/*/compile*', {
body: {
status: 'validation-problems',
validationProblems,
clsiServerId: 'foo',
compileGroup: 'priority',
},
}).as('compile')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.wait('@compile')
cy.findByText('Project too large')
cy.findByText('Unknown main document')
cy.findByText('Conflicting Paths Found')
})
describe('clear cache', function () {
it('sends a clear cache request when the button is pressed', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile({ pdf: true })
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByRole('button', { name: 'Clear cached files' }).should(
'not.be.disabled'
)
const { promise, resolve } = Promise.withResolvers<void>()
cy.intercept('DELETE', '/project/*/output*', req => {
return promise
.then(() => Cypress.Promise.delay(100))
.then(() => {
req.reply({ statusCode: 204 })
})
}).as('clear-cache')
// click the button
cy.findByRole('button', { name: 'Clear cached files' }).click()
cy.findByRole('button', { name: 'Clear cached files' }).should(
'be.disabled'
)
cy.then(() => {
resolve()
})
cy.wait('@clear-cache')
cy.findByRole('button', { name: 'Clear cached files' }).should(
'not.be.disabled'
)
})
it('handle "recompile from scratch"', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile({ pdf: true })
cy.interceptCompile({ prefix: 'recompile' })
cy.intercept('DELETE', '/project/*/output*', {
statusCode: 204,
delay: 100,
}).as('clear-cache')
// show the logs UI
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByRole('button', { name: 'Clear cached files' }).should(
'not.be.disabled'
)
cy.interceptDeferredCompile().then(resolveDeferredCompile => {
cy.findByRole('button', { name: 'Toggle compile options menu' }).click()
cy.findByRole('menuitem', {
name: 'Recompile from scratch',
}).trigger('click')
cy.findByRole('button', { name: 'Clear cached files' }).should(
'be.disabled'
)
cy.wait('@clear-cache')
cy.findByRole('button', { name: 'Compiling…' }).then(() =>
resolveDeferredCompile()
)
// wait for recompile from scratch to finish
cy.waitForCompile({ pdf: true })
cy.findByRole('button', { name: 'Recompile' })
})
})
})
describe('invalid URLs and broken PDFs', function () {
it('shows an error for an invalid URL', function () {
cy.interceptCompile()
cy.intercept('/build/*/output.pdf*', {
statusCode: 500,
body: {
message: 'something awful happened',
code: 'AWFUL_ERROR',
},
}).as('compile-pdf-error')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile()
cy.wait('@compile-pdf-error')
cy.contains('Something went wrong while rendering this PDF.')
cy.contains(
'Please try recompiling the project from scratch, and if that doesnt help, follow our troubleshooting guide.'
)
cy.findByLabelText('Page 1').should('not.exist')
})
it('shows an error for a corrupt PDF', function () {
cy.interceptCompile()
cy.intercept('/build/*/output.pdf*', {
fixture: 'build/output-corrupt.pdf,null',
}).as('compile-pdf-corrupt')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile()
cy.wait('@compile-pdf-corrupt')
cy.contains('Something went wrong while rendering this PDF.')
cy.contains(
'Please try recompiling the project from scratch, and if that doesnt help, follow our troubleshooting guide.'
)
cy.findByLabelText('Page 1').should('not.exist')
})
})
describe('human readable logs', function () {
it('shows human readable hint for undefined reference errors', function () {
cy.interceptCompile()
cy.intercept('/build/*/output.log*', {
fixture: 'build/output-human-readable.log',
}).as('compile-log')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile()
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByText(
"Reference `intorduction' on page 1 undefined on input line 11."
)
cy.findByText(
"Reference `section1' on page 1 undefined on input line 13."
)
cy.findByText('There were undefined references.')
cy.findAllByText(
/You have referenced something which has not yet been labelled/
).should('have.length', 3)
})
it('does not show human readable hint when no undefined reference errors', function () {
cy.interceptCompile()
cy.intercept('/build/*/output.log?*', {
fixture: 'build/output-undefined-references.log',
}).as('compile-log')
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<div className="pdf-viewer">
<PdfPreview />
</div>
</EditorProviders>
)
cy.findByRole('button', { name: 'Recompile' }).click()
cy.waitForCompile()
cy.findByRole('button', { name: 'View logs' }).click()
cy.findByText(
"Package rerunfilecheck Warning: File `output.brf' has changed. Rerun to get bibliographical references right."
)
cy.findByText(
/You have referenced something which has not yet been labelled/
).should('not.exist')
})
})
})

View File

@@ -0,0 +1,439 @@
import PdfSynctexControls from '../../../../frontend/js/features/pdf-preview/components/pdf-synctex-controls'
import { cloneDeep } from 'lodash'
import { useDetachCompileContext as useCompileContext } from '../../../../frontend/js/shared/context/detach-compile-context'
import { useFileTreeData } from '../../../../frontend/js/shared/context/file-tree-data-context'
import { useEffect } from 'react'
import { EditorProviders } from '../../helpers/editor-providers'
import { mockScope } from './scope'
import { detachChannel, testDetachChannel } from '../../helpers/detach-channel'
import { FindResult } from '@/features/file-tree/util/path'
const mockHighlights = [
{
page: 1,
h: 85.03936,
v: 509.999878,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 486.089539,
width: 441.921265,
height: 8.855677,
},
]
type Position = {
page: number
offset: { top: number; left: number }
pageSize: { height: number; width: number }
}
const mockPosition: Position = {
page: 1,
offset: { top: 10, left: 10 },
pageSize: { height: 500, width: 500 },
}
const mockSelectedEntities = [{ type: 'doc' }] as FindResult[]
const WithPosition = ({ mockPosition }: { mockPosition: Position }) => {
const { setPosition } = useCompileContext()
// mock PDF scroll position update
useEffect(() => {
setPosition(mockPosition)
}, [mockPosition, setPosition])
return null
}
// mock PDF scroll position update
const setDetachedPosition = (mockPosition: Position) => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-position',
data: { value: mockPosition },
})
}
const WithSelectedEntities = ({
mockSelectedEntities = [],
}: {
mockSelectedEntities: FindResult[]
}) => {
const { setSelectedEntities } = useFileTreeData()
useEffect(() => {
setSelectedEntities(mockSelectedEntities)
}, [mockSelectedEntities, setSelectedEntities])
return null
}
describe('<PdfSynctexControls/>', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-project_id', 'test-project')
window.metaAttributesCache.set('ol-preventCompileOnLoad', false)
cy.interceptEvents()
})
it('handles clicks on sync buttons', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.get('.synctex-control-icon').should('have.length', 2)
// mock editor cursor position update
cy.window().then(win => {
win.dispatchEvent(
new CustomEvent('cursor:editor:update', {
detail: { row: 100, column: 10 },
})
)
})
cy.wrap(null).then(() => {
setDetachedPosition(mockPosition)
})
cy.interceptAsync({ pathname: '/project/*/sync/code' }, 'sync-code').then(
syncCodeResponse => {
cy.findByRole('button', { name: 'Go to code location in PDF' }).click()
cy.findByRole('button', { name: 'Go to code location in PDF' })
.should('be.disabled')
.then(() => {
syncCodeResponse.resolve({
body: { pdf: cloneDeep(mockHighlights) },
})
})
cy.wait('@sync-code')
}
)
cy.interceptAsync({ pathname: '/project/*/sync/pdf' }, 'sync-pdf').then(
syncPdfResponse => {
cy.findByRole('button', { name: /^Go to PDF location in code/ }).click()
cy.findByRole('button', { name: /^Go to PDF location in code/ })
.should('be.disabled')
.then(() => {
syncPdfResponse.resolve({
body: { code: [{ file: 'main.tex', line: 100 }] },
})
})
cy.wait('@sync-pdf')
}
)
})
it('disables button when multiple entities are selected', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities
mockSelectedEntities={
[{ type: 'doc' }, { type: 'doc' }] as FindResult[]
}
/>
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.findByRole('button', { name: 'Go to code location in PDF' }).should(
'be.disabled'
)
})
it('disables button when a file is selected', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities
mockSelectedEntities={[{ type: 'fileRef' }] as FindResult[]}
/>
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.findByRole('button', { name: 'Go to code location in PDF' }).should(
'be.disabled'
)
})
describe('with detacher role', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-detachRole', 'detacher')
})
it('does not have go to PDF location button nor arrow icon', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'not.exist'
)
cy.get('.synctex-control-icon').should('not.exist')
})
it('send set highlights action', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
// mock editor cursor position update
cy.window().then(win => {
win.dispatchEvent(
new CustomEvent('cursor:editor:update', {
detail: { row: 100, column: 10 },
})
)
})
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.interceptAsync({ pathname: '/project/*/sync/code' }, 'sync-code').then(
syncCodeResponse => {
cy.findByRole('button', {
name: 'Go to code location in PDF',
})
.should('not.be.disabled')
.click()
cy.findByRole('button', {
name: 'Go to code location in PDF',
})
.should('be.disabled')
.then(() => {
syncCodeResponse.resolve({
body: { pdf: cloneDeep(mockHighlights) },
})
})
cy.wait('@sync-code')
}
)
cy.findByRole('button', {
name: 'Go to code location in PDF',
}).should('not.be.disabled')
// synctex is called locally and the result are broadcast for the detached tab
// NOTE: can't use `.to.deep.include({…})` as it doesn't match the nested array
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detacher',
event: 'action-setHighlights',
data: { args: [mockHighlights] },
})
})
it('reacts to sync to code action', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.interceptAsync({ pathname: '/project/*/sync/pdf' }, 'sync-pdf')
.then(syncPdfResponse => {
syncPdfResponse.resolve({
body: { code: [{ file: 'main.tex', line: 100 }] },
})
testDetachChannel.postMessage({
role: 'detached',
event: 'action-sync-to-code',
data: {
args: [mockPosition],
},
})
})
.wait('@sync-pdf')
})
})
describe('with detached role', function () {
beforeEach(function () {
window.metaAttributesCache.set('ol-detachRole', 'detached')
})
it('does not have go to code location button nor arrow icon', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile()
cy.findByRole('button', {
name: 'Go to code location in PDF',
}).should('not.exist')
cy.get('.synctex-control-icon').should('not.exist')
})
it('send go to code line action', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile().then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: `state-position`,
data: { value: mockPosition },
})
})
cy.findByRole('button', {
name: /^Go to PDF location in code/,
})
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'not.be.disabled'
)
cy.spy(detachChannel, 'postMessage').as('postDetachMessage')
cy.findByRole('button', { name: /^Go to PDF location in code/ }).click()
// the button is only disabled when the state is updated
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'not.be.disabled'
)
cy.get('.synctex-spin-icon').should('not.exist')
cy.get('@postDetachMessage').should('have.been.calledWith', {
role: 'detached',
event: 'action-sync-to-code',
data: {
args: [{ visualOffset: 72 }],
},
})
})
it('update inflight state', function () {
cy.interceptCompile()
const scope = mockScope()
cy.mount(
<EditorProviders scope={scope}>
<WithPosition mockPosition={mockPosition} />
<PdfSynctexControls />
</EditorProviders>
)
cy.waitForCompile().then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: `state-position`,
data: { value: mockPosition },
})
})
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'not.be.disabled'
)
cy.findByRole('status', { hidden: true }).should('not.exist')
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-sync-to-code-inflight',
data: { value: true },
})
})
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'be.disabled'
)
cy.findByRole('status', { hidden: true }).should('have.length', 1)
cy.wrap(null).then(() => {
testDetachChannel.postMessage({
role: 'detacher',
event: 'state-sync-to-code-inflight',
data: { value: false },
})
})
cy.findByRole('button', { name: /^Go to PDF location in code/ }).should(
'not.be.disabled'
)
cy.get('.synctex-spin-icon').should('not.exist')
})
})
})

View File

@@ -0,0 +1,24 @@
import { EditorView } from '@codemirror/view'
export const mockScope = () => ({
settings: {
syntaxValidation: false,
pdfViewer: 'pdfjs',
},
editor: {
open_doc_name: 'main.tex',
sharejs_doc: {
doc_id: 'test-doc',
getSnapshot: () => 'some doc content',
hasBufferedOps: () => false,
},
view: new EditorView({
doc: '\\documentclass{article}',
}),
},
hasLintingError: false,
ui: {
view: 'editor',
pdfLayout: 'sideBySide',
},
})