Updated the process for retrieving records from CRMServiceRequests

This commit is contained in:
2026-10-28 11:43:26 -03:00
parent 318459cef8
commit 3fa212e561
17 changed files with 796 additions and 328 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,7 @@ module.exports = {
"src/features/opportunities/dashboard-state.js", "src/features/opportunities/dashboard-state.js",
"src/features/debug/state.js", "src/features/debug/state.js",
"src/features/opportunities/columns.js", "src/features/opportunities/columns.js",
"src/features/opportunities/print.js",
"src/features/opportunities/badge-content.js", "src/features/opportunities/badge-content.js",
"src/content/fusion-page.js", "src/content/fusion-page.js",
"src/content/modal.js", "src/content/modal.js",

View File

@@ -296,8 +296,8 @@ function createContentApplication(overrides = {}) {
}); });
const internalSrSession = createInternalSrSession({ const internalSrSession = createInternalSrSession({
environment, environment,
query: async srId => { query: async (srId, srNumber) => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId }); const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId, srNumber });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []); debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
return result; return result;
} }
@@ -305,7 +305,7 @@ function createContentApplication(overrides = {}) {
const crmSrPopover = createCrmSrPopover({ const crmSrPopover = createCrmSrPopover({
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger), openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value), formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestDetails: srId => internalSrSession.request(srId), requestDetails: (srId, srNumber) => internalSrSession.request(srId, srNumber),
MODAL_ID: contracts.MODAL_ID, MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args), createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
@@ -364,7 +364,43 @@ function createContentApplication(overrides = {}) {
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args), requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
}); });
const opportunitiesPrint = createOpportunitiesPrint({ environment, getSnapshot: () => {
const state = opportunitiesTableState.opportunitiesTableState;
const items = opportunitiesFilters.getSortedOpportunities();
const filter = opportunitiesFilterState;
const selectedLabels = (options, selected, allLabel) => {
const matching = options.filter(option => selected.has(option.key));
if (options.length && matching.length === options.length) return allLabel;
return matching.map(option => option.label).join(", ") || "None";
};
const root = environment.document.getElementById(contracts.MODAL_ID);
const filters = [
["Reference", root?.querySelector(".opportunities-extension-form select")?.value || "-"],
["Customer", selectedLabels(opportunitiesFormatters.getCustomerOptions(), filter.selectedCustomerKeys, "All customers")],
["Owner", selectedLabels(opportunitiesFormatters.getOwnerOptions(), filter.selectedOwnerKeys, "All owners")],
["Search", filter.tableSearch || "All"],
["Status", [...filter.selectedStatuses].join(", ")],
["Stage", [...filter.selectedStages].join(", ")],
["Opportunity type", [...filter.selectedProductTypes].join(", ")]
];
for (const group of root?.querySelectorAll(".opportunities-extension-activity-filter-controls") || []) {
filters.push([group.getAttribute("aria-label").replace(/^Filter by /, ""),
[...group.querySelectorAll('[aria-pressed="true"]')].map(button => button.getAttribute("aria-label")).join("; ")]);
}
const totals = [{ label: "Total Opportunities", value: `${items.length}/${state.items.length}`, detail: "Filtered / total" }];
for (const stage of contracts.STAGE_OPTIONS) {
const stageItems = items.filter(item => item.ForecastGroup_c === stage);
const amount = stageItems.reduce((total, item) => {
const value = Number(item.PrimaryRevenue?.RevnAmount);
return total + (Number.isFinite(value) ? value : 0);
}, 0);
totals.push({ label: stage, value: opportunitiesDashboard.createStageAmountFormatter(stageItems)(amount), detail: `${stageItems.length} opportunities` });
}
return { status: state.status, filters, totals, columns,
rows: items.map(item => columns.map(column => opportunitiesFormatters.displayOpportunityValue(column, item))) };
} });
const opportunityCalendar = createOpportunityCalendar({ const opportunityCalendar = createOpportunityCalendar({
createPrintButton: () => opportunitiesPrint.createButton(),
productTypeSelection: opportunitiesFilterState.selectedProductTypes, productTypeSelection: opportunitiesFilterState.selectedProductTypes,
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(), onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
createViewIcon: view => contentIcons.createViewIcon(view), createViewIcon: view => contentIcons.createViewIcon(view),

View File

@@ -78,7 +78,18 @@
.opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort-icon { order: -1; margin-left: 0; margin-right: auto; } .opportunities-extension-modal table[data-table-layout="standard"] th[data-align="money"] .opportunities-extension-ratecard-sort-icon { order: -1; margin-left: 0; margin-right: auto; }
.opportunities-extension-modal table[data-table-layout="standard"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; } .opportunities-extension-modal table[data-table-layout="standard"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; }
/* Preserve room for Overview badges and the compact daily entry controls. */ /* Preserve room for Overview badges and the compact daily entry controls. */
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 38px; vertical-align: top; } .opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td:is([data-column="name"], [data-column="customer"]) { padding-bottom: 10px; vertical-align: top; }
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.opportunities-extension-modal table.opportunities-extension-table > tbody > tr > td[data-column] > .opportunities-extension-cell-content > .opportunities-extension-cell-actions {
position: static;
height: 24px;
flex-shrink: 0;
}
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-left: 5px; padding-right: 5px; } .opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] > :is(thead, tbody) > tr > :is(th, td) { padding-left: 5px; padding-right: 5px; }
.opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; } .opportunities-extension-modal .oe-time-grid table[data-table-layout="standard"] { table-layout: fixed; }
.opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); } .opportunities-extension-modal table[data-table-layout="standard"] > tbody > tr > th[scope="row"] { padding: 10px 12px; text-align: left; background: var(--oe-table-heading); border-bottom: 1px solid var(--oe-table-border); }

