diff --git a/acrobat/blocks/rnr/rnr.js b/acrobat/blocks/rnr/rnr.js index c8f146c6e..275a28f9f 100644 --- a/acrobat/blocks/rnr/rnr.js +++ b/acrobat/blocks/rnr/rnr.js @@ -113,16 +113,48 @@ function extractMetadata(options) { // #region IMS Helpers +const getRefreshToken = async () => { + try { + const { tokenInfo } = window.adobeIMS ? await window.adobeIMS.refreshToken() : {}; + return tokenInfo; + } catch (e) { + return { + token: null, + error: e, + }; + } +}; + +const attemptTokenRefresh = async () => { + const refreshResult = await getRefreshToken(); + if (!refreshResult.error) { + return { token: refreshResult, error: null }; + } + return refreshResult; +}; + const getImsToken = async (operation) => { + const RETRY_WAIT = 2000; try { - const token = window.adobeIMS.getAccessToken()?.token; - if (!token) { - throw new Error(`Cannot ${operation} token is missing`); + const accessToken = window.adobeIMS?.getAccessToken(); + if (!accessToken || accessToken?.expire?.valueOf() <= Date.now() + (5 * 60 * 1000)) { + const reason = !accessToken ? 'access_token_null' : 'access_token_expired'; + const firstAttempt = await attemptTokenRefresh(); + if (!firstAttempt.error) return firstAttempt.token?.token; + await new Promise((resolve) => { setTimeout(resolve, RETRY_WAIT); }); + const retryAttempt = await attemptTokenRefresh(); + if (!retryAttempt.error) return retryAttempt.token?.token; + const errorMsg = `Token refresh failed after retry. refresh_error_${reason}`; + window.lana.log( + `RnR: Cannot ${operation} - ${errorMsg} for verb ${metadata.verb}`, + lanaOptions, + ); + return null; } - return token; + return accessToken?.token; } catch (error) { window.lana.log( - `RnR: ${error.message} for verb ${metadata.verb}`, + `RnR: Cannot ${operation} - ${error.message} for verb ${metadata.verb}`, lanaOptions, ); return null; diff --git a/test/blocks/rnr/rnr.test.js b/test/blocks/rnr/rnr.test.js index 9419a0ab3..6aa4a7774 100644 --- a/test/blocks/rnr/rnr.test.js +++ b/test/blocks/rnr/rnr.test.js @@ -12,7 +12,19 @@ describe('rnr - Ratings and reviews', () => { document.body.innerHTML = await readFile({ path: './mocks/body.html' }); localStorage.removeItem('rnr-snapshot'); window.mph = { 'rnr-rating-tooltips': 'Poor, Below Average, Good, Very Good, Outstanding' }; - window.adobeIMS = { getAccessToken: () => ({ token: 'test-token' }) }; + // Mock IMS with valid token that won't expire for 10 minutes + window.adobeIMS = { + getAccessToken: () => ({ + token: 'test-token', + expire: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes from now + }), + refreshToken: () => Promise.resolve({ + tokenInfo: { + token: 'refreshed-test-token', + expire: new Date(Date.now() + 10 * 60 * 1000), + }, + }), + }; window.lana = { log: () => {} }; const rnr = document.querySelector('.rnr'); init(rnr); @@ -22,6 +34,9 @@ describe('rnr - Ratings and reviews', () => { if (window.fetch.restore) window.fetch.restore(); if (window.lana.log.restore) window.lana.log.restore(); if (window.adobeIMS.getAccessToken.restore) window.adobeIMS.getAccessToken.restore(); + if (window.adobeIMS.refreshToken && window.adobeIMS.refreshToken.restore) { + window.adobeIMS.refreshToken.restore(); + } }); // #region IMS Token Error Handling @@ -39,12 +54,15 @@ describe('rnr - Ratings and reviews', () => { it('should handle null access token', async () => { sinon.stub(window.adobeIMS, 'getAccessToken').returns(null); + sinon.stub(window.adobeIMS, 'refreshToken').rejects(new Error('Token refresh failed')); document.body.innerHTML = await readFile({ path: './mocks/body.html' }); const rnr = document.querySelector('.rnr'); await init(rnr); + // Wait for retry logic to complete + await new Promise((resolve) => { setTimeout(resolve, 2500); }); const containerElement = await waitForElement('.rnr-container'); expect(containerElement).to.exist; - }); + }).timeout(5000); it('should handle error thrown by getAccessToken', async () => { const originalIMS = window.adobeIMS; @@ -53,41 +71,142 @@ describe('rnr - Ratings and reviews', () => { console.error('Intentional test error: IMS not available'); return undefined; }, + refreshToken: () => Promise.reject(new Error('Token refresh failed')), }; try { document.body.innerHTML = await readFile({ path: './mocks/body.html' }); const rnr = document.querySelector('.rnr'); await init(rnr); + // Wait for retry logic to complete + await new Promise((resolve) => { setTimeout(resolve, 2500); }); const containerElement = await waitForElement('.rnr-container'); expect(containerElement).to.exist; } finally { window.adobeIMS = originalIMS; } - }); + }).timeout(5000); it('should handle token not available when posting review', async () => { const containerElement = await waitForElement('.rnr-container'); const ratingFieldsetElement = containerElement.querySelector('.rnr-rating-fieldset'); const stars = ratingFieldsetElement.querySelectorAll('input'); window.adobeIMS.getAccessToken = () => null; + window.adobeIMS.refreshToken = () => Promise.reject(new Error('Token refresh failed')); stars[4].click(); expect(containerElement).to.exist; }); it('should wait for IMS to be ready', async () => { const originalGetAccessToken = window.adobeIMS.getAccessToken; + const originalRefreshToken = window.adobeIMS.refreshToken; let tokenAvailable = false; - window.adobeIMS.getAccessToken = () => (tokenAvailable ? { token: 'test-token' } : null); + let refreshCallCount = 0; + window.adobeIMS.getAccessToken = () => (tokenAvailable ? { + token: 'test-token', + expire: new Date(Date.now() + 10 * 60 * 1000), + } : null); + window.adobeIMS.refreshToken = () => { + refreshCallCount += 1; + // Return success on second refresh attempt (after first retry) + if (refreshCallCount >= 2) { + tokenAvailable = true; + return Promise.resolve({ + tokenInfo: { + token: 'refreshed-test-token', + expire: new Date(Date.now() + 10 * 60 * 1000), + }, + }); + } + return Promise.reject(new Error('Token not ready')); + }; document.body.innerHTML = await readFile({ path: './mocks/body.html' }); const rnr = document.querySelector('.rnr'); - setTimeout(() => { - tokenAvailable = true; - }, 500); await init(rnr); + // Wait for retry logic to complete + await new Promise((resolve) => { setTimeout(resolve, 3000); }); const containerElement = await waitForElement('.rnr-container'); expect(containerElement).to.exist; window.adobeIMS.getAccessToken = originalGetAccessToken; - }); + window.adobeIMS.refreshToken = originalRefreshToken; + }).timeout(8000); + + it('should successfully refresh expired token', async () => { + const originalIMS = window.adobeIMS; + const refreshTokenStub = sinon.stub().resolves({ + tokenInfo: { + token: 'new-refreshed-token', + expire: new Date(Date.now() + 10 * 60 * 1000), + }, + }); + // Mock expired token + window.adobeIMS = { + getAccessToken: () => ({ + token: 'expired-token', + expire: { valueOf: () => Date.now() - 1000 }, // Already expired + }), + refreshToken: refreshTokenStub, + }; + const fetchStub = sinon.stub(window, 'fetch').resolves({ + ok: true, + json: () => Promise.resolve({ + overallRating: 4.5, + ratingHistogram: { rating1: 0, rating2: 0, rating3: 0, rating4: 5, rating5: 5 }, + }), + }); + try { + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + const rnr = document.querySelector('.rnr'); + await init(rnr); + // Wait a bit for async operations to complete + await new Promise((resolve) => { setTimeout(resolve, 100); }); + const containerElement = await waitForElement('.rnr-container'); + expect(containerElement).to.exist; + // Verify that refreshToken was called or that fetch was called (meaning token was obtained) + const wasCalled = refreshTokenStub.called || fetchStub.called; + expect(wasCalled).to.be.true; + } finally { + window.adobeIMS = originalIMS; + } + }).timeout(5000); + + it('should handle token expiring soon (within 5 minutes)', async () => { + const originalIMS = window.adobeIMS; + const refreshTokenStub = sinon.stub().resolves({ + tokenInfo: { + token: 'new-refreshed-token', + expire: new Date(Date.now() + 10 * 60 * 1000), + }, + }); + // Mock token that will expire in 4 minutes + window.adobeIMS = { + getAccessToken: () => ({ + token: 'expiring-soon-token', + expire: { valueOf: () => Date.now() + 4 * 60 * 1000 }, // 4 minutes from now + }), + refreshToken: refreshTokenStub, + }; + const fetchStub = sinon.stub(window, 'fetch').resolves({ + ok: true, + json: () => Promise.resolve({ + overallRating: 4.5, + ratingHistogram: { rating1: 0, rating2: 0, rating3: 0, rating4: 5, rating5: 5 }, + }), + }); + try { + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + const rnr = document.querySelector('.rnr'); + await init(rnr); + // Wait a bit for async operations to complete + await new Promise((resolve) => { setTimeout(resolve, 100); }); + const containerElement = await waitForElement('.rnr-container'); + expect(containerElement).to.exist; + // Verify that refreshToken was called or that fetch was called (meaning token was obtained) + const wasCalled = refreshTokenStub.called || fetchStub.called; + expect(wasCalled).to.be.true; + } finally { + window.adobeIMS = originalIMS; + } + }).timeout(5000); it('should gracefully handle fetch errors when loading data', async () => { sinon.stub(window, 'fetch').rejects(new Error('Network error')); diff --git a/test/e2e/features/unity/smoke/verbs.feature b/test/e2e/features/unity/smoke/verbs.feature index a932dc215..ce1b70c2b 100644 --- a/test/e2e/features/unity/smoke/verbs.feature +++ b/test/e2e/features/unity/smoke/verbs.feature @@ -189,4 +189,9 @@ Feature: Frictionless Converter Block @ai-summary-generator Examples: | Verb | File | - | ai-summary-generator | test-files/test.pdf | \ No newline at end of file + | ai-summary-generator | test-files/test.pdf | + + @pdf-ai + Examples: + | Verb | File | + | pdf-ai | test-files/test.pdf | diff --git a/test/e2e/page-objects/pdfai.page.js b/test/e2e/page-objects/pdfai.page.js new file mode 100644 index 000000000..927ecbb3c --- /dev/null +++ b/test/e2e/page-objects/pdfai.page.js @@ -0,0 +1,7 @@ +import { FrictionlessPage } from './frictionless.page'; + +export class PdfAiPage extends FrictionlessPage { + constructor() { + super("/acrobat/online/pdf-ai"); + } +} diff --git a/test/e2e/step-definitions/dc.steps.js b/test/e2e/step-definitions/dc.steps.js index ed5aafed1..d93d514be 100644 --- a/test/e2e/step-definitions/dc.steps.js +++ b/test/e2e/step-definitions/dc.steps.js @@ -27,6 +27,7 @@ import { AddPdfPageNumbersPage } from "../page-objects/addpdfpagenumbers.page"; import { OcrPdfPage } from "../page-objects/ocrpdf.page"; import { AiChatPdfPage } from "../page-objects/aichatpdf.page"; import { AiSummaryGeneratorPage } from "../page-objects/aisummarygenerator.page"; +import { PdfAiPage } from "../page-objects/pdfai.page"; import { FrictionlessPage } from "../page-objects/frictionless.page"; import { UnityPage } from "../page-objects/unity.page"; import { DCPage } from "../page-objects/dc.page"; @@ -72,7 +73,8 @@ Then(/^I go to the ([^\"]*) page$/, async function (verb) { "add-pdf-page-numbers": AddPdfPageNumbersPage, "ocr-pdf": OcrPdfPage, "ai-chat-pdf": AiChatPdfPage, - "ai-summary-generator": AiSummaryGeneratorPage + "ai-summary-generator": AiSummaryGeneratorPage, + "pdf-ai": PdfAiPage }[verb]; this.page = new pageClass();