diff --git a/package-lock.json b/package-lock.json index c2b46cf03a..f88a888ef6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/services/web/app/src/Features/Compile/ClsiCacheManager.mjs b/services/web/app/src/Features/Compile/ClsiCacheManager.mjs index 40a7b35938..aba619dae3 100644 --- a/services/web/app/src/Features/Compile/ClsiCacheManager.mjs +++ b/services/web/app/src/Features/Compile/ClsiCacheManager.mjs @@ -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}` diff --git a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.tsx b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.tsx index 0e8c5cfedc..6263d2789f 100644 --- a/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.tsx +++ b/services/web/frontend/js/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.tsx @@ -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 }) }, diff --git a/services/web/frontend/js/shared/context/local-compile-context.tsx b/services/web/frontend/js/shared/context/local-compile-context.tsx index dd6d86b7c6..72531581c5 100644 --- a/services/web/frontend/js/shared/context/local-compile-context.tsx +++ b/services/web/frontend/js/shared/context/local-compile-context.tsx @@ -398,8 +398,6 @@ export const LocalCompileProvider: FC = ({ 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. diff --git a/services/web/package.json b/services/web/package.json index 5770d73344..c869b314f3 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -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", diff --git a/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx b/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx index c109082b4d..d074ff441f 100644 --- a/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx +++ b/services/web/test/frontend/components/pdf-preview/pdf-preview.spec.tsx @@ -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('', 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: () => { diff --git a/services/web/test/frontend/features/project-list/components/notifications.test.tsx b/services/web/test/frontend/features/project-list/components/notifications.test.tsx index 8db12d23e6..644135f0b1 100644 --- a/services/web/test/frontend/features/project-list/components/notifications.test.tsx +++ b/services/web/test/frontend/features/project-list/components/notifications.test.tsx @@ -790,6 +790,7 @@ describe('', function () { }) const resendButton = resendButtons[0] fireEvent.click(resendButton) + await fetchMock.callHistory.flush(true) await screen.findByRole('dialog') diff --git a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx index 91f37a8a1e..3af8c889f9 100644 --- a/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx +++ b/services/web/test/frontend/features/project-list/components/table/cells/action-buttons/compile-and-download-project-pdf-button.test.tsx @@ -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('', function () { let sendMBSpy: sinon.SinonSpy @@ -31,6 +32,102 @@ describe('', 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`,