diff --git a/dist/chromium/content.js b/dist/chromium/content.js index 675978b..5543b77 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -89,6 +89,8 @@ let tokenRelayRequest = null; let consumerTokenServiceRequest = null; let accessToken = ""; + let salesCommonToken = null; + let salesCommonTokenRequest = null; let periodRequestVersion = 0; let authStatus = AUTH_STATUS.idle; let requestLog = []; @@ -106,6 +108,7 @@ let productsRenderQueued = false; let opptyProductsByNumber = new Map(); let expandedProductOptyNumbers = new Set(); + const aiSummariesByOptyNumber = new Map(); let productsProgress = { state: "idle", completed: 0, @@ -615,7 +618,27 @@ return consumerTokenServiceRequest; } - async function requestTokenRelay() { + function requestTokenRelaySalesCommon() { + if (salesCommonToken && Date.now() < salesCommonToken.expiresAt) { + return Promise.resolve(salesCommonToken.access_token); + } + if (salesCommonTokenRequest) { + return salesCommonTokenRequest; + } + salesCommonTokenRequest = requestTokenRelay({ + name: "requestTokenRelaySalesCommon", + url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`, + includeExpiry: true + }).then((token) => { + salesCommonToken = token; + return token.access_token; + }).finally(() => { + salesCommonTokenRequest = null; + }); + return salesCommonTokenRequest; + } + + async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) { let refreshResult = null; let refreshAttempted = false; @@ -624,7 +647,7 @@ if (!xsrfTokenSource.token) { if (refreshAttempted) { - const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh."); throw new Error("XSRF token cookie not found after automatic refresh."); } @@ -634,10 +657,10 @@ continue; } - const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); try { - const response = await fetch(TOKEN_RELAY_URL, { + const response = await fetch(url, { method: "GET", credentials: "include", headers: { @@ -698,6 +721,14 @@ access_token: maskToken(responseData.access_token) }, null, 2))); + if (includeExpiry) { + const lifetime = Number(responseData.expires_in); + return { + access_token: responseData.access_token, + expiresAt: Number.isFinite(lifetime) && lifetime > 0 + ? requestEntry.startedAt.getTime() + lifetime * 1000 : 0 + }; + } return responseData.access_token; } catch (error) { logRequestFailure(requestEntry, error.message || "Request failed."); @@ -706,11 +737,11 @@ } } - function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult) { + function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) { return logRequestStart({ - name: "requestTokenRelay", + name, method: "GET", - url: TOKEN_RELAY_URL, + url, headers: { "x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)" }, @@ -3096,6 +3127,7 @@ expandedProductOptyNumbers.delete(normalizedOptyNumber); } else { expandedProductOptyNumbers.add(normalizedOptyNumber); + requestGenerateAiSummary(normalizedOptyNumber); } renderOpportunitiesTable(); @@ -3103,6 +3135,98 @@ return button; } + function requestGenerateAiSummary(optyNumber) { + const key = String(optyNumber); + const existing = aiSummariesByOptyNumber.get(key); + if (existing && existing.status === "loading") { + return existing.promise; + } + + const state = { status: "loading", summaryText: "", error: "", promise: null }; + aiSummariesByOptyNumber.set(key, state); + state.promise = (async () => { + let entry; + try { + let token = await requestTokenRelaySalesCommon(); + if (!token) { + throw new Error("Authentication is required to generate the summary."); + } + const path = "/api/sales-common/gen-ai/v1/summarizations/generate"; + const url = `https://eeho.fa.us2.oraclecloud.com${path}`; + const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key }; + let response; + for (let attempt = 0; attempt < 2; attempt += 1) { + const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + entry = logRequestStart({ + name: "requestGenerateAiSummary", method: "POST", url, headers, + body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 } + }); + response = await fetch(url, { + method: "POST", credentials: "include", headers, body: JSON.stringify(payload) + }); + logRequestSuccess(entry, response, await readResponseBodyForDebug(response)); + if (response.status !== 401 && response.status !== 403) { + break; + } + // A late failure for an old token must not discard a newly refreshed token. + if (salesCommonToken && salesCommonToken.access_token === token) { + salesCommonToken = null; + } + if (attempt === 0) { + token = await requestTokenRelaySalesCommon(); + } + } + if (!response.ok) { + throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`); + } + let data; + try { + data = await response.json(); + } catch (error) { + throw new Error("requestGenerateAiSummary returned an invalid JSON response."); + } + if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) { + throw new Error("requestGenerateAiSummary returned an empty summary."); + } + state.summaryText = data.summaryText; + state.status = "ready"; + } catch (error) { + state.status = "error"; + state.error = error.message || "Unable to generate the AI summary."; + if (entry) { + logRequestFailure(entry, state.error); + } + } finally { + // Update only visible summaries, without reopening rows or rebuilding the table. + document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => { + if (alert.dataset.optyNumber === key) { + alert.replaceWith(createAiSummaryAlert(key)); + } + }); + } + })(); + return state.promise; + } + + function createAiSummaryAlert(optyNumber) { + const state = aiSummariesByOptyNumber.get(optyNumber); + const alert = document.createElement("div"); + alert.className = "opportunities-extension-ai-summary"; + alert.dataset.optyNumber = optyNumber; + alert.setAttribute("role", "status"); + alert.setAttribute("aria-live", "polite"); + alert.setAttribute("aria-busy", String(!state || state.status === "loading")); + const title = document.createElement("strong"); + title.textContent = "Summary generated by AI"; + const description = document.createElement("p"); + description.textContent = !state || state.status === "loading" + ? "Generating summary..." + : state.status === "error" ? state.error : state.summaryText; + alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error")); + alert.append(title, description); + return alert; + } + function createProductsDetailRow(opportunity) { const row = document.createElement("tr"); const cell = document.createElement("td"); @@ -3127,6 +3251,7 @@ summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated"; heading.append(title, summary); panel.append(heading); + panel.append(createAiSummaryAlert(optyNumber)); if (productsResult && productsResult.error) { const error = document.createElement("p"); @@ -5245,6 +5370,51 @@ color: #312d2a; } + .opportunities-extension-ai-summary { + box-sizing: border-box; + width: 100%; + min-width: 0; + margin: 0 0 12px; + padding: 12px 16px; + border: 1px solid #dedbd7; + border-radius: 6px; + background: #ffffff; + color: #312d2a; + font-size: 13px; + line-height: 1.5; + text-align: left; + white-space: normal; + overflow-wrap: anywhere; + } + + .opportunities-extension-ai-summary strong { + display: block; + font-size: 13px; + font-weight: 600; + } + + .opportunities-extension-ai-summary p { + margin: 4px 0 0; + font: inherit; + white-space: pre-wrap; + } + + .opportunities-extension-ai-summary-error { + color: #b42318; + border-color: #d9928b; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary { + background: #292827; + color: #f6f4f2; + border-color: #5f5a55; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary-error { + color: #ffb4ab; + border-color: #a45c55; + } + .opportunities-extension-products-detail-heading strong { font-size: 13px; } diff --git a/dist/firefox/content.js b/dist/firefox/content.js index 675978b..5543b77 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -89,6 +89,8 @@ let tokenRelayRequest = null; let consumerTokenServiceRequest = null; let accessToken = ""; + let salesCommonToken = null; + let salesCommonTokenRequest = null; let periodRequestVersion = 0; let authStatus = AUTH_STATUS.idle; let requestLog = []; @@ -106,6 +108,7 @@ let productsRenderQueued = false; let opptyProductsByNumber = new Map(); let expandedProductOptyNumbers = new Set(); + const aiSummariesByOptyNumber = new Map(); let productsProgress = { state: "idle", completed: 0, @@ -615,7 +618,27 @@ return consumerTokenServiceRequest; } - async function requestTokenRelay() { + function requestTokenRelaySalesCommon() { + if (salesCommonToken && Date.now() < salesCommonToken.expiresAt) { + return Promise.resolve(salesCommonToken.access_token); + } + if (salesCommonTokenRequest) { + return salesCommonTokenRequest; + } + salesCommonTokenRequest = requestTokenRelay({ + name: "requestTokenRelaySalesCommon", + url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`, + includeExpiry: true + }).then((token) => { + salesCommonToken = token; + return token.access_token; + }).finally(() => { + salesCommonTokenRequest = null; + }); + return salesCommonTokenRequest; + } + + async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) { let refreshResult = null; let refreshAttempted = false; @@ -624,7 +647,7 @@ if (!xsrfTokenSource.token) { if (refreshAttempted) { - const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh."); throw new Error("XSRF token cookie not found after automatic refresh."); } @@ -634,10 +657,10 @@ continue; } - const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); try { - const response = await fetch(TOKEN_RELAY_URL, { + const response = await fetch(url, { method: "GET", credentials: "include", headers: { @@ -698,6 +721,14 @@ access_token: maskToken(responseData.access_token) }, null, 2))); + if (includeExpiry) { + const lifetime = Number(responseData.expires_in); + return { + access_token: responseData.access_token, + expiresAt: Number.isFinite(lifetime) && lifetime > 0 + ? requestEntry.startedAt.getTime() + lifetime * 1000 : 0 + }; + } return responseData.access_token; } catch (error) { logRequestFailure(requestEntry, error.message || "Request failed."); @@ -706,11 +737,11 @@ } } - function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult) { + function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) { return logRequestStart({ - name: "requestTokenRelay", + name, method: "GET", - url: TOKEN_RELAY_URL, + url, headers: { "x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)" }, @@ -3096,6 +3127,7 @@ expandedProductOptyNumbers.delete(normalizedOptyNumber); } else { expandedProductOptyNumbers.add(normalizedOptyNumber); + requestGenerateAiSummary(normalizedOptyNumber); } renderOpportunitiesTable(); @@ -3103,6 +3135,98 @@ return button; } + function requestGenerateAiSummary(optyNumber) { + const key = String(optyNumber); + const existing = aiSummariesByOptyNumber.get(key); + if (existing && existing.status === "loading") { + return existing.promise; + } + + const state = { status: "loading", summaryText: "", error: "", promise: null }; + aiSummariesByOptyNumber.set(key, state); + state.promise = (async () => { + let entry; + try { + let token = await requestTokenRelaySalesCommon(); + if (!token) { + throw new Error("Authentication is required to generate the summary."); + } + const path = "/api/sales-common/gen-ai/v1/summarizations/generate"; + const url = `https://eeho.fa.us2.oraclecloud.com${path}`; + const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key }; + let response; + for (let attempt = 0; attempt < 2; attempt += 1) { + const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + entry = logRequestStart({ + name: "requestGenerateAiSummary", method: "POST", url, headers, + body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 } + }); + response = await fetch(url, { + method: "POST", credentials: "include", headers, body: JSON.stringify(payload) + }); + logRequestSuccess(entry, response, await readResponseBodyForDebug(response)); + if (response.status !== 401 && response.status !== 403) { + break; + } + // A late failure for an old token must not discard a newly refreshed token. + if (salesCommonToken && salesCommonToken.access_token === token) { + salesCommonToken = null; + } + if (attempt === 0) { + token = await requestTokenRelaySalesCommon(); + } + } + if (!response.ok) { + throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`); + } + let data; + try { + data = await response.json(); + } catch (error) { + throw new Error("requestGenerateAiSummary returned an invalid JSON response."); + } + if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) { + throw new Error("requestGenerateAiSummary returned an empty summary."); + } + state.summaryText = data.summaryText; + state.status = "ready"; + } catch (error) { + state.status = "error"; + state.error = error.message || "Unable to generate the AI summary."; + if (entry) { + logRequestFailure(entry, state.error); + } + } finally { + // Update only visible summaries, without reopening rows or rebuilding the table. + document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => { + if (alert.dataset.optyNumber === key) { + alert.replaceWith(createAiSummaryAlert(key)); + } + }); + } + })(); + return state.promise; + } + + function createAiSummaryAlert(optyNumber) { + const state = aiSummariesByOptyNumber.get(optyNumber); + const alert = document.createElement("div"); + alert.className = "opportunities-extension-ai-summary"; + alert.dataset.optyNumber = optyNumber; + alert.setAttribute("role", "status"); + alert.setAttribute("aria-live", "polite"); + alert.setAttribute("aria-busy", String(!state || state.status === "loading")); + const title = document.createElement("strong"); + title.textContent = "Summary generated by AI"; + const description = document.createElement("p"); + description.textContent = !state || state.status === "loading" + ? "Generating summary..." + : state.status === "error" ? state.error : state.summaryText; + alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error")); + alert.append(title, description); + return alert; + } + function createProductsDetailRow(opportunity) { const row = document.createElement("tr"); const cell = document.createElement("td"); @@ -3127,6 +3251,7 @@ summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated"; heading.append(title, summary); panel.append(heading); + panel.append(createAiSummaryAlert(optyNumber)); if (productsResult && productsResult.error) { const error = document.createElement("p"); @@ -5245,6 +5370,51 @@ color: #312d2a; } + .opportunities-extension-ai-summary { + box-sizing: border-box; + width: 100%; + min-width: 0; + margin: 0 0 12px; + padding: 12px 16px; + border: 1px solid #dedbd7; + border-radius: 6px; + background: #ffffff; + color: #312d2a; + font-size: 13px; + line-height: 1.5; + text-align: left; + white-space: normal; + overflow-wrap: anywhere; + } + + .opportunities-extension-ai-summary strong { + display: block; + font-size: 13px; + font-weight: 600; + } + + .opportunities-extension-ai-summary p { + margin: 4px 0 0; + font: inherit; + white-space: pre-wrap; + } + + .opportunities-extension-ai-summary-error { + color: #b42318; + border-color: #d9928b; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary { + background: #292827; + color: #f6f4f2; + border-color: #5f5a55; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary-error { + color: #ffb4ab; + border-color: #a45c55; + } + .opportunities-extension-products-detail-heading strong { font-size: 13px; } diff --git a/package.json b/package.json index 130a217..32310ee 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "node scripts/build.mjs", - "check": "node --check src/content.js && node --check src/background.js && node tests/consumptionTrend.test.cjs && node tests/consumerTokenSessionRetry.test.cjs && node scripts/build.mjs --check", - "test": "node tests/consumptionTrend.test.cjs && node tests/consumerTokenSessionRetry.test.cjs" + "check": "node --check src/content.js && node --check src/background.js && node tests/consumptionTrend.test.cjs && node tests/consumerTokenSessionRetry.test.cjs && node tests/aiSummary.test.cjs && node scripts/build.mjs --check", + "test": "node tests/consumptionTrend.test.cjs && node tests/consumerTokenSessionRetry.test.cjs && node tests/aiSummary.test.cjs" } } diff --git a/src/content.js b/src/content.js index 675978b..5543b77 100644 --- a/src/content.js +++ b/src/content.js @@ -89,6 +89,8 @@ let tokenRelayRequest = null; let consumerTokenServiceRequest = null; let accessToken = ""; + let salesCommonToken = null; + let salesCommonTokenRequest = null; let periodRequestVersion = 0; let authStatus = AUTH_STATUS.idle; let requestLog = []; @@ -106,6 +108,7 @@ let productsRenderQueued = false; let opptyProductsByNumber = new Map(); let expandedProductOptyNumbers = new Set(); + const aiSummariesByOptyNumber = new Map(); let productsProgress = { state: "idle", completed: 0, @@ -615,7 +618,27 @@ return consumerTokenServiceRequest; } - async function requestTokenRelay() { + function requestTokenRelaySalesCommon() { + if (salesCommonToken && Date.now() < salesCommonToken.expiresAt) { + return Promise.resolve(salesCommonToken.access_token); + } + if (salesCommonTokenRequest) { + return salesCommonTokenRequest; + } + salesCommonTokenRequest = requestTokenRelay({ + name: "requestTokenRelaySalesCommon", + url: `${TOKEN_RELAY_URL}?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F`, + includeExpiry: true + }).then((token) => { + salesCommonToken = token; + return token.access_token; + }).finally(() => { + salesCommonTokenRequest = null; + }); + return salesCommonTokenRequest; + } + + async function requestTokenRelay({ name = "requestTokenRelay", url = TOKEN_RELAY_URL, includeExpiry = false } = {}) { let refreshResult = null; let refreshAttempted = false; @@ -624,7 +647,7 @@ if (!xsrfTokenSource.token) { if (refreshAttempted) { - const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh."); throw new Error("XSRF token cookie not found after automatic refresh."); } @@ -634,10 +657,10 @@ continue; } - const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult); + const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url); try { - const response = await fetch(TOKEN_RELAY_URL, { + const response = await fetch(url, { method: "GET", credentials: "include", headers: { @@ -698,6 +721,14 @@ access_token: maskToken(responseData.access_token) }, null, 2))); + if (includeExpiry) { + const lifetime = Number(responseData.expires_in); + return { + access_token: responseData.access_token, + expiresAt: Number.isFinite(lifetime) && lifetime > 0 + ? requestEntry.startedAt.getTime() + lifetime * 1000 : 0 + }; + } return responseData.access_token; } catch (error) { logRequestFailure(requestEntry, error.message || "Request failed."); @@ -706,11 +737,11 @@ } } - function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult) { + function createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name = "requestTokenRelay", url = TOKEN_RELAY_URL) { return logRequestStart({ - name: "requestTokenRelay", + name, method: "GET", - url: TOKEN_RELAY_URL, + url, headers: { "x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)" }, @@ -3096,6 +3127,7 @@ expandedProductOptyNumbers.delete(normalizedOptyNumber); } else { expandedProductOptyNumbers.add(normalizedOptyNumber); + requestGenerateAiSummary(normalizedOptyNumber); } renderOpportunitiesTable(); @@ -3103,6 +3135,98 @@ return button; } + function requestGenerateAiSummary(optyNumber) { + const key = String(optyNumber); + const existing = aiSummariesByOptyNumber.get(key); + if (existing && existing.status === "loading") { + return existing.promise; + } + + const state = { status: "loading", summaryText: "", error: "", promise: null }; + aiSummariesByOptyNumber.set(key, state); + state.promise = (async () => { + let entry; + try { + let token = await requestTokenRelaySalesCommon(); + if (!token) { + throw new Error("Authentication is required to generate the summary."); + } + const path = "/api/sales-common/gen-ai/v1/summarizations/generate"; + const url = `https://eeho.fa.us2.oraclecloud.com${path}`; + const payload = { path, promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: key }; + let response; + for (let attempt = 0; attempt < 2; attempt += 1) { + const headers = { Accept: "application/json", Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; + entry = logRequestStart({ + name: "requestGenerateAiSummary", method: "POST", url, headers, + body: JSON.stringify(payload, null, 2), metadata: { optyNumber: key, attempt: attempt + 1 } + }); + response = await fetch(url, { + method: "POST", credentials: "include", headers, body: JSON.stringify(payload) + }); + logRequestSuccess(entry, response, await readResponseBodyForDebug(response)); + if (response.status !== 401 && response.status !== 403) { + break; + } + // A late failure for an old token must not discard a newly refreshed token. + if (salesCommonToken && salesCommonToken.access_token === token) { + salesCommonToken = null; + } + if (attempt === 0) { + token = await requestTokenRelaySalesCommon(); + } + } + if (!response.ok) { + throw new Error(`requestGenerateAiSummary failed with status ${response.status}.`); + } + let data; + try { + data = await response.json(); + } catch (error) { + throw new Error("requestGenerateAiSummary returned an invalid JSON response."); + } + if (!data || typeof data.summaryText !== "string" || !data.summaryText.trim()) { + throw new Error("requestGenerateAiSummary returned an empty summary."); + } + state.summaryText = data.summaryText; + state.status = "ready"; + } catch (error) { + state.status = "error"; + state.error = error.message || "Unable to generate the AI summary."; + if (entry) { + logRequestFailure(entry, state.error); + } + } finally { + // Update only visible summaries, without reopening rows or rebuilding the table. + document.querySelectorAll(".opportunities-extension-ai-summary").forEach((alert) => { + if (alert.dataset.optyNumber === key) { + alert.replaceWith(createAiSummaryAlert(key)); + } + }); + } + })(); + return state.promise; + } + + function createAiSummaryAlert(optyNumber) { + const state = aiSummariesByOptyNumber.get(optyNumber); + const alert = document.createElement("div"); + alert.className = "opportunities-extension-ai-summary"; + alert.dataset.optyNumber = optyNumber; + alert.setAttribute("role", "status"); + alert.setAttribute("aria-live", "polite"); + alert.setAttribute("aria-busy", String(!state || state.status === "loading")); + const title = document.createElement("strong"); + title.textContent = "Summary generated by AI"; + const description = document.createElement("p"); + description.textContent = !state || state.status === "loading" + ? "Generating summary..." + : state.status === "error" ? state.error : state.summaryText; + alert.classList.toggle("opportunities-extension-ai-summary-error", Boolean(state && state.status === "error")); + alert.append(title, description); + return alert; + } + function createProductsDetailRow(opportunity) { const row = document.createElement("tr"); const cell = document.createElement("td"); @@ -3127,6 +3251,7 @@ summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated"; heading.append(title, summary); panel.append(heading); + panel.append(createAiSummaryAlert(optyNumber)); if (productsResult && productsResult.error) { const error = document.createElement("p"); @@ -5245,6 +5370,51 @@ color: #312d2a; } + .opportunities-extension-ai-summary { + box-sizing: border-box; + width: 100%; + min-width: 0; + margin: 0 0 12px; + padding: 12px 16px; + border: 1px solid #dedbd7; + border-radius: 6px; + background: #ffffff; + color: #312d2a; + font-size: 13px; + line-height: 1.5; + text-align: left; + white-space: normal; + overflow-wrap: anywhere; + } + + .opportunities-extension-ai-summary strong { + display: block; + font-size: 13px; + font-weight: 600; + } + + .opportunities-extension-ai-summary p { + margin: 4px 0 0; + font: inherit; + white-space: pre-wrap; + } + + .opportunities-extension-ai-summary-error { + color: #b42318; + border-color: #d9928b; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary { + background: #292827; + color: #f6f4f2; + border-color: #5f5a55; + } + + .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-ai-summary-error { + color: #ffb4ab; + border-color: #a45c55; + } + .opportunities-extension-products-detail-heading strong { font-size: 13px; } diff --git a/tests/aiSummary.test.cjs b/tests/aiSummary.test.cjs new file mode 100644 index 0000000..cb73109 --- /dev/null +++ b/tests/aiSummary.test.cjs @@ -0,0 +1,225 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); + +class Element { + constructor(tag) { + this.tag = tag; + this.children = []; + this.dataset = {}; + this.attributes = {}; + this.listeners = {}; + this.classes = new Set(); + this.classList = { toggle: (name, on) => on ? this.classes.add(name) : this.classes.delete(name) }; + } + append(...children) { this.children.push(...children); } + setAttribute(name, value) { this.attributes[name] = value; } + addEventListener(name, callback) { this.listeners[name] = callback; } + replaceWith(element) { this.replacement = element; } + click() { this.listeners.click({ preventDefault() {}, stopPropagation() {} }); } +} + +function setup(mockSalesToken = true) { + const calls = []; + let now = Date.now(); + class TestDate extends Date { + constructor(...args) { super(...(args.length ? args : [now])); } + static now() { return now; } + } + let visibleAlerts = []; + const source = fs.readFileSync(path.join(__dirname, "../src/content.js"), "utf8"); + const marker = " if (!isAllowedPage()) {"; + assert.ok(source.includes(marker)); + const instrumented = source.replace(marker, ` + accessToken = "test-bearer-secret"; + getXsrfToken = async () => ({ token: "test-xsrf-cookie", cookieName: "XSRF-TOKEN-DYNAMIC", source: "test" }); + refreshXsrfCookie = async () => ({ refreshed: false }); + ${mockSalesToken ? 'requestTokenRelaySalesCommon = async () => "test-sales-common-secret";' : ''} + renderDebugPanel = () => {}; + renderOpportunitiesTable = () => {}; + globalThis.helpers = { + requestGenerateAiSummary, createProductsCountButton, createProductsDetailRow, + createAiSummaryAlert, aiSummariesByOptyNumber, expandedProductOptyNumbers, + opptyProductsByNumber, requestLog, formatHeaderBlock, requestTokenRelay, + getAccessToken: () => accessToken, requestTokenRelaySalesCommon + }; + return; +${marker}`); + const context = { + document: { + createElement: (tag) => new Element(tag), + querySelectorAll: () => visibleAlerts + }, + fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })), + Intl, Date: TestDate, Map, Set + }; + vm.runInNewContext(instrumented, context); + return { ...context.helpers, calls, advance: (ms) => { now += ms; }, show: (alerts) => { visibleAlerts = alerts; } }; +} + +function json(data, status = 200) { + return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } }); +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +async function run() { + const h = setup(); + const products = { items: [{ ProdGroupName: "Cloud", RevnAmount: "10000", WinProb: 30 }] }; + h.opptyProductsByNumber.set("A1", products); + h.createProductsCountButton("A1", products).click(); + const first = h.aiSummariesByOptyNumber.get("A1").promise; + await flush(); + assert.equal(h.calls.length, 1); + assert.equal(h.requestGenerateAiSummary("A1"), first); + assert.equal(h.calls.length, 1, "in-flight requests are deduplicated"); + const { url, options } = h.calls[0]; + assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/api/sales-common/gen-ai/v1/summarizations/generate"); + assert.equal(options.method, "POST"); + assert.equal(options.headers.Authorization, "Bearer test-sales-common-secret"); + assert.equal(options.headers["Content-Type"], "application/json"); + assert.deepEqual(JSON.parse(options.body), { + path: "/api/sales-common/gen-ai/v1/summarizations/generate", + promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: "A1" + }); + const loadingRow = h.createProductsDetailRow({ OptyNumber: "A1" }); + const panel = loadingRow.children[0].children[0]; + assert.equal(panel.children[1].attributes["aria-busy"], "true"); + assert.equal(panel.children[2].tag, "table", "products remain below the summary"); + assert.equal(panel.children[2].children[1].children.length, 1); + + h.createProductsCountButton("A1", products).click(); + assert.equal(h.calls.length, 1, "collapsing does not request another summary"); + assert.equal(h.expandedProductOptyNumbers.has("A1"), false); + const second = h.requestGenerateAiSummary("A2"); + await flush(); + const visible = h.createAiSummaryAlert("A2"); + h.show([visible]); + h.calls[1].resolve(json({ summaryText: "Second opportunity" })); + await second; + assert.equal(visible.replacement.children[1].textContent, "Second opportunity"); + const unsafeText = "\nLong summary"; + h.calls[0].resolve(json({ summaryText: unsafeText, lastUpdateDate: "not displayed" })); + await first; + assert.equal(h.expandedProductOptyNumbers.has("A1"), false, "late responses do not reopen rows"); + assert.equal(visible.replacement.children[1].textContent, "Second opportunity"); + const ready = h.createAiSummaryAlert("A1"); + assert.equal(ready.children[0].textContent, "Summary generated by AI"); + assert.equal(ready.children[1].textContent, unsafeText); + assert.equal(ready.children[1].children.length, 0, "AI text is not parsed as HTML"); + assert.equal(ready.children.length, 2); + assert.equal(h.requestLog[0].name, "requestGenerateAiSummary"); + assert.equal(h.requestLog[0].httpStatus, 200); + assert.equal(h.requestLog[0].status, "success"); + assert.ok(h.requestLog[0].responseBody.includes("summaryText")); + assert.ok(!h.formatHeaderBlock(h.requestLog[0].requestHeaders).includes("test-sales-common-secret")); + + h.createProductsCountButton("A1", products).click(); + await flush(); + assert.equal(h.calls.length, 3, "expanding again generates a fresh summary"); + h.createProductsCountButton("A1", products).click(); + h.createProductsCountButton("A1", products).click(); + assert.equal(h.calls.length, 3, "rapid collapse/expand reuses the pending request"); + h.calls[2].resolve(json({ summaryText: "Fresh summary" })); + await h.aiSummariesByOptyNumber.get("A1").promise; + + for (const response of [json({}, 500), new Response("login"), json({}), json({ summaryText: " " }), json({ summaryText: 123 })]) { + const failure = h.requestGenerateAiSummary("error"); + await flush(); + h.calls.at(-1).resolve(response); + await failure; + assert.equal(h.aiSummariesByOptyNumber.get("error").status, "error"); + assert.equal(h.requestLog.at(-1).status, "failed"); + assert.ok(h.requestLog.at(-1).httpStatus); + assert.ok(h.createAiSummaryAlert("error").classes.has("opportunities-extension-ai-summary-error")); + } + const networkFailure = h.requestGenerateAiSummary("error"); + await flush(); + h.calls.at(-1).reject(new Error("Network unavailable")); + await networkFailure; + assert.equal(h.requestLog.at(-1).error, "Network unavailable"); + const retry = h.requestGenerateAiSummary("error"); + await flush(); + h.calls.at(-1).resolve(json({ summaryText: "Recovered" })); + await retry; + assert.equal(h.aiSummariesByOptyNumber.get("error").status, "ready"); + + const scoped = setup(false); + const generation = scoped.requestGenerateAiSummary("SC1"); + await flush(); + assert.equal(scoped.calls.length, 1, "summary waits for its scoped token"); + assert.equal(scoped.calls[0].url, "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F"); + assert.equal(scoped.calls[0].options.method, "GET"); + assert.equal(scoped.calls[0].options.credentials, "include"); + assert.equal(scoped.calls[0].options.headers["x-xsrf-token"], "test-xsrf-cookie"); + scoped.calls[0].resolve(json({ access_token: "scoped-token", expires_in: 3600 })); + await flush(); + assert.equal(scoped.calls[1].options.headers.Authorization, "Bearer scoped-token"); + scoped.calls[1].resolve(json({ summaryText: "Scoped summary" })); + await generation; + assert.equal(scoped.requestLog[0].name, "requestTokenRelaySalesCommon"); + assert.equal(scoped.requestLog[0].httpStatus, 200); + assert.equal(scoped.getAccessToken(), "test-bearer-secret", "scoped token does not replace the general token"); + const normal = scoped.requestTokenRelay(); + await flush(); + assert.equal(scoped.calls[2].url, "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay"); + assert.equal(scoped.calls[2].options.headers["x-xsrf-token"], scoped.calls[0].options.headers["x-xsrf-token"]); + scoped.calls[2].resolve(json({ access_token: "normal-token" })); + await normal; + assert.equal(scoped.requestLog[2].name, "requestTokenRelay"); + assert.equal(await scoped.requestTokenRelaySalesCommon(), "scoped-token"); + assert.equal(scoped.calls.length, 3, "valid token is reused"); + scoped.advance(3600 * 1000); + const failedScope = scoped.requestGenerateAiSummary("SC2"); + await flush(); + scoped.calls[3].resolve(json({ error: "Unavailable" }, 500)); + await failedScope; + assert.equal(scoped.calls.length, 4, "no summary POST after scoped authentication failure"); + assert.equal(scoped.aiSummariesByOptyNumber.get("SC2").status, "error"); + assert.equal(scoped.requestLog[3].status, "failed"); + + const pendingToken = scoped.requestTokenRelaySalesCommon(); + assert.equal(scoped.requestTokenRelaySalesCommon(), pendingToken, "concurrent refresh is deduplicated"); + await flush(); + scoped.calls[4].resolve(json({ access_token: "renewed-token", expires_in: 3600 })); + await pendingToken; + assert.equal(await scoped.requestTokenRelaySalesCommon(), "renewed-token"); + assert.equal(scoped.calls.length, 5, "failed refresh can be retried and then cached"); + + const rejected = scoped.requestGenerateAiSummary("REJECTED"); + const concurrent = scoped.requestGenerateAiSummary("CONCURRENT"); + await flush(); + assert.equal(scoped.calls[5].options.headers.Authorization, "Bearer renewed-token"); + scoped.calls[5].resolve(json({}, 401)); + await flush(); + assert.equal(scoped.calls[7].options.method, "GET"); + scoped.calls[7].resolve(json({ access_token: "replacement-token", expires_in: 3600 })); + await flush(); + assert.equal(scoped.calls[8].options.headers.Authorization, "Bearer replacement-token"); + scoped.calls[8].resolve(json({ summaryText: "Recovered after refresh" })); + await rejected; + scoped.calls[6].resolve(json({}, 401)); + await flush(); + assert.equal(scoped.calls[9].options.method, "POST", "late rejection must reuse the already refreshed token"); + assert.equal(scoped.calls[9].options.headers.Authorization, "Bearer replacement-token"); + scoped.calls[9].resolve(json({ summaryText: "Concurrent recovery" })); + await concurrent; + assert.equal(scoped.calls.length, 10); + + const forbidden = scoped.requestGenerateAiSummary("FORBIDDEN"); + await flush(); + scoped.calls[10].resolve(json({}, 403)); + await flush(); + scoped.calls[11].resolve(json({ access_token: "still-forbidden", expires_in: 3600 })); + await flush(); + scoped.calls[12].resolve(json({}, 403)); + await forbidden; + assert.equal(scoped.calls.length, 13, "only one renewal and one summary retry per generation"); + assert.equal(scoped.aiSummariesByOptyNumber.get("FORBIDDEN").status, "error"); + assert.equal(scoped.requestLog.at(-1).httpStatus, 403); + assert.equal(scoped.getAccessToken(), "test-bearer-secret"); + console.log("AI summary tests passed."); +} + +run().catch((error) => { console.error(error); process.exitCode = 1; });