mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-06-01 05:11:34 +02:00
[web] integrate clsi-cache into download pdf from project dashboard (#31138)
* [monorepo] fix downloads from non-sharded clsi-cache * [web] check some compile from cache options server-side * [web] integrate clsi-cache into download pdf from project dashboard * [web] remove frontend tests for server-side validation * [web] remove unused fetch mock * [web] use helper that adds polyfill for AbortSignal.any() * [web] upgrade fetch-mock to fix leaking AbortSignal * [web] do not add an extra timeout to clsi-cache request The web backend service has a low timeout already. GitOrigin-RevId: a90984b92f5d4f24005db5a09f2c5d2424436886
This commit is contained in:
34
package-lock.json
generated
34
package-lock.json
generated
@@ -29962,6 +29962,22 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-mock": {
|
||||
"version": "12.6.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.6.0.tgz",
|
||||
"integrity": "sha512-oAy0OqAvjAvduqCeWveBix7LLuDbARPqZZ8ERYtBcCURA3gy7EALA3XWq0tCNxsSg+RmmJqyaeeZlOCV9abv6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/glob-to-regexp": "^0.4.4",
|
||||
"dequal": "^2.0.3",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"regexparam": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
@@ -58447,7 +58463,7 @@
|
||||
"eventsource-client": "^1.1.4",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"fast-fuzzy": "^1.12.0",
|
||||
"fetch-mock": "^12.5.2",
|
||||
"fetch-mock": "^12.6.0",
|
||||
"formik": "^2.2.9",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "^12.0.0",
|
||||
@@ -59775,22 +59791,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"services/web/node_modules/fetch-mock": {
|
||||
"version": "12.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.5.2.tgz",
|
||||
"integrity": "sha512-b5KGDFmdmado2MPQjZl6ix3dAG3iwCitb0XQwN72y2s9VnWZ3ObaGNy+bkpm1390foiLDybdJ7yjRGKD36kATw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/glob-to-regexp": "^0.4.4",
|
||||
"dequal": "^2.0.3",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"regexparam": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.11.0"
|
||||
}
|
||||
},
|
||||
"services/web/node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Path from 'node:path'
|
||||
import _ from 'lodash'
|
||||
import { NotFoundError, ResourceGoneError } from '../Errors/Errors.js'
|
||||
import ClsiCacheHandler from './ClsiCacheHandler.mjs'
|
||||
@@ -27,17 +28,21 @@ const ClsiCookieManager = ClsiCookieManagerFactory(
|
||||
* @param userId
|
||||
* @param filename
|
||||
* @param signal
|
||||
* @return {Promise<{internal: {location: string}, external: {zone: string, shard: string, isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>}
|
||||
* @return {Promise<{internal: {location: string, imageName: string, compiler: string}, external: {zone: string, shard: string, isUpToDate: boolean, lastUpdated: Date, size: number, allFiles: string[]}}>}
|
||||
*/
|
||||
async function getLatestBuildFromCache(projectId, userId, filename, signal) {
|
||||
const [
|
||||
{ location, lastModified: lastCompiled, zone, shard, size, allFiles },
|
||||
lastUpdatedInRedis,
|
||||
{ lastUpdated: lastUpdatedInMongo },
|
||||
{ lastUpdated: lastUpdatedInMongo, imageName: fullImageName, compiler },
|
||||
] = await Promise.all([
|
||||
ClsiCacheHandler.getLatestOutputFile(projectId, userId, filename, signal),
|
||||
DocumentUpdaterHandler.promises.getProjectLastUpdatedAt(projectId),
|
||||
ProjectGetter.promises.getProject(projectId, { lastUpdated: 1 }),
|
||||
ProjectGetter.promises.getProject(projectId, {
|
||||
lastUpdated: 1,
|
||||
imageName: 1,
|
||||
compiler: 1,
|
||||
}),
|
||||
])
|
||||
|
||||
const lastUpdated =
|
||||
@@ -45,10 +50,13 @@ async function getLatestBuildFromCache(projectId, userId, filename, signal) {
|
||||
? lastUpdatedInRedis
|
||||
: lastUpdatedInMongo
|
||||
const isUpToDate = lastCompiled >= lastUpdated
|
||||
const imageName = Path.basename(fullImageName)
|
||||
|
||||
return {
|
||||
internal: {
|
||||
location,
|
||||
imageName,
|
||||
compiler,
|
||||
},
|
||||
external: {
|
||||
isUpToDate,
|
||||
@@ -80,7 +88,7 @@ async function getLatestCompileResult(projectId, userId) {
|
||||
|
||||
async function tryGetLatestCompileResult(projectId, userId, signal) {
|
||||
const {
|
||||
internal: { location: metaLocation },
|
||||
internal: { location: metaLocation, imageName, compiler },
|
||||
external: {
|
||||
isUpToDate,
|
||||
allFiles,
|
||||
@@ -127,6 +135,10 @@ async function tryGetLatestCompileResult(projectId, userId, signal) {
|
||||
timings,
|
||||
} = meta
|
||||
|
||||
if (options.imageName !== imageName || options.compiler !== compiler) {
|
||||
throw new ResourceGoneError()
|
||||
}
|
||||
|
||||
let baseURL = `/project/${projectId}`
|
||||
if (userId) {
|
||||
baseURL += `/user/${userId}`
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Project } from '../../../../../../../../types/project/dashboard/api'
|
||||
import * as eventTracking from '../../../../../../infrastructure/event-tracking'
|
||||
import { useLocation } from '../../../../../../shared/hooks/use-location'
|
||||
import useAbortController from '../../../../../../shared/hooks/use-abort-controller'
|
||||
import { postJSON } from '../../../../../../infrastructure/fetch-json'
|
||||
import { getJSON, postJSON } from '../../../../../../infrastructure/fetch-json'
|
||||
import { isSmallDevice } from '../../../../../../infrastructure/event-tracking'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
const FAKE_EDITOR_ID = uuid()
|
||||
|
||||
type CompileAndDownloadProjectPDFButtonProps = {
|
||||
project: Project
|
||||
@@ -49,43 +53,70 @@ function CompileAndDownloadProjectPDFButton({
|
||||
isSmallDevice,
|
||||
})
|
||||
|
||||
postJSON(`/project/${project.id}/compile`, {
|
||||
body: {
|
||||
check: 'silent',
|
||||
draft: false,
|
||||
incrementalCompilesEnabled: true,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
.catch(() => ({ status: 'error' }))
|
||||
.then(data => {
|
||||
setPendingCompile(false)
|
||||
if (data.status === 'success') {
|
||||
const { isOverleaf } = getMeta('ol-ExposedSettings')
|
||||
;(async () => {
|
||||
if (isOverleaf) {
|
||||
try {
|
||||
const data = await getJSON(
|
||||
`/project/${project.id}/output/cached/output.overleaf.json`,
|
||||
{ signal }
|
||||
)
|
||||
if (data.options.draft) throw new Error('options changed')
|
||||
|
||||
setPendingCompile(false)
|
||||
const outputFile = data.outputFiles
|
||||
.filter((file: { path: string }) => file.path === 'output.pdf')
|
||||
.pop()
|
||||
|
||||
const params = new URLSearchParams({
|
||||
compileGroup: data.compileGroup,
|
||||
popupDownload: 'true',
|
||||
})
|
||||
if (data.clsiServerId) {
|
||||
params.set('clsiserverid', data.clsiServerId)
|
||||
}
|
||||
// Note: Triggering concurrent downloads does not work.
|
||||
// Note: This is affecting the download of .zip files as well.
|
||||
// When creating a dynamic `a` element with `download` attribute,
|
||||
// another "actual" UI click is needed to trigger downloads.
|
||||
// Forwarding the click `event` to the dynamic `a` element does
|
||||
// not work either.
|
||||
location.assign(
|
||||
`/download/project/${project.id}/build/${outputFile.build}/output/output.pdf?${params}`
|
||||
)
|
||||
location.assign(outputFile.downloadURL)
|
||||
onDone?.(e)
|
||||
} else {
|
||||
setShowErrorModal(true)
|
||||
return
|
||||
} catch {
|
||||
// fall back to compile
|
||||
}
|
||||
}
|
||||
|
||||
let data
|
||||
try {
|
||||
data = await postJSON(`/project/${project.id}/compile`, {
|
||||
body: {
|
||||
check: 'silent',
|
||||
draft: false,
|
||||
incrementalCompilesEnabled: true,
|
||||
// Add a fake editorId for enabling clsi-cache.
|
||||
editorId: FAKE_EDITOR_ID,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
} catch {
|
||||
data = { status: 'error' }
|
||||
}
|
||||
setPendingCompile(false)
|
||||
if (data.status !== 'success') {
|
||||
setShowErrorModal(true)
|
||||
return
|
||||
}
|
||||
const outputFile = data.outputFiles
|
||||
.filter((file: { path: string }) => file.path === 'output.pdf')
|
||||
.pop()
|
||||
|
||||
const params = new URLSearchParams({
|
||||
compileGroup: data.compileGroup,
|
||||
popupDownload: 'true',
|
||||
})
|
||||
if (data.clsiServerId) {
|
||||
params.set('clsiserverid', data.clsiServerId)
|
||||
}
|
||||
// Note: Triggering concurrent downloads does not work.
|
||||
// Note: This is affecting the download of .zip files as well.
|
||||
// When creating a dynamic `a` element with `download` attribute,
|
||||
// another "actual" UI click is needed to trigger downloads.
|
||||
// Forwarding the click `event` to the dynamic `a` element does
|
||||
// not work either.
|
||||
location.assign(
|
||||
`/download/project/${project.id}/build/${outputFile.build}/output/output.pdf?${params}`
|
||||
)
|
||||
onDone?.(e)
|
||||
})()
|
||||
return true
|
||||
})
|
||||
},
|
||||
|
||||
@@ -398,8 +398,6 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
|
||||
const rootDocOverride = compiler.getRootDocOverrideId() || rootDocId
|
||||
settingsUpToDate =
|
||||
rootDocOverride === dataFromCache.rootDocId &&
|
||||
dataFromCache.options.imageName === imageName &&
|
||||
dataFromCache.options.compiler === compilerName &&
|
||||
dataFromCache.options.draft === draft &&
|
||||
// Allow stopOnFirstError to be enabled in the compile from cache and disabled locally.
|
||||
// Compiles that passed with stopOnFirstError=true will also pass with stopOnFirstError=false. The inverse does not hold, and we need to recompile.
|
||||
|
||||
@@ -330,7 +330,7 @@
|
||||
"eventsource-client": "^1.1.4",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"fast-fuzzy": "^1.12.0",
|
||||
"fetch-mock": "^12.5.2",
|
||||
"fetch-mock": "^12.6.0",
|
||||
"formik": "^2.2.9",
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "^12.0.0",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from '../../../../frontend/js/shared/context/layout-context'
|
||||
import { FC, PropsWithChildren, ReactElement, useEffect } from 'react'
|
||||
import { useLocalCompileContext } from '@/shared/context/local-compile-context'
|
||||
import { ProjectCompiler } from '../../../../types/project-settings'
|
||||
|
||||
const storeAndFireEvent = (win: typeof window, key: string, value: unknown) => {
|
||||
localStorage.setItem(key, value)
|
||||
@@ -211,20 +210,6 @@ describe('<PdfPreview/>', function () {
|
||||
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' as ProjectCompiler,
|
||||
},
|
||||
},
|
||||
'ignores the compile from cache when draft mode changed': {
|
||||
cached: false,
|
||||
setup: () => {
|
||||
|
||||
@@ -790,6 +790,7 @@ describe('<UserNotifications />', function () {
|
||||
})
|
||||
const resendButton = resendButtons[0]
|
||||
fireEvent.click(resendButton)
|
||||
await fetchMock.callHistory.flush(true)
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { location } from '@/shared/components/location'
|
||||
import { CompileAndDownloadProjectPDFButtonTooltip } from '../../../../../../../../frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button'
|
||||
import fetchMock from 'fetch-mock'
|
||||
import * as eventTracking from '@/infrastructure/event-tracking'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
describe('<CompileAndDownloadProjectPDFButton />', function () {
|
||||
let sendMBSpy: sinon.SinonSpy
|
||||
@@ -31,6 +32,102 @@ describe('<CompileAndDownloadProjectPDFButton />', function () {
|
||||
await screen.findByRole('tooltip', { name: 'Download PDF' })
|
||||
})
|
||||
|
||||
it('downloads the project PDF from cache when clicked', async function () {
|
||||
Object.assign(getMeta('ol-ExposedSettings'), {
|
||||
isOverleaf: true,
|
||||
})
|
||||
fetchMock.get(
|
||||
`/project/${projectsData[0].id}/output/cached/output.overleaf.json`,
|
||||
{
|
||||
status: 'success',
|
||||
compileGroup: 'standard',
|
||||
clsiServerId: 'server-1',
|
||||
outputFiles: [
|
||||
{ path: 'output.pdf', build: '123-321', downloadURL: 'cached.pdf' },
|
||||
],
|
||||
options: { draft: false },
|
||||
},
|
||||
{ delay: 10 }
|
||||
)
|
||||
|
||||
const btn = screen.getByRole('button', { name: 'Download PDF' })
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByRole('button', { name: 'Compiling…' })
|
||||
})
|
||||
|
||||
const assignStub = this.locationWrapperStub.assign
|
||||
await waitFor(() => {
|
||||
expect(assignStub).to.have.been.called
|
||||
})
|
||||
|
||||
expect(assignStub).to.have.been.calledOnce
|
||||
expect(assignStub).to.have.been.calledWith('cached.pdf')
|
||||
|
||||
expect(sendMBSpy).to.have.been.calledOnce
|
||||
expect(sendMBSpy).to.have.been.calledWith('project-list-page-interaction', {
|
||||
action: 'downloadPDF',
|
||||
page: '/',
|
||||
projectId: projectsData[0].id,
|
||||
isSmallDevice: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores cached draft PDF and downloads the project PDF when clicked', async function () {
|
||||
Object.assign(getMeta('ol-ExposedSettings'), {
|
||||
isOverleaf: true,
|
||||
})
|
||||
fetchMock.get(
|
||||
`/project/${projectsData[0].id}/output/cached/output.overleaf.json`,
|
||||
{
|
||||
status: 'success',
|
||||
compileGroup: 'standard',
|
||||
clsiServerId: 'server-1',
|
||||
outputFiles: [
|
||||
{ path: 'output.pdf', build: '123-321', downloadURL: 'cached.pdf' },
|
||||
],
|
||||
options: { draft: true },
|
||||
},
|
||||
{ delay: 10 }
|
||||
)
|
||||
fetchMock.post(
|
||||
`/project/${projectsData[0].id}/compile`,
|
||||
{
|
||||
status: 'success',
|
||||
compileGroup: 'standard',
|
||||
clsiServerId: 'server-1',
|
||||
outputFiles: [{ path: 'output.pdf', build: '123-321' }],
|
||||
},
|
||||
{ delay: 10 }
|
||||
)
|
||||
|
||||
const btn = screen.getByRole('button', { name: 'Download PDF' })
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByRole('button', { name: 'Compiling…' })
|
||||
})
|
||||
|
||||
const assignStub = this.locationWrapperStub.assign
|
||||
await waitFor(() => {
|
||||
expect(assignStub).to.have.been.called
|
||||
})
|
||||
|
||||
expect(assignStub).to.have.been.calledOnce
|
||||
expect(assignStub).to.have.been.calledWith(
|
||||
`/download/project/${projectsData[0].id}/build/123-321/output/output.pdf?compileGroup=standard&popupDownload=true&clsiserverid=server-1`
|
||||
)
|
||||
|
||||
expect(sendMBSpy).to.have.been.calledOnce
|
||||
expect(sendMBSpy).to.have.been.calledWith('project-list-page-interaction', {
|
||||
action: 'downloadPDF',
|
||||
page: '/',
|
||||
projectId: projectsData[0].id,
|
||||
isSmallDevice: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('downloads the project PDF when clicked', async function () {
|
||||
fetchMock.post(
|
||||
`/project/${projectsData[0].id}/compile`,
|
||||
|
||||
Reference in New Issue
Block a user