View File

@@ -27,7 +27,7 @@ function createDebugContentView({
if (entry.name === "requestOpptyProducts") return "products"; if (entry.name === "requestOpptyProducts") return "products";
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions"; if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
if (entry.name === "requestInstallBase") return "installBase"; if (entry.name === "requestInstallBase") return "installBase";
if (["requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests"; if (["requestSRSearchResources", "requestSRInfo", "requestLookupTypePlatform", "requestInternalSrServices", "requestPostInternalServiceRequest"].includes(entry.name)) return "serviceRequests";
if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests"; if (["requestCustomerServiceRequest", "resquestCrmServiceRequest", "requestInternalServiceRequest", "requestSrLookupPrimaryPillar", "requestSrLookupLanguage", "requestSrLookupTechPlatform", "requestSrLookupOpportunityBusiness", "requestResourceUser", "requestSrCatalog", "requestSrTokenCatalog", "requestPostServiceRequest"].includes(entry.name)) return "serviceRequests";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption"; if (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other"; return "other";

View File

@@ -1,7 +1,7 @@
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange, function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon, formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon, createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
productTypeSelection, onProductTypeChange }) { productTypeSelection, onProductTypeChange, createPrintButton }) {
const { document } = environment; const { document } = environment;
let elements = null; let elements = null;
let calendar = null; let calendar = null;
@@ -223,6 +223,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
}); });
filters.append(button); filters.append(button);
} }
if (createPrintButton) filters.append(createPrintButton());
viewbar.append(filters); viewbar.append(filters);
} }
workspace.append(viewbar, tableSurface, surface); body.append(workspace); workspace.append(viewbar, tableSurface, surface); body.append(workspace);

View File

@@ -124,9 +124,13 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
children.setAttribute("role", "status"); children.setAttribute("role", "status");
children.textContent = "Loading SR details..."; children.textContent = "Loading SR details...";
section.append(children); section.append(children);
detailLoads.push(async () => { const loadDetails = async () => {
if (active !== entry || entry.panel !== panel) return;
children.textContent = "Loading SR details...";
children.setAttribute("aria-busy", "true");
children.setAttribute("role", "status");
try { try {
const records = await requestDetails(item.SrId); const records = await requestDetails(item.SrId, item.SrNumber);
if (active !== entry || entry.panel !== panel) return; if (active !== entry || entry.panel !== panel) return;
children.textContent = ""; children.textContent = "";
if (!records.length) children.textContent = "No further details."; if (!records.length) children.textContent = "No further details.";
@@ -157,13 +161,20 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, reque
if (active !== entry || entry.panel !== panel) return; if (active !== entry || entry.panel !== panel) return;
children.setAttribute("role", "alert"); children.setAttribute("role", "alert");
children.textContent = error.message || "Unable to load SR details."; children.textContent = error.message || "Unable to load SR details.";
const retry = document.createElement("button");
retry.type = "button";
retry.textContent = "Retry";
retry.setAttribute("aria-label", "Retry loading Internal Service Requests");
retry.addEventListener("click", () => { retry.disabled = true; loadDetails(); });
children.append(retry);
} finally { } finally {
if (active === entry && entry.panel === panel) { if (active === entry && entry.panel === panel) {
children.setAttribute("aria-busy", "false"); children.setAttribute("aria-busy", "false");
entry.position(); entry.position();
} }
} }
}); };
detailLoads.push(loadDetails);
panel.append(section); panel.append(section);
}); });
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer)); panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));

View File

