diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 752df3b21..dada60074 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -1489,6 +1489,8 @@ describe('DocumentDetailComponent', () => { mockContentWindow.onafterprint(new Event('afterprint')) } + tick(500) + expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') @@ -1512,65 +1514,97 @@ describe('DocumentDetailComponent', () => { ) }) - it('should show error toast if printing throws inside iframe', fakeAsync(() => { - initNormally() + const iframePrintErrorCases: Array<{ + description: string + thrownError: Error + expectToast: boolean + }> = [ + { + description: 'should show error toast if printing throws inside iframe', + thrownError: new Error('focus failed'), + expectToast: true, + }, + { + description: + 'should suppress toast if cross-origin afterprint error occurs', + thrownError: new DOMException( + 'Accessing onafterprint triggered a cross-origin violation', + 'SecurityError' + ), + expectToast: false, + }, + ] - const appendChildSpy = jest - .spyOn(document.body, 'appendChild') - .mockImplementation((node: Node) => node) - const removeChildSpy = jest - .spyOn(document.body, 'removeChild') - .mockImplementation((node: Node) => node) - const createObjectURLSpy = jest - .spyOn(URL, 'createObjectURL') - .mockReturnValue('blob:mock-url') - const revokeObjectURLSpy = jest - .spyOn(URL, 'revokeObjectURL') - .mockImplementation(() => {}) + iframePrintErrorCases.forEach(({ description, thrownError, expectToast }) => { + it( + description, + fakeAsync(() => { + initNormally() - const toastSpy = jest.spyOn(toastService, 'showError') + const appendChildSpy = jest + .spyOn(document.body, 'appendChild') + .mockImplementation((node: Node) => node) + const removeChildSpy = jest + .spyOn(document.body, 'removeChild') + .mockImplementation((node: Node) => node) + const createObjectURLSpy = jest + .spyOn(URL, 'createObjectURL') + .mockReturnValue('blob:mock-url') + const revokeObjectURLSpy = jest + .spyOn(URL, 'revokeObjectURL') + .mockImplementation(() => {}) - const mockContentWindow = { - focus: jest.fn().mockImplementation(() => { - throw new Error('focus failed') - }), - print: jest.fn(), - onafterprint: null, - } + const toastSpy = jest.spyOn(toastService, 'showError') - const mockIframe: any = { - style: {}, - src: '', - onload: null, - contentWindow: mockContentWindow, - } + const mockContentWindow = { + focus: jest.fn().mockImplementation(() => { + throw thrownError + }), + print: jest.fn(), + onafterprint: null, + } - const createElementSpy = jest - .spyOn(document, 'createElement') - .mockReturnValue(mockIframe as any) + const mockIframe: any = { + style: {}, + src: '', + onload: null, + contentWindow: mockContentWindow, + } - const blob = new Blob(['test'], { type: 'application/pdf' }) - component.printDocument() + const createElementSpy = jest + .spyOn(document, 'createElement') + .mockReturnValue(mockIframe as any) - const req = httpTestingController.expectOne( - `${environment.apiBaseUrl}documents/${doc.id}/download/` + const blob = new Blob(['test'], { type: 'application/pdf' }) + component.printDocument() + + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}documents/${doc.id}/download/` + ) + req.flush(blob) + + tick() + + if (mockIframe.onload) { + mockIframe.onload(new Event('load')) + } + + tick(200) + + if (expectToast) { + expect(toastSpy).toHaveBeenCalled() + } else { + expect(toastSpy).not.toHaveBeenCalled() + } + expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') + + createElementSpy.mockRestore() + appendChildSpy.mockRestore() + removeChildSpy.mockRestore() + createObjectURLSpy.mockRestore() + revokeObjectURLSpy.mockRestore() + }) ) - req.flush(blob) - - tick() - - if (mockIframe.onload) { - mockIframe.onload(new Event('load')) - } - - expect(toastSpy).toHaveBeenCalled() - expect(removeChildSpy).toHaveBeenCalledWith(mockIframe) - expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') - - createElementSpy.mockRestore() - appendChildSpy.mockRestore() - removeChildSpy.mockRestore() - createObjectURLSpy.mockRestore() - revokeObjectURLSpy.mockRestore() - })) + }) }) diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index 48ddf61e5..9c0c84592 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -21,7 +21,7 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms' import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { DeviceDetectorService } from 'ngx-device-detector' -import { BehaviorSubject, Observable, of, Subject } from 'rxjs' +import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs' import { catchError, debounceTime, @@ -1452,9 +1452,18 @@ export class DocumentDetailComponent URL.revokeObjectURL(blobUrl) } } catch (err) { - this.toastService.showError($localize`Print failed.`, err) - document.body.removeChild(iframe) - URL.revokeObjectURL(blobUrl) + // FF throws cross-origin error on onafterprint + const isCrossOriginAfterPrintError = + err instanceof DOMException && + err.message.includes('onafterprint') + if (!isCrossOriginAfterPrintError) { + this.toastService.showError($localize`Print failed.`, err) + } + timer(100).subscribe(() => { + // delay to avoid FF print failure + document.body.removeChild(iframe) + URL.revokeObjectURL(blobUrl) + }) } } },