AI-powered summarization and next-step suggestions implemented
This commit is contained in:
184
dist/chromium/content.js
vendored
184
dist/chromium/content.js
vendored
@@ -89,6 +89,8 @@
|
|||||||
let tokenRelayRequest = null;
|
let tokenRelayRequest = null;
|
||||||
let consumerTokenServiceRequest = null;
|
let consumerTokenServiceRequest = null;
|
||||||
let accessToken = "";
|
let accessToken = "";
|
||||||
|
let salesCommonToken = null;
|
||||||
|
let salesCommonTokenRequest = null;
|
||||||
let periodRequestVersion = 0;
|
let periodRequestVersion = 0;
|
||||||
let authStatus = AUTH_STATUS.idle;
|
let authStatus = AUTH_STATUS.idle;
|
||||||
let requestLog = [];
|
let requestLog = [];
|
||||||
@@ -106,6 +108,7 @@
|
|||||||
let productsRenderQueued = false;
|
let productsRenderQueued = false;
|
||||||
let opptyProductsByNumber = new Map();
|
let opptyProductsByNumber = new Map();
|
||||||
let expandedProductOptyNumbers = new Set();
|
let expandedProductOptyNumbers = new Set();
|
||||||
|
const aiSummariesByOptyNumber = new Map();
|
||||||
let productsProgress = {
|
let productsProgress = {
|
||||||
state: "idle",
|
state: "idle",
|
||||||
completed: 0,
|
completed: 0,
|
||||||
@@ -615,7 +618,27 @@
|
|||||||
return consumerTokenServiceRequest;
|
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 refreshResult = null;
|
||||||
let refreshAttempted = false;
|
let refreshAttempted = false;
|
||||||
|
|
||||||
@@ -624,7 +647,7 @@
|
|||||||
|
|
||||||
if (!xsrfTokenSource.token) {
|
if (!xsrfTokenSource.token) {
|
||||||
if (refreshAttempted) {
|
if (refreshAttempted) {
|
||||||
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
|
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
||||||
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
|
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
|
||||||
throw new Error("XSRF token cookie not found after automatic refresh.");
|
throw new Error("XSRF token cookie not found after automatic refresh.");
|
||||||
}
|
}
|
||||||
@@ -634,10 +657,10 @@
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
|
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(TOKEN_RELAY_URL, {
|
const response = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -698,6 +721,14 @@
|
|||||||
access_token: maskToken(responseData.access_token)
|
access_token: maskToken(responseData.access_token)
|
||||||
}, null, 2)));
|
}, 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;
|
return responseData.access_token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logRequestFailure(requestEntry, error.message || "Request failed.");
|
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({
|
return logRequestStart({
|
||||||
name: "requestTokenRelay",
|
name,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: TOKEN_RELAY_URL,
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
|
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
|
||||||
},
|
},
|
||||||
@@ -3096,6 +3127,7 @@
|
|||||||
expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
||||||
} else {
|
} else {
|
||||||
expandedProductOptyNumbers.add(normalizedOptyNumber);
|
expandedProductOptyNumbers.add(normalizedOptyNumber);
|
||||||
|
requestGenerateAiSummary(normalizedOptyNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
@@ -3103,6 +3135,98 @@
|
|||||||
return button;
|
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) {
|
function createProductsDetailRow(opportunity) {
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
const cell = document.createElement("td");
|
const cell = document.createElement("td");
|
||||||
@@ -3127,6 +3251,7 @@
|
|||||||
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
|
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
|
||||||
heading.append(title, summary);
|
heading.append(title, summary);
|
||||||
panel.append(heading);
|
panel.append(heading);
|
||||||
|
panel.append(createAiSummaryAlert(optyNumber));
|
||||||
|
|
||||||
if (productsResult && productsResult.error) {
|
if (productsResult && productsResult.error) {
|
||||||
const error = document.createElement("p");
|
const error = document.createElement("p");
|
||||||
@@ -5245,6 +5370,51 @@
|
|||||||
color: #312d2a;
|
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 {
|
.opportunities-extension-products-detail-heading strong {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|||||||
184
dist/firefox/content.js
vendored
184
dist/firefox/content.js
vendored
@@ -89,6 +89,8 @@
|
|||||||
let tokenRelayRequest = null;
|
let tokenRelayRequest = null;
|
||||||
let consumerTokenServiceRequest = null;
|
let consumerTokenServiceRequest = null;
|
||||||
let accessToken = "";
|
let accessToken = "";
|
||||||
|
let salesCommonToken = null;
|
||||||
|
let salesCommonTokenRequest = null;
|
||||||
let periodRequestVersion = 0;
|
let periodRequestVersion = 0;
|
||||||
let authStatus = AUTH_STATUS.idle;
|
let authStatus = AUTH_STATUS.idle;
|
||||||
let requestLog = [];
|
let requestLog = [];
|
||||||
@@ -106,6 +108,7 @@
|
|||||||
let productsRenderQueued = false;
|
let productsRenderQueued = false;
|
||||||
let opptyProductsByNumber = new Map();
|
let opptyProductsByNumber = new Map();
|
||||||
let expandedProductOptyNumbers = new Set();
|
let expandedProductOptyNumbers = new Set();
|
||||||
|
const aiSummariesByOptyNumber = new Map();
|
||||||
let productsProgress = {
|
let productsProgress = {
|
||||||
state: "idle",
|
state: "idle",
|
||||||
completed: 0,
|
completed: 0,
|
||||||
@@ -615,7 +618,27 @@
|
|||||||
return consumerTokenServiceRequest;
|
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 refreshResult = null;
|
||||||
let refreshAttempted = false;
|
let refreshAttempted = false;
|
||||||
|
|
||||||
@@ -624,7 +647,7 @@
|
|||||||
|
|
||||||
if (!xsrfTokenSource.token) {
|
if (!xsrfTokenSource.token) {
|
||||||
if (refreshAttempted) {
|
if (refreshAttempted) {
|
||||||
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
|
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
||||||
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
|
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
|
||||||
throw new Error("XSRF token cookie not found after automatic refresh.");
|
throw new Error("XSRF token cookie not found after automatic refresh.");
|
||||||
}
|
}
|
||||||
@@ -634,10 +657,10 @@
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
|
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(TOKEN_RELAY_URL, {
|
const response = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -698,6 +721,14 @@
|
|||||||
access_token: maskToken(responseData.access_token)
|
access_token: maskToken(responseData.access_token)
|
||||||
}, null, 2)));
|
}, 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;
|
return responseData.access_token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logRequestFailure(requestEntry, error.message || "Request failed.");
|
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({
|
return logRequestStart({
|
||||||
name: "requestTokenRelay",
|
name,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: TOKEN_RELAY_URL,
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
|
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
|
||||||
},
|
},
|
||||||
@@ -3096,6 +3127,7 @@
|
|||||||
expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
||||||
} else {
|
} else {
|
||||||
expandedProductOptyNumbers.add(normalizedOptyNumber);
|
expandedProductOptyNumbers.add(normalizedOptyNumber);
|
||||||
|
requestGenerateAiSummary(normalizedOptyNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
@@ -3103,6 +3135,98 @@
|
|||||||
return button;
|
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) {
|
function createProductsDetailRow(opportunity) {
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
const cell = document.createElement("td");
|
const cell = document.createElement("td");
|
||||||
@@ -3127,6 +3251,7 @@
|
|||||||
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
|
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
|
||||||
heading.append(title, summary);
|
heading.append(title, summary);
|
||||||
panel.append(heading);
|
panel.append(heading);
|
||||||
|
panel.append(createAiSummaryAlert(optyNumber));
|
||||||
|
|
||||||
if (productsResult && productsResult.error) {
|
if (productsResult && productsResult.error) {
|
||||||
const error = document.createElement("p");
|
const error = document.createElement("p");
|
||||||
@@ -5245,6 +5370,51 @@
|
|||||||
color: #312d2a;
|
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 {
|
.opportunities-extension-products-detail-heading strong {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node scripts/build.mjs",
|
"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",
|
"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"
|
"test": "node tests/consumptionTrend.test.cjs && node tests/consumerTokenSessionRetry.test.cjs && node tests/aiSummary.test.cjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
184
src/content.js
184
src/content.js
@@ -89,6 +89,8 @@
|
|||||||
let tokenRelayRequest = null;
|
let tokenRelayRequest = null;
|
||||||
let consumerTokenServiceRequest = null;
|
let consumerTokenServiceRequest = null;
|
||||||
let accessToken = "";
|
let accessToken = "";
|
||||||
|
let salesCommonToken = null;
|
||||||
|
let salesCommonTokenRequest = null;
|
||||||
let periodRequestVersion = 0;
|
let periodRequestVersion = 0;
|
||||||
let authStatus = AUTH_STATUS.idle;
|
let authStatus = AUTH_STATUS.idle;
|
||||||
let requestLog = [];
|
let requestLog = [];
|
||||||
@@ -106,6 +108,7 @@
|
|||||||
let productsRenderQueued = false;
|
let productsRenderQueued = false;
|
||||||
let opptyProductsByNumber = new Map();
|
let opptyProductsByNumber = new Map();
|
||||||
let expandedProductOptyNumbers = new Set();
|
let expandedProductOptyNumbers = new Set();
|
||||||
|
const aiSummariesByOptyNumber = new Map();
|
||||||
let productsProgress = {
|
let productsProgress = {
|
||||||
state: "idle",
|
state: "idle",
|
||||||
completed: 0,
|
completed: 0,
|
||||||
@@ -615,7 +618,27 @@
|
|||||||
return consumerTokenServiceRequest;
|
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 refreshResult = null;
|
||||||
let refreshAttempted = false;
|
let refreshAttempted = false;
|
||||||
|
|
||||||
@@ -624,7 +647,7 @@
|
|||||||
|
|
||||||
if (!xsrfTokenSource.token) {
|
if (!xsrfTokenSource.token) {
|
||||||
if (refreshAttempted) {
|
if (refreshAttempted) {
|
||||||
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
|
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
||||||
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
|
logRequestFailure(requestEntry, "XSRF token cookie not found after automatic refresh.");
|
||||||
throw new Error("XSRF token cookie not found after automatic refresh.");
|
throw new Error("XSRF token cookie not found after automatic refresh.");
|
||||||
}
|
}
|
||||||
@@ -634,10 +657,10 @@
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult);
|
const requestEntry = createTokenRelayRequestEntry(xsrfTokenSource, refreshResult, name, url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(TOKEN_RELAY_URL, {
|
const response = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -698,6 +721,14 @@
|
|||||||
access_token: maskToken(responseData.access_token)
|
access_token: maskToken(responseData.access_token)
|
||||||
}, null, 2)));
|
}, 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;
|
return responseData.access_token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logRequestFailure(requestEntry, error.message || "Request failed.");
|
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({
|
return logRequestStart({
|
||||||
name: "requestTokenRelay",
|
name,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: TOKEN_RELAY_URL,
|
url,
|
||||||
headers: {
|
headers: {
|
||||||
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
|
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
|
||||||
},
|
},
|
||||||
@@ -3096,6 +3127,7 @@
|
|||||||
expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
expandedProductOptyNumbers.delete(normalizedOptyNumber);
|
||||||
} else {
|
} else {
|
||||||
expandedProductOptyNumbers.add(normalizedOptyNumber);
|
expandedProductOptyNumbers.add(normalizedOptyNumber);
|
||||||
|
requestGenerateAiSummary(normalizedOptyNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderOpportunitiesTable();
|
renderOpportunitiesTable();
|
||||||
@@ -3103,6 +3135,98 @@
|
|||||||
return button;
|
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) {
|
function createProductsDetailRow(opportunity) {
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
const cell = document.createElement("td");
|
const cell = document.createElement("td");
|
||||||
@@ -3127,6 +3251,7 @@
|
|||||||
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
|
summary.textContent = productsResult && productsResult.fromCache ? "Cached" : "Updated";
|
||||||
heading.append(title, summary);
|
heading.append(title, summary);
|
||||||
panel.append(heading);
|
panel.append(heading);
|
||||||
|
panel.append(createAiSummaryAlert(optyNumber));
|
||||||
|
|
||||||
if (productsResult && productsResult.error) {
|
if (productsResult && productsResult.error) {
|
||||||
const error = document.createElement("p");
|
const error = document.createElement("p");
|
||||||
@@ -5245,6 +5370,51 @@
|
|||||||
color: #312d2a;
|
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 {
|
.opportunities-extension-products-detail-heading strong {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|||||||
225
tests/aiSummary.test.cjs
Normal file
225
tests/aiSummary.test.cjs
Normal file
@@ -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 = "<img src=x onerror=alert(1)>\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("<html>login</html>"), 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; });
|
||||||
Reference in New Issue
Block a user