@@ -1,16 +1,15 @@
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) { function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
const { document, setTimeout } = environment; const pending = new Map();
let recovery = null; let recovery = null;
const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
const now = () => environment.performance?.now() ?? Date.now();
function isUnauthorized(result) { function isUnauthorized(result) {
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus; const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
if (status != null) return Number(status) === 401; return status != null ? Number(status) === 401 : /failed with status 401\.$/.test(result?.error || "");
// Older background workers may return only the request error text.
return /failed with status 401\.$/.test(result?.error || "");
} }
async function warmSession(id) { async function warmSession(id, srNumber) {
const document = environment?.document;
if (!document?.body) throw new Error(`${sessionLabel} session cannot be initialized on this page.`);
const frame = document.createElement("iframe"); const frame = document.createElement("iframe");
// Restore the original session initialization route without opening a tab.
frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr"; frame.src = sessionUrl || "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR0001421040&srId=302963804324543&view=showisr";
frame.title = `${sessionLabel} session initialization`; frame.title = `${sessionLabel} session initialization`;
frame.tabIndex = -1; frame.tabIndex = -1;
@@ -18,43 +17,46 @@ function createInternalSrSession({ query, environment, sessionUrl, sessionLabel
frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms"); frame.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms");
frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;"; frame.style.cssText = "position:fixed;width:1px;height:1px;left:-10000px;top:0;border:0;opacity:0;pointer-events:none;";
let blocked = false; let blocked = false;
let loaded = false; const failed = () => { blocked = true; };
frame.addEventListener("error", () => { blocked = true; }); frame.addEventListener("error", failed);
frame.addEventListener("load", () => { loaded = true; });
try { try {
if (!document.body) throw new Error(`The current page cannot initialize the ${sessionLabel} session.`);
document.body.append(frame); document.body.append(frame);
const deadline = now() + 60000; // Keep the iframe alive while its application completes authentication.
for (let attempt = 0; now() < deadline; attempt += 1) { // A load event is not proof of a ready session. Limit and space API checks.
// Check immediately, then briefly poll faster before backing off. for (const delay of [2000, 4000, 8000, 12000, 16000]) {
if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now())); await new Promise(resolve => environment.setTimeout(resolve, delay));
if (now() >= deadline) break; if (blocked) throw new Error(`${sessionLabel} session iframe could not be loaded. Retry after signing in.`);
if (blocked) throw new Error(`The ${sessionLabel} session iframe could not be loaded. Sign in manually and try again.`); const result = await query(id, srNumber);
const result = await query(id); if (result?.ok) return { id: String(id), result };
if (result?.ok) return { id, result };
if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`); if (!isUnauthorized(result)) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
} }
throw new Error(`${sessionLabel} session timed out after 60 seconds: the API still returned HTTP 401. ${loaded ? "The iframe emitted a load event, but authentication was not confirmed." : "The iframe did not emit a load event."} Sign in manually and try again.`); throw new Error(`${sessionLabel} session is still unavailable. Retry loading the details after signing in.`);
} finally { } finally {
// Stop remaining page resources without stopping the caller's page. frame.removeEventListener("error", failed);
try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ } try { frame.contentWindow?.stop(); } catch { /* SSO may redirect cross-origin. */ }
frame.src = "about:blank";
frame.remove(); frame.remove();
} }
} }
async function request(id) { function request(id, srNumber) {
let result = await query(id); const key = String(id);
for (let attempt = 0; !result?.ok && isUnauthorized(result) && attempt < 2; attempt += 1) { if (!pending.has(key)) {
const operation = (async () => {
let result = await query(id, srNumber);
if (!result?.ok && isUnauthorized(result)) {
if (!recovery) { if (!recovery) {
recovery = warmSession(id); const current = warmSession(id, srNumber);
const pending = recovery; recovery = current;
pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {}); current.finally(() => { if (recovery === current) recovery = null; }).catch(() => {});
} }
const recovered = await recovery; const recovered = await recovery;
result = recovered.id === id ? recovered.result : await query(id); result = recovered.id === key ? recovered.result : await query(id, srNumber);
} }
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`); if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
return result.items; return result.items;
})().finally(() => pending.delete(key));
pending.set(key, operation);
}
return pending.get(key);
} }
return { request }; return { request };
} }

View File

@@ -0,0 +1,100 @@
function createOpportunitiesPrint({ environment, getSnapshot }) {
function createButton() {
const doc = environment.document;
const button = doc.createElement("button");
button.type = "button";
button.className = "opportunities-extension-icon-button";
button.title = "Print opportunities";
button.setAttribute("aria-label", "Print opportunities");
const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.6");
svg.setAttribute("aria-hidden", "true");
const path = doc.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M6 9V3h12v6M6 17H3V9h18v8h-3M6 14h12v7H6zM17 11h1");
svg.append(path); button.append(svg);
button.addEventListener("click", print);
return button;
}
function print() {
const snapshot = getSnapshot();
if (!["ready", "empty"].includes(snapshot.status)) {
environment.window.alert("Wait for opportunities to finish loading before printing.");
return;
}
const popup = environment.window.open("", "_blank", "width=1200,height=850");
if (!popup) { environment.window.alert("Allow pop-ups to print opportunities."); return; }
popup.opener = null;
const doc = popup.document;
doc.title = "Opportunities Extension — Report";
doc.documentElement.lang = "en";
const node = (tag, text, parent) => {
const el = doc.createElement(tag);
if (text != null) el.textContent = String(text);
if (parent) parent.append(el);
return el;
};
node("style", `
@page { size: A4 landscape; margin: 15mm; }
* { box-sizing: border-box; }
body { margin: 32px; font: 11px/1.4 Arial, sans-serif; color: #222; background: white; }
h1 { margin: 0 0 4px; font-size: 21px; } h2 { font-size: 13px; margin: 16px 0 6px; }
.meta { color: #555; margin: 0 0 12px; }
.filters { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px 16px; border: 1px solid #ccc; padding: 10px; }
.filters div { overflow-wrap: anywhere; } .filters strong { margin-right: 5px; }
.totals { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 12px 0; }
.totals section { border: 1px solid #bbb; border-top: 3px solid #426a75; padding: 8px; break-inside: avoid; }
.totals strong { display: block; font-size: 16px; } .totals small { display: block; }
table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 9px; }
th, td { padding: 6px 4px; border-bottom: 1px solid #ccc; text-align: left; vertical-align: top; overflow-wrap: anywhere; }
th { background: #eee; font-weight: bold; } tbody tr:nth-child(even) { background: #f7f7f7; }
thead { display: table-header-group; } tr { break-inside: avoid; }
th:nth-child(1), th:nth-child(3) { width: 22%; }
th:nth-child(2) { width: 7%; } th:nth-child(4) { width: 8%; }
th:nth-child(5) { width: 9%; } th:nth-child(6) { width: 7%; }
th:nth-child(7), th:nth-child(8) { width: 5%; } th:nth-child(9) { width: 7%; } th:nth-child(10) { width: 8%; }
.money { text-align: right; font-variant-numeric: tabular-nums; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
button { padding: 8px 14px; cursor: pointer; }
@media print { body { margin: 0; } .toolbar { display: none; } th { print-color-adjust: exact; } }
`, doc.head);
const toolbar = node("div", null, doc.body); toolbar.className = "toolbar";
node("button", "Print / Save PDF", toolbar).addEventListener("click", () => popup.print());
node("button", "Close", toolbar).addEventListener("click", () => popup.close());
node("h1", "Opportunities Extension", doc.body);
node("p", `Generated ${new Date().toLocaleString("en-US")} • ${snapshot.rows.length} opportunities`, doc.body).className = "meta";
const filters = node("div", null, doc.body); filters.className = "filters";
for (const [label, value] of snapshot.filters) {
const field = node("div", null, filters);
node("strong", `${label}:`, field); node("span", value || "None", field);
}
const totals = node("div", null, doc.body); totals.className = "totals";
for (const card of snapshot.totals) {
const section = node("section", null, totals);
node("span", card.label, section); node("strong", card.value, section);
if (card.detail) node("small", card.detail, section);
}
node("h2", "Opportunities", doc.body);
const table = node("table", null, doc.body);
const header = node("tr", null, node("thead", null, table));
for (const column of snapshot.columns) {
const th = node("th", column.label, header); th.scope = "col";
if (column.key === "revenue") th.className = "money";
}
const body = node("tbody", null, table);
for (const values of snapshot.rows) {
const row = node("tr", null, body);
values.forEach((value, index) => {
const td = node("td", value, row);
if (snapshot.columns[index].key === "revenue") td.className = "money";
});
}
if (!snapshot.rows.length) node("p", "No opportunities match the selected filters.", doc.body);
popup.focus();
// Print a frozen snapshot, without changing the extension's scroll or selected view.
environment.setTimeout(() => { if (!popup.closed) popup.print(); }, 150);
}
return { createButton, print };
}

View File

@@ -2,10 +2,27 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
logRequestStart, logRequestSuccess, logRequestFailure, readResponseBodyForDebug, maskToken, environment }) { logRequestStart, logRequestSuccess, logRequestFailure, readResponseBodyForDebug, maskToken, environment }) {
const { fetch } = environment; const { fetch } = environment;
const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries"; const url = "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries";
function createPayload(offset) { async function requestSRSearchResources(token) {
const searchUrl = `${url}?applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=CRMServiceRequest&language=en&limit=-1&onlyData=true`;
const headers = { Accept: "application/json", Authorization: `Bearer ${token}` };
const entry = logRequestStart({ name: "requestSRSearchResources", method: "GET", url: searchUrl, headers });
try {
const response = await fetch(searchUrl, { method: "GET", credentials: "include", headers });
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (response.status !== 200) throw new Error(`requestSRSearchResources failed with status ${response.status}.`);
const data = await response.json();
const number = data?.items?.[0]?.savedSearchNumber;
if (!Array.isArray(data?.items) || typeof number !== "string" || !number.trim()) {
throw new Error("requestSRSearchResources did not return savedSearchNumber in its first item.");
}
return number.trim();
} catch (error) { logRequestFailure(entry, error.message); throw error; }
}
function createPayload(offset, savedSearchNumber) {
if (typeof savedSearchNumber !== "string" || !savedSearchNumber.trim()) throw new Error("Missing SR savedSearchNumber.");
return { return {
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true,
entity: "CRMServiceRequest", copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205", entity: "CRMServiceRequest", copiedFrom: `queries/${savedSearchNumber.trim()}`,
q: { op: "$and", criteria: [ q: { op: "$and", criteria: [
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] }, { op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" } { op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
@@ -19,11 +36,13 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
}; };
} }
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) { async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
if (!isActive()) return null;
const savedSearchNumber = await requestSRSearchResources(token);
const items = []; const items = [];
let offset = 0; let offset = 0;
let attempt = 1; let attempt = 1;
while (isActive()) { while (isActive()) {
const payload = createPayload(offset); const payload = createPayload(offset, savedSearchNumber);
const headers = { const headers = {
Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`,
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient" Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
@@ -60,5 +79,5 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
} }
return null; return null;
} }
return { createPayload, requestCrmServiceRequest }; return { createPayload, requestCrmServiceRequest, requestSRSearchResources };
} }

View File

@@ -4,7 +4,8 @@ const { loadApplication } = require("./support/application.cjs");
const { response } = require("./support/characterization.cjs"); const { response } = require("./support/characterization.cjs");
function setup(fetch) { function setup(fetch) {
const app = loadApplication("content", { fetch }); const app = loadApplication("content", { fetch: (url, options) => options.method === "GET"
? response({ items: [{ savedSearchNumber: "dynamic-sr-search" }] }) : fetch(url, options) });
app.currentUser.allowsCrmActivities = () => true; app.currentUser.allowsCrmActivities = () => true;
app.opportunitiesQueryState.periodRequestVersion = 1; app.opportunitiesQueryState.periodRequestVersion = 1;
app.debugContentView.renderDebugPanel = () => {}; app.debugContentView.renderDebugPanel = () => {};

View File

@@ -2,10 +2,10 @@ const assert = require("node:assert/strict");
const { test } = require("node:test"); const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs"); const { loadApplication } = require("./support/application.cjs");
const response = data => new Response(JSON.stringify(data), { status: 200 }); const response = data => new Response(JSON.stringify(data), { status: 200 });
function setup(fetch) { function setup(fetch, searchFetch = async () => response({ items: [{ savedSearchNumber: "dynamic-sr-search" }] })) {
const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) }; const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
document.createElementNS = (_, tag) => document.createElement(tag); document.createElementNS = (_, tag) => document.createElement(tag);
const app = loadApplication("content", { fetch, document, Intl, Date }); const app = loadApplication("content", { fetch: (url, options) => options.method === "GET" ? searchFetch(url, options) : fetch(url, options), document, Intl, Date });
app.currentUser.allowsCrmActivities = () => true; app.currentUser.allowsCrmActivities = () => true;
app.debugContentView.renderDebugPanel = () => {}; app.debugContentView.renderDebugPanel = () => {};
app.opportunitiesQueryState.periodRequestVersion = 1; app.opportunitiesQueryState.periodRequestVersion = 1;
@@ -15,6 +15,42 @@ const sr = (number, id = "SR-1") => ({ SrNumber: id, Title: "<script>synthetic</
OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner", OpportunityName_Id_c: { OptyNumber: number }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner",
PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false }); PrimaryEmail: { EmailAddress: "test@example.invalid" } }, DoNotCallFlag: false });
test("SR saved search runs once before pages and uses the first item, not the default", async () => {
const calls = [];
const app = setup(async (_url, options) => {
const payload = JSON.parse(options.body); calls.push(payload);
assert.equal(payload.copiedFrom, "queries/first-search");
return response({ items: [sr("A")], count: 1, offset: payload.offset, hasMore: payload.offset === 0 });
}, async (url, options) => {
assert.equal(options.method, "GET");
assert.equal(options.headers.Authorization, "Bearer synthetic");
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries?applications=461e5791-9f15-4cd1-b5cd-19e8480cbce4&entity=CRMServiceRequest&language=en&limit=-1&onlyData=true");
calls.push("lookup");
return response({ items: [{ savedSearchNumber: "first-search" }, { savedSearchNumber: "other", isDefault: true }] });
});
await app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1);
assert.equal(calls.length, 3); assert.equal(calls[0], "lookup");
assert.equal(app.debugContentView.getRequestCategory(app.debugState.requestLog[0]), "serviceRequests");
});
test("failed or empty SR search blocks POST; superseded search never starts pagination", async () => {
for (const lookup of [
async () => new Response("Denied", { status: 401 }),
async () => response({ items: [] }),
async () => response({ items: [{ savedSearchNumber: " " }] }),
async () => new Response("<html>Login</html>")
]) {
const app = setup(() => assert.fail("No POST without a saved search"), lookup);
await assert.rejects(app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1));
assert.equal(app.debugState.requestLog[0].status, "failed");
}
const app = setup(() => assert.fail("Stale POST"), async () => {
app.opportunitiesQueryState.periodRequestVersion = 2;
return response({ items: [{ savedSearchNumber: "first" }] });
});
assert.equal(await app.fusionCrmServiceRequests.requestCrmServiceRequest("synthetic", 1), null);
});
test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => { test("SR badge shows reported hours, sums linked SRs and preserves missing values", async () => {
for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/A"], [[4, null], "N/A"], [[""], "N/A"], [["invalid"], "N/A"]]) { for (const [values, expected] of [[[4], "4h"], [[0], "0h"], [[2, "1.5"], "3.5h"], [[0.1, 0.2], "0.3h"], [[null], "N/A"], [[4, null], "N/A"], [[""], "N/A"], [["invalid"], "N/A"]]) {
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false })); const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
@@ -79,7 +115,7 @@ test("CRM query preserves payload and pagination after opportunities, uses token
assert.equal(options.headers.Preference, "transient"); assert.equal(options.headers.Preference, "transient");
assert.deepEqual(payload, { assert.deepEqual(payload, {
aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "CRMServiceRequest", aggregationResults: false, applications: ["ORACLE-ISS-APP"], onlyData: true, entity: "CRMServiceRequest",
copiedFrom: "queries/c521309c-1310-49a0-9723-da2eb1988205", copiedFrom: "queries/dynamic-sr-search",
q: { op: "$and", criteria: [ q: { op: "$and", criteria: [
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] }, { op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" } { op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
@@ -90,8 +126,9 @@ test("CRM query preserves payload and pagination after opportunities, uses token
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"], keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"],
skipInValidFields: true, limit: 15, offset: 0 skipInValidFields: true, limit: 15, offset: 0
}); });
assert.equal(app.debugState.requestLog.length, 2); assert.equal(app.debugState.requestLog.length, 3);
for (const log of app.debugState.requestLog) { assert.equal(app.debugState.requestLog[0].name, "requestSRSearchResources");
for (const log of app.debugState.requestLog.slice(1)) {
assert.equal(log.name, "resquestCrmServiceRequest"); assert.equal(log.name, "resquestCrmServiceRequest");
assert.equal(log.requestHeaders.Origin, "https://eeho.fa.us2.oraclecloud.com"); assert.equal(log.requestHeaders.Origin, "https://eeho.fa.us2.oraclecloud.com");
assert.equal(log.requestHeaders.Preference, "transient"); assert.equal(log.requestHeaders.Preference, "transient");
@@ -144,6 +181,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
let resolveOld; let resolveOld;
const app = setup(() => new Promise(resolve => { resolveOld = resolve; })); const app = setup(() => new Promise(resolve => { resolveOld = resolve; }));
const old = app.opportunityCrmServiceRequests.load("token", 1); const old = app.opportunityCrmServiceRequests.load("token", 1);
while (!resolveOld) await new Promise(resolve => setImmediate(resolve));
app.opportunitiesQueryState.periodRequestVersion = 2; app.opportunitiesQueryState.periodRequestVersion = 2;
app.opportunityCrmServiceRequests.reset(); app.opportunityCrmServiceRequests.reset();
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [sr("NEW")]; app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [sr("NEW")];
@@ -152,7 +190,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
await old; await old;
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h"); assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h"); assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
assert.equal(app.debugState.requestLog.length, 1); assert.equal(app.debugState.requestLog.length, 2);
}); });
test("Reference changes share pending pagination and reuse completed CRM results", async () => { test("Reference changes share pending pagination and reuse completed CRM results", async () => {
@@ -167,6 +205,7 @@ test("Reference changes share pending pagination and reuse completed CRM results
app.opportunitiesQueryState.periodRequestVersion = 2; app.opportunitiesQueryState.periodRequestVersion = 2;
const second = app.opportunityCrmServiceRequests.load("token", 2); const second = app.opportunityCrmServiceRequests.load("token", 2);
assert.equal(first, second); assert.equal(first, second);
while (!finish) await new Promise(resolve => setImmediate(resolve));
finish(response({ items: [sr("A")], count: 1, offset: 0, hasMore: true })); finish(response({ items: [sr("A")], count: 1, offset: 0, hasMore: true }));
await second; await second;
app.opportunitiesQueryState.periodRequestVersion = 3; app.opportunitiesQueryState.periodRequestVersion = 3;

View File

@@ -250,44 +250,6 @@ test("Primary pillar lookup uses Session, orders enabled name/code options and r
assert.equal((await loadBackground({ fetch, messageTimeout: 10000 }).dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" })).ok, false); assert.equal((await loadBackground({ fetch, messageTimeout: 10000 }).dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" })).ok, false);
} }
}); });
for (const family of ["chrome", "firefox"]) {
test(`${family}: 401 is returned to the caller without opening tabs`, async () => {
let count = 0;
const h = loadBackground({ family, fetch: async () => ++count < 3 ? response({}, 401) : response({ items: [] }) });
const result = await h.dispatch({ type, srId: "123" });
assert.equal(result.ok, false);
assert.equal(result.httpStatus, 401);
assert.equal(count, 1);
assert.equal(h.tabs.length, 0);
assert.deepEqual(h.removedTabs, []);
assert.deepEqual(result.debugRequests.map(entry => entry.httpStatus), [401]);
});
}
test("concurrent 401 responses do not cause background tab recovery", async () => {
let calls = 0;
const h = loadBackground({ fetch: async url => {
if (++calls <= 2) return response({}, 401);
const id = new URL(url).searchParams.get("q");
return response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: [{ Title: id }] }] });
} });
const results = await Promise.all([h.dispatch({ type, srId: "1" }), h.dispatch({ type, srId: "2" })]);
assert.equal(h.tabs.length, 0);
assert.deepEqual(h.removedTabs, []);
assert.ok(results.every(result => result.httpStatus === 401));
});
test("background never creates recovery tabs after an error", async () => {
for (const status of [401, 500]) {
let count = 0;
const h = loadBackground({ fetch: async () => response({}, ++count === 1 ? 401 : status) });
const result = await h.dispatch({ type, srId: "1" });
assert.equal(result.ok, false);
assert.equal(count, 1);
assert.deepEqual(h.removedTabs, []);
}
const h = loadBackground({ fetch: async () => response({}, 403) });
assert.equal((await h.dispatch({ type, srId: "1" })).ok, false);
assert.equal(h.tabs.length, 0);
});
for (const family of ["chrome", "firefox"]) { for (const family of ["chrome", "firefox"]) {
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => { test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
const calls = []; const calls = [];

View File

@@ -84,7 +84,7 @@ test("internal SR form validates Save, maps parent data, prevents duplicate post
assert.ok(!h.walk(h.modal).includes(form)); assert.ok(!h.walk(h.modal).includes(form));
}); });
test("internal SR uses Session without TokenRelay and reports unavailable iframe host", async () => { test("internal SR uses Session and offers manual retry after 401 without automatic probes", async () => {
const h = setup(); const h = setup();
let renewals = 0; let renewals = 0;
const sent = []; const sent = [];
@@ -106,6 +106,11 @@ test("internal SR uses Session without TokenRelay and reports unavailable iframe
assert.equal(sent[0].token, undefined); assert.equal(sent[0].token, undefined);
assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest")); assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest"));
assert.ok(h.walk(h.panel()).some(node => node.attributes.role === "alert")); assert.ok(h.walk(h.panel()).some(node => node.attributes.role === "alert"));
const retry = h.walk(h.panel()).find(node => node.attributes["aria-label"] === "Retry loading Internal Service Requests");
assert.ok(retry);
retry.listeners.click();
await flush();
assert.equal(sent.length, 2);
}); });
function setup() { function setup() {

View File

@@ -0,0 +1,14 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const fs = require('node:fs');
const vm = require('node:vm');
const factory = vm.runInNewContext(fs.readFileSync('src/services/fusion/sr-details.js', 'utf8') + '\ncreateFusionSrDetails');
test('Internal SR background returns 401 without opening any tab or retrying', async () => {
let calls = 0;
const api = factory({
fetchJson: async () => { calls++; throw Object.assign(new Error('Unauthorized'), {httpStatus:401}); },
tabsCreate: () => assert.fail('Internal SR must not open a tab')
});
await assert.rejects(api.requestInternalServiceRequest({srId:'123'}), /Unauthorized/);
assert.equal(calls,1);
});

View File

@@ -1,145 +1,69 @@
const assert = require("node:assert/strict"); const assert = require('node:assert/strict');
const { test } = require("node:test"); const { test } = require('node:test');
const fs = require("node:fs"); const fs = require('node:fs');
const vm = require("node:vm"); const vm = require('node:vm');
const path = require("node:path"); const factory = vm.runInNewContext(fs.readFileSync('src/features/opportunities/internal-sr-session.js', 'utf8') + '\ncreateInternalSrSession');
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/internal-sr-session.js"), "utf8")}\ncreateInternalSrSession`); test('without a document, session recovery stops without polling', async () => {
function setup(query, options = {}) { for (const result of [{ok:false,httpStatus:401}, {ok:false,debugRequests:[{httpStatus:401}]}, {ok:false,error:'Request failed with status 401.'}]) {
let elapsed = 0; let calls=0;
const frames = [], removed = [], delays = [], stopped = []; const service=factory({query:async()=>{calls++; return result;},environment:{document:{createElement(){assert.fail('No iframe');}},setTimeout(){assert.fail('No polling');}}});
const body = { append(frame) { frame.initialSrc = frame.src; frames.push(frame); } }; await assert.rejects(service.request('123'), /Internal SR|status 401/);
const document = { body, createElement(tag) { await new Promise(resolve=>setImmediate(resolve));
assert.equal(tag, "iframe"); assert.equal(calls,1);
return { contentWindow: { stop() { stopped.push(true); } }, style: {}, attributes: {}, listeners: {}, setAttribute(k, v) { this.attributes[k] = v; }, }
addEventListener(k, fn) { this.listeners[k] = fn; }, remove() { removed.push(this); } }; });
function harness() {
const timers = [], listeners = new Map();
let appended = 0, removed = 0;
const frame = {style:{},setAttribute(){},addEventListener(k,v){listeners.set(k,v);},removeEventListener(k){listeners.delete(k);},remove(){removed++;}};
return { timers, frame, listeners, counts:()=>({appended,removed}), environment:{
document:{createElement:()=>frame,body:{append(){appended++;}}},
setTimeout(fn,delay){timers.push({fn,delay});}
}}; }};
const api = factory({ ...options, query, environment: { document, performance: { now: () => elapsed }, setTimeout: (fn, ms) => { delays.push(ms); elapsed += ms; return setImmediate(fn); } } });
return { api, frames, removed, delays, stopped };
} }
const flush = () => new Promise(resolve=>setImmediate(resolve));
test("lookup requests recover together using the configured page and preserve each result", async () => { test('hidden iframe remains alive after load and closes only after successful session query',async()=>{
let authenticated = false; const h=harness(); let calls=0;
let calls = 0; const api=factory({environment:h.environment,query:async()=>++calls<3?{ok:false,httpStatus:401}:{ok:true,items:[{SrId:1}]}});
const h = setup(async message => { const first=api.request('123'); assert.equal(first,api.request(123));
if (++calls <= 4) return { ok: false, httpStatus: 401 }; await flush();
authenticated = true; assert.equal(h.counts().appended,1);
return { ok: true, items: [message.type] }; h.listeners.get('load')?.();
}, { sessionUrl: "https://eeho.fa.us2.oraclecloud.com/?view=Show%20Service%20Request", sessionLabel: "Service Request" }); assert.equal(h.counts().removed,0);
const types = ["pillar", "language", "platform", "services"]; assert.equal(calls,1);
const results = await Promise.all(types.map(type => h.api.request({ type }))); assert.equal(h.timers[0].delay,2000);
assert.deepEqual(results, types.map(type => [type])); h.timers.shift().fn(); await flush();
assert.equal(authenticated, true); assert.equal(h.counts().removed,0);
assert.equal(h.frames.length, 1); assert.equal(h.timers[0].delay,4000);
assert.match(h.frames[0].initialSrc, /Show%20Service%20Request$/); h.timers.shift().fn();
assert.equal(h.frames[0].title, "Service Request session initialization"); assert.equal((await first)[0].SrId,1);
assert.equal(h.removed.length, 1); assert.equal(h.counts().removed,1);
assert.equal(h.stopped.length, 1);
}); });
test("401 initializes a hidden iframe in the calling document and removes it on success", async () => { test('persistent 401 is bounded to five recovery checks and removes iframe',async()=>{
let calls = 0; const h=harness(); let calls=0;
const h = setup(async () => ++calls < 3 ? { ok: false, httpStatus: 401 } : { ok: true, items: [{ SrId: 1 }] }); const api=factory({environment:h.environment,query:async()=>{calls++;return {ok:false,httpStatus:401};}});
const items = await h.api.request("1"); const failed=assert.rejects(api.request('123'),/still unavailable/);
assert.equal(items[0].SrId, 1); for(const delay of [2000,4000,8000,12000,16000]) {
assert.equal(h.frames.length, 1); await flush(); assert.equal(h.timers[0].delay,delay); h.timers.shift().fn();
const frame = h.frames[0];
assert.equal(frame.tabIndex, -1);
assert.equal(frame.attributes["aria-hidden"], "true");
assert.match(frame.style.cssText, /pointer-events:none/);
assert.ok(!frame.attributes.sandbox.includes("allow-top-navigation"));
assert.ok(frame.initialSrc.endsWith("&view=showisr"));
assert.equal(frame.src, "about:blank");
assert.equal(h.stopped.length, 1);
assert.deepEqual(h.delays, [250]);
assert.equal(h.removed[0], frame);
});
test("an immediately available session stops the iframe without waiting for load or a timer", async () => {
let calls = 0;
const h = setup(async () => ++calls === 1 ? { ok: false, httpStatus: 401 } : { ok: true, items: [] });
await h.api.request("1");
assert.equal(calls, 2);
assert.equal(h.delays.length, 0);
assert.equal(h.stopped.length, 1);
assert.equal(h.removed.length, 1);
});
test("lookup 401 errors from older background responses still initialize the session", async () => {
for (const failure of [
{ ok: false, error: "requestSrLookupLanguage failed with status 401." },
{ ok: false, debugRequests: [{ httpStatus: 401 }] },
{ ok: false, httpStatus: "401" }
]) {
let calls = 0;
const h = setup(async () => ++calls <= 2 ? failure : { ok: true, items: ["ready"] });
assert.deepEqual(await h.api.request("language"), ["ready"]);
assert.equal(h.frames.length, 1);
assert.equal(h.removed.length, 1);
} }
await failed;
assert.equal(calls,6);
assert.equal(h.counts().removed,1);
assert.equal(h.timers.length,0);
}); });
test("a lookup that still returns 401 after shared recovery initializes its own session", async () => { test('concurrent requests share work and explicit retry can recover', async () => {
const calls = new Map(); let calls=0, complete;
const h = setup(async id => { const service=factory({query:()=>{calls++; return new Promise(resolve=>{complete=resolve;});}});
const count = (calls.get(id) || 0) + 1; const first=service.request('123'); const second=service.request(123);
calls.set(id, count); assert.equal(first,second); await Promise.resolve();
return count <= (id === "language" ? 1 : 2) complete({ok:false,httpStatus:401}); await assert.rejects(first);
? { ok: false, httpStatus: 401 } : { ok: true, items: [id] }; const retry=service.request('123'); await Promise.resolve(); complete({ok:true,items:[{SrId:'456'}]});
assert.equal((await retry)[0].SrId,'456'); assert.equal(calls,2);
}); });
assert.deepEqual(await Promise.all([h.api.request("language"), h.api.request("platform")]), [["language"], ["platform"]]); test('other errors do not retry and successful empty results are preserved', async()=>{
assert.equal(h.frames.length, 2); const failed=factory({query:async()=>({ok:false,error:'Forbidden',httpStatus:403})});
assert.equal(h.removed.length, 2); await assert.rejects(failed.request('123'),/Forbidden/);
}); const ok=factory({query:async()=>({ok:true,items:[]})}); assert.equal((await ok.request('123')).length,0);
test("cross-origin redirects still allow iframe removal when stop is blocked", async () => {
let calls = 0;
const h = setup(async () => {
if (++calls === 1) return { ok: false, httpStatus: 401 };
h.frames[0].contentWindow.stop = () => { throw new Error("Cross-origin"); };
return { ok: true, items: [] };
});
await h.api.request("1");
assert.equal(h.frames[0].src, "about:blank");
assert.equal(h.removed.length, 1);
});
test("concurrent queries share one iframe without mixing SR results", async () => {
let calls = 0;
const h = setup(async id => ++calls <= 2 ? { ok: false, httpStatus: 401 } : { ok: true, items: [id] });
const results = await Promise.all([h.api.request("1"), h.api.request("2")]);
assert.deepEqual(results, [["1"], ["2"]]);
assert.equal(h.frames.length, 1);
assert.equal(h.removed.length, 1);
});
test("normal success does not mount an iframe; errors and timeout always clean it up", async () => {
const success = setup(async () => ({ ok: true, items: [] }));
await success.api.request("1");
assert.equal(success.frames.length, 0);
for (const status of [401, 500]) {
let calls = 0;
const h = setup(async () => ({ ok: false, httpStatus: ++calls === 1 ? 401 : status }));
await assert.rejects(h.api.request("1"));
if (status === 401) {
assert.ok(calls > 31);
assert.equal(h.delays.reduce((sum, ms) => sum + ms, 0), 60000);
} else assert.equal(calls, 2);
assert.equal(h.removed.length, 1);
}
});
test("slow session initialization can succeed after the old thirty-attempt limit", async () => {
let calls = 0;
const h = setup(async () => ++calls < 45 ? { ok: false, httpStatus: 401 } : { ok: true, items: ["ready"] });
assert.deepEqual(await h.api.request("1"), ["ready"]);
assert.equal(calls, 45);
assert.equal(h.removed.length, 1);
assert.ok(h.delays.reduce((sum, ms) => sum + ms, 0) < 60000);
});
test("iframe load alone never confirms authentication", async () => {
let calls = 0;
const h = setup(async () => {
if (++calls > 1) h.frames[0].listeners.load();
return { ok: false, httpStatus: 401 };
});
await assert.rejects(h.api.request("1"), /HTTP 401.*load event, but authentication was not confirmed/);
assert.equal(h.removed.length, 1);
}); });