2 Commits

Author SHA1 Message Date
88de9b81b7 Release 1.0.9 2026-10-28 11:47:48 -03:00
3fa212e561 Updated the process for retrieving records from CRMServiceRequests 2026-10-28 11:43:26 -03:00
24 changed files with 899 additions and 422 deletions

View File

@@ -1,4 +1,4 @@
# Opportunities Extension 1.0.7
# Opportunities Extension
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table or calendar.
@@ -372,94 +372,6 @@ AI text and consumption; unexpected requests fail closed. It does not authentica
against Fusion or read real extension storage. `/consumption-trend.html` provides
a focused chart fixture. Stop the server with Ctrl+C when finished.
## Release 1.0.0
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
- Opportunity type view preferences persisted in the browser.
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
- Light and dark themes based on Oracle Redwood.
- Separate outputs for Chromium/Edge and Firefox.
## Release 1.0.1
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
## Release 1.0.2
- Modularized the extension with regression tests and documented execution boundaries.
- Added AI opportunity summaries with a dedicated, reusable Sales Common access token.
- Added Installed Base and customer Service Requests tables with search and dark-mode support.
- Expanded Cloud Consumption with plan projections, interactive details and a searchable, sortable Rate Card drawer.
- Added Ramp Months, CRM Service Request and Workload Actions badges; SR details include direct links and copy controls.
- Added a one-time current-user lookup before the initial request flow. Users with `SALES_REP` do not query or display CRM SR/Actions.
- Improved debug request grouping, full-page viewing, response formatting and theme contrast.
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
## Release 1.0.3 - 2026-10-13
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
- Added searchable Action forms, required-field validation, saving and local badge updates.
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
## Release 1.0.4 - 2026-10-19
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.5 - 2026-10-20
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.6 - 2026-10-21
- Validate the current-user JSON structure before starting dependent requests, sharing concurrent authentication work and retrying failed identity acquisition.
- Recover SPA sessions through one inactive auxiliary tab at `https://spa.oracle.com/`, preserving the originating window and Firefox cookie store.
- Inspect the SPA document before retrying identity: reject error pages, SAML/login forms, OAuth callbacks and incomplete documents. HTTP 200, cookies and elapsed time alone no longer release identity probes.
- Read identity in the verified SPA tab, at most once per document; restart rejected authentication at the home URL rather than replaying OAuth callbacks. Readiness timeouts do not trigger fallback API probes.
- Correct native timer invocation and retain protected debug diagnostics and auxiliary-tab cleanup.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle authentication and installed-browser smoke tests were not performed for this release.
## Release 1.0.7 - 2026-10-26
- Remember table sorting preferences and restore the selected column and direction when reopening supported tables.
- Retrieve the user's Opportunity saved searches before loading opportunities and use the default savedSearchNumber for copiedFrom across all result pages.
- Show requestOpportunitiesSavedSearch in the Opportunities debug category and stop dependent queries when the default search is missing or invalid.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests were not performed for this release.
## Release 1.0.8 - 2026-10-27
- Added customer Organization Charts with contact search, contact creation, person details, notes, hierarchy editing and locally persisted chart data.
- Added paginated, sortable opportunities by contact using the contact PartyNumber.
- Unified modal and table layouts, loading skeletons and typography; added Rate Card order-number filtering and simplified Overview customer names using PartyName.
- Added Reference-bounded scrolling calendars with all daily opportunity cards and positioning around the current week.
- Added Booking/Workload filters and improved ramp/projection access, alerts and unavailable-workload indicators.
- Removed AI-generated opportunity summaries and their dedicated requests.
- Persisted debug requests in localStorage with lazy rendering; history resets only when a new opportunity query starts.
- Reuse successful current-user identity during the browser session; fixed debug timer invocation and added Token Relay timeout/retry support.
- Prepare SPA and SR sessions concurrently, inspect SPA before visual assets finish loading, and provide SR session retry with a 70-second timeout.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests are not included.
## Loading in Development Mode
### Chrome or Microsoft Edge

97
RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,97 @@
# Release Notes
## Release 1.0.9 - 2026-10-28
- Added a landscape print/PDF view with filters, totals, and opportunities.
- Resolve the CRM Service Request saved search dynamically before querying records.
- Restore hidden-iframe Internal SR session recovery with bounded retries and shared pending requests.
- Align opportunity badges and customer actions consistently.
- Move release history from README to this dedicated file.
## Release 1.0.0
- Paginated opportunity retrieval with filters for reference, customer, owner, status, stage, and text search.
- Column sorting, links to opportunities and accounts, opportunity number copying, and stage indicators.
- Opportunity type view preferences persisted in the browser.
- Background product retrieval with configurable caching, progress tracking, and expandable product details.
- Cloud consumption account-plan lookup from each opportunity, displayed in a tabbed detail modal.
- Light and dark themes based on Oracle Redwood.
- Separate outputs for Chromium/Edge and Firefox.
## Release 1.0.1
- Retries the consumer token service when it returns HTTP 200 with an HTML fallback.
- Opens the SPA session page in an inactive temporary tab and closes it as soon as valid JSON is received.
- Limits the recovery flow to five attempts and records the session bootstrap details in debug mode.
## Release 1.0.2
- Modularized the extension with regression tests and documented execution boundaries.
- Added AI opportunity summaries with a dedicated, reusable Sales Common access token.
- Added Installed Base and customer Service Requests tables with search and dark-mode support.
- Expanded Cloud Consumption with plan projections, interactive details and a searchable, sortable Rate Card drawer.
- Added Ramp Months, CRM Service Request and Workload Actions badges; SR details include direct links and copy controls.
- Added a one-time current-user lookup before the initial request flow. Users with `SALES_REP` do not query or display CRM SR/Actions.
- Improved debug request grouping, full-page viewing, response formatting and theme contrast.
- Improved compact layouts, customer actions, session recovery and completion-badge fade-out behavior.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses automated, browser-independent tests; installed-browser smoke tests were not performed for this release.
## Release 1.0.3 - 2026-10-13
- Added monthly and weekly calendar views with stage colors, fiscal week labels, daily counts and overflow details.
- Added searchable Action forms, required-field validation, saving and local badge updates.
- Added Service Request creation with dynamic lookups, payload/header confirmation and session recovery.
- Added Internal Service Request details and creation. Save reads parent data from `requestSRInfo`, resolves the current resource user, and links the new record using the parent `SrId`.
- Internal SR creation uses Session authentication, JSON, Origin and the observed App Builder client ID. Customer deadlines remain date-only; internal deadlines use midnight UTC ISO timestamps.
- Improved activity filters, cache reuse, Refresh all, progress indicators and compact layouts.
- Debug retains complete response bodies and reveals original sensitive values through the visibility toggle; values are hidden by default.
- Regenerated Chromium/Edge and Firefox distributions. Automated tests use synthetic data; real Oracle creation and installed-browser smoke tests are not part of release validation.
## Release 1.0.4 - 2026-10-19
- Added Manage Time with weekly navigation, a persisted reference date, daily hour totals and loading skeletons.
- Added searchable Service Request, Activity and Task Type fields, expanded SR tooltips and separate Service/Non Service task types.
- Existing time entries populate weekly hours and Notes. Local drafts are preserved while navigating between weeks.
- Save creates entries with POST, updates changed entries with PUT and applies queued deletions with DELETE. Writes use Session, Origin and the observed App Builder client ID.
- Added required-field validation, the 0-24 hour range, duplicate SR selection prevention, temporary row highlighting and success/error toasts.
- Reused Cache duration across form queries and invalidated cached responses after successful writes. Resource user data is reused after SR session initialization.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.5 - 2026-10-20
- Corrected Reference date ranges for Oracle fiscal quarters and years, updated the opportunity query structure and removed the Renewals reference.
- Added paginated workload retrieval by unique customer account and joined workloads to products by RevnId.
- Added consumption projection comparisons using adjustedACR, RampMonths_c and the reference workbook formula, including first-month proration.
- Product and opportunity alerts identify monthly forecast differences. Single-product alerts open the comparison directly; multiple-product alerts expand the product list.
- Added Update estimates with explicit confirmation before PATCH, dynamic calculated amounts and current-user attribution, duplicate-submit protection and success/error feedback.
- Successful forecast updates refresh local comparisons and invalidate workload caches. Workloads reuse Cache duration across modal openings and token renewals.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle writes and installed-browser smoke tests are not part of this validation.
## Release 1.0.6 - 2026-10-21
- Validate the current-user JSON structure before starting dependent requests, sharing concurrent authentication work and retrying failed identity acquisition.
- Recover SPA sessions through one inactive auxiliary tab at `https://spa.oracle.com/`, preserving the originating window and Firefox cookie store.
- Inspect the SPA document before retrying identity: reject error pages, SAML/login forms, OAuth callbacks and incomplete documents. HTTP 200, cookies and elapsed time alone no longer release identity probes.
- Read identity in the verified SPA tab, at most once per document; restart rejected authentication at the home URL rather than replaying OAuth callbacks. Readiness timeouts do not trigger fallback API probes.
- Correct native timer invocation and retain protected debug diagnostics and auxiliary-tab cleanup.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle authentication and installed-browser smoke tests were not performed for this release.
## Release 1.0.7 - 2026-10-26
- Remember table sorting preferences and restore the selected column and direction when reopening supported tables.
- Retrieve the user's Opportunity saved searches before loading opportunities and use the default savedSearchNumber for copiedFrom across all result pages.
- Show requestOpportunitiesSavedSearch in the Opportunities debug category and stop dependent queries when the default search is missing or invalid.
- Regenerated Chromium/Edge and Firefox distributions. Release validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests were not performed for this release.
## Release 1.0.8 - 2026-10-27
- Added customer Organization Charts with contact search, contact creation, person details, notes, hierarchy editing and locally persisted chart data.
- Added paginated, sortable opportunities by contact using the contact PartyNumber.
- Unified modal and table layouts, loading skeletons and typography; added Rate Card order-number filtering and simplified Overview customer names using PartyName.
- Added Reference-bounded scrolling calendars with all daily opportunity cards and positioning around the current week.
- Added Booking/Workload filters and improved ramp/projection access, alerts and unavailable-workload indicators.
- Removed AI-generated opportunity summaries and their dedicated requests.
- Persisted debug requests in localStorage with lazy rendering; history resets only when a new opportunity query starts.
- Reuse successful current-user identity during the browser session; fixed debug timer invocation and added Token Relay timeout/retry support.
- Prepare SPA and SR sessions concurrently, inspect SPA before visual assets finish loading, and provide SR session retry with a 70-second timeout.
- Regenerated Chromium/Edge and Firefox distributions. Validation uses synthetic automated tests; live Oracle calls and installed-browser smoke tests are not included.

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.8",
"version": "1.0.9",
"permissions": [
"cookies",
"storage",

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.8",
"version": "1.0.9",
"permissions": [
"cookies",
"storage",

View File

@@ -1,6 +1,6 @@
{
"name": "opportunities-extension",
"version": "1.0.8",
"version": "1.0.9",
"private": true,
"type": "module",
"scripts": {

View File

@@ -15,7 +15,7 @@ const baseManifest = {
manifest_version: 3,
name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.8",
version: "1.0.9",
permissions: [
"cookies",
"storage",

View File

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

View File

@@ -296,8 +296,8 @@ function createContentApplication(overrides = {}) {
});
const internalSrSession = createInternalSrSession({
environment,
query: async srId => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
query: async (srId, srNumber) => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId, srNumber });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
return result;
}
@@ -305,7 +305,7 @@ function createContentApplication(overrides = {}) {
const crmSrPopover = createCrmSrPopover({
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestDetails: srId => internalSrSession.request(srId),
requestDetails: (srId, srNumber) => internalSrSession.request(srId, srNumber),
MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
@@ -364,7 +364,43 @@ function createContentApplication(overrides = {}) {
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
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({
createPrintButton: () => opportunitiesPrint.createButton(),
productTypeSelection: opportunitiesFilterState.selectedProductTypes,
onProductTypeChange: () => opportunitiesTable.renderOpportunitiesTable(),
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"] td[data-align="money"] > span[class*="skeleton"] { margin-left: auto; }
/* 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"] { 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); }

View File

@@ -27,7 +27,7 @@ function createDebugContentView({
if (entry.name === "requestOpptyProducts") return "products";
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
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 (/^request(?:AccountSummary|AccountPlans|ConsumptionService|ConsumptionPlanDetail|RatecardByPlan)/.test(entry.name)) return "consumption";
return "other";

View File

@@ -1,7 +1,7 @@
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion, getRange,
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon,
productTypeSelection, onProductTypeChange }) {
productTypeSelection, onProductTypeChange, createPrintButton }) {
const { document } = environment;
let elements = null;
let calendar = null;
@@ -223,6 +223,7 @@ function createOpportunityCalendar({ environment, loadLibrary, getState, getItem
});
filters.append(button);
}
if (createPrintButton) filters.append(createPrintButton());
viewbar.append(filters);
}
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.textContent = "Loading SR details...";
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 {
const records = await requestDetails(item.SrId);
const records = await requestDetails(item.SrId, item.SrNumber);
if (active !== entry || entry.panel !== panel) return;
children.textContent = "";
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;
children.setAttribute("role", "alert");
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 {
if (active === entry && entry.panel === panel) {
children.setAttribute("aria-busy", "false");
entry.position();
}
}
});
};
detailLoads.push(loadDetails);
panel.append(section);
});
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));

View File

@@ -1,16 +1,15 @@
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
const { document, setTimeout } = environment;
const pending = new Map();
let recovery = null;
const wait = duration => new Promise(resolve => setTimeout(resolve, duration));
const now = () => environment.performance?.now() ?? Date.now();
function isUnauthorized(result) {
const status = result?.httpStatus ?? result?.debugRequests?.at(-1)?.httpStatus;
if (status != null) return Number(status) === 401;
// Older background workers may return only the request error text.
return /failed with status 401\.$/.test(result?.error || "");
return status != null ? Number(status) === 401 : /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");
// 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.title = `${sessionLabel} session initialization`;
frame.tabIndex = -1;
@@ -18,43 +17,46 @@ function createInternalSrSession({ query, environment, sessionUrl, sessionLabel
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;";
let blocked = false;
let loaded = false;
frame.addEventListener("error", () => { blocked = true; });
frame.addEventListener("load", () => { loaded = true; });
const failed = () => { blocked = true; };
frame.addEventListener("error", failed);
try {
if (!document.body) throw new Error(`The current page cannot initialize the ${sessionLabel} session.`);
document.body.append(frame);
const deadline = now() + 60000;
for (let attempt = 0; now() < deadline; attempt += 1) {
// Check immediately, then briefly poll faster before backing off.
if (attempt > 0) await wait(Math.min(attempt <= 4 ? 250 : 1000, deadline - now()));
if (now() >= deadline) break;
if (blocked) throw new Error(`The ${sessionLabel} session iframe could not be loaded. Sign in manually and try again.`);
const result = await query(id);
if (result?.ok) return { id, result };
// Keep the iframe alive while its application completes authentication.
// A load event is not proof of a ready session. Limit and space API checks.
for (const delay of [2000, 4000, 8000, 12000, 16000]) {
await new Promise(resolve => environment.setTimeout(resolve, delay));
if (blocked) throw new Error(`${sessionLabel} session iframe could not be loaded. Retry after signing in.`);
const result = await query(id, srNumber);
if (result?.ok) return { id: String(id), result };
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 {
// Stop remaining page resources without stopping the caller's page.
try { frame.contentWindow?.stop(); } catch { /* Redirects can make the frame cross-origin. */ }
frame.src = "about:blank";
frame.removeEventListener("error", failed);
try { frame.contentWindow?.stop(); } catch { /* SSO may redirect cross-origin. */ }
frame.remove();
}
}
async function request(id) {
let result = await query(id);
for (let attempt = 0; !result?.ok && isUnauthorized(result) && attempt < 2; attempt += 1) {
if (!recovery) {
recovery = warmSession(id);
const pending = recovery;
pending.finally(() => { if (recovery === pending) recovery = null; }).catch(() => {});
}
const recovered = await recovery;
result = recovered.id === id ? recovered.result : await query(id);
function request(id, srNumber) {
const key = String(id);
if (!pending.has(key)) {
const operation = (async () => {
let result = await query(id, srNumber);
if (!result?.ok && isUnauthorized(result)) {
if (!recovery) {
const current = warmSession(id, srNumber);
recovery = current;
current.finally(() => { if (recovery === current) recovery = null; }).catch(() => {});
}
const recovered = await recovery;
result = recovered.id === key ? recovered.result : await query(id, srNumber);
}
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
return result.items;
})().finally(() => pending.delete(key));
pending.set(key, operation);
}
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
return result.items;
return pending.get(key);
}
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 }) {
const { fetch } = environment;
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 {
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: [
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
{ 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 = () => {}) {
if (!isActive()) return null;
const savedSearchNumber = await requestSRSearchResources(token);
const items = [];
let offset = 0;
let attempt = 1;
while (isActive()) {
const payload = createPayload(offset);
const payload = createPayload(offset, savedSearchNumber);
const headers = {
Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}`,
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
@@ -60,5 +79,5 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
}
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");
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.opportunitiesQueryState.periodRequestVersion = 1;
app.debugContentView.renderDebugPanel = () => {};

View File

@@ -40,7 +40,7 @@ test("distribution contracts: manifests, execution boundaries and deterministic
const base = {
manifest_version: 3, name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.8", permissions: ["cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
version: "1.0.9", permissions: ["cookies", "storage", "webRequest", "declarativeNetRequestWithHostAccess"],
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*", "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"],
content_scripts: [{
matches: ["https://eeho.fa.us2.oraclecloud.com/hcmUI/faces/FuseWelcome*", "https://eeho.fa.us2.oraclecloud.com/fscmUI/faces/FuseWelcome*"],

View File

@@ -2,10 +2,10 @@ const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
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; } }) };
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.debugContentView.renderDebugPanel = () => {};
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",
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 () => {
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 }));
@@ -79,7 +115,7 @@ test("CRM query preserves payload and pagination after opportunities, uses token
assert.equal(options.headers.Preference, "transient");
assert.deepEqual(payload, {
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: [
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
{ 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"],
skipInValidFields: true, limit: 15, offset: 0
});
assert.equal(app.debugState.requestLog.length, 2);
for (const log of app.debugState.requestLog) {
assert.equal(app.debugState.requestLog.length, 3);
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.requestHeaders.Origin, "https://eeho.fa.us2.oraclecloud.com");
assert.equal(log.requestHeaders.Preference, "transient");
@@ -144,6 +181,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
let resolveOld;
const app = setup(() => new Promise(resolve => { resolveOld = resolve; }));
const old = app.opportunityCrmServiceRequests.load("token", 1);
while (!resolveOld) await new Promise(resolve => setImmediate(resolve));
app.opportunitiesQueryState.periodRequestVersion = 2;
app.opportunityCrmServiceRequests.reset();
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [sr("NEW")];
@@ -152,7 +190,7 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
await old;
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").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 () => {
@@ -167,6 +205,7 @@ test("Reference changes share pending pagination and reuse completed CRM results
app.opportunitiesQueryState.periodRequestVersion = 2;
const second = app.opportunityCrmServiceRequests.load("token", 2);
assert.equal(first, second);
while (!finish) await new Promise(resolve => setImmediate(resolve));
finish(response({ items: [sr("A")], count: 1, offset: 0, hasMore: true }));
await second;
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);
}
});
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"]) {
test(`${family}: SR detail GET uses exact scope and Session authentication`, async () => {
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));
});
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();
let renewals = 0;
const sent = [];
@@ -106,6 +106,11 @@ test("internal SR uses Session without TokenRelay and reports unavailable iframe
assert.equal(sent[0].token, undefined);
assert.ok(sent.every(item => item.type === "opportunitiesExtension.requestInternalServiceRequest"));
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() {

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 { test } = require("node:test");
const fs = require("node:fs");
const vm = require("node:vm");
const path = require("node:path");
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/internal-sr-session.js"), "utf8")}\ncreateInternalSrSession`);
function setup(query, options = {}) {
let elapsed = 0;
const frames = [], removed = [], delays = [], stopped = [];
const body = { append(frame) { frame.initialSrc = frame.src; frames.push(frame); } };
const document = { body, createElement(tag) {
assert.equal(tag, "iframe");
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); } };
} };
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 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/features/opportunities/internal-sr-session.js', 'utf8') + '\ncreateInternalSrSession');
test('without a document, session recovery stops without polling', async () => {
for (const result of [{ok:false,httpStatus:401}, {ok:false,debugRequests:[{httpStatus:401}]}, {ok:false,error:'Request failed with status 401.'}]) {
let calls=0;
const service=factory({query:async()=>{calls++; return result;},environment:{document:{createElement(){assert.fail('No iframe');}},setTimeout(){assert.fail('No polling');}}});
await assert.rejects(service.request('123'), /Internal SR|status 401/);
await new Promise(resolve=>setImmediate(resolve));
assert.equal(calls,1);
}
});
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});}
}};
}
test("lookup requests recover together using the configured page and preserve each result", async () => {
let authenticated = false;
let calls = 0;
const h = setup(async message => {
if (++calls <= 4) return { ok: false, httpStatus: 401 };
authenticated = true;
return { ok: true, items: [message.type] };
}, { sessionUrl: "https://eeho.fa.us2.oraclecloud.com/?view=Show%20Service%20Request", sessionLabel: "Service Request" });
const types = ["pillar", "language", "platform", "services"];
const results = await Promise.all(types.map(type => h.api.request({ type })));
assert.deepEqual(results, types.map(type => [type]));
assert.equal(authenticated, true);
assert.equal(h.frames.length, 1);
assert.match(h.frames[0].initialSrc, /Show%20Service%20Request$/);
assert.equal(h.frames[0].title, "Service Request session initialization");
assert.equal(h.removed.length, 1);
assert.equal(h.stopped.length, 1);
const flush = () => new Promise(resolve=>setImmediate(resolve));
test('hidden iframe remains alive after load and closes only after successful session query',async()=>{
const h=harness(); let calls=0;
const api=factory({environment:h.environment,query:async()=>++calls<3?{ok:false,httpStatus:401}:{ok:true,items:[{SrId:1}]}});
const first=api.request('123'); assert.equal(first,api.request(123));
await flush();
assert.equal(h.counts().appended,1);
h.listeners.get('load')?.();
assert.equal(h.counts().removed,0);
assert.equal(calls,1);
assert.equal(h.timers[0].delay,2000);
h.timers.shift().fn(); await flush();
assert.equal(h.counts().removed,0);
assert.equal(h.timers[0].delay,4000);
h.timers.shift().fn();
assert.equal((await first)[0].SrId,1);
assert.equal(h.counts().removed,1);
});
test("401 initializes a hidden iframe in the calling document and removes it on success", async () => {
let calls = 0;
const h = setup(async () => ++calls < 3 ? { ok: false, httpStatus: 401 } : { ok: true, items: [{ SrId: 1 }] });
const items = await h.api.request("1");
assert.equal(items[0].SrId, 1);
assert.equal(h.frames.length, 1);
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);
test('persistent 401 is bounded to five recovery checks and removes iframe',async()=>{
const h=harness(); let calls=0;
const api=factory({environment:h.environment,query:async()=>{calls++;return {ok:false,httpStatus:401};}});
const failed=assert.rejects(api.request('123'),/still unavailable/);
for(const delay of [2000,4000,8000,12000,16000]) {
await flush(); assert.equal(h.timers[0].delay,delay); h.timers.shift().fn();
}
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 () => {
const calls = new Map();
const h = setup(async id => {
const count = (calls.get(id) || 0) + 1;
calls.set(id, count);
return count <= (id === "language" ? 1 : 2)
? { ok: false, httpStatus: 401 } : { ok: true, items: [id] };
});
assert.deepEqual(await Promise.all([h.api.request("language"), h.api.request("platform")]), [["language"], ["platform"]]);
assert.equal(h.frames.length, 2);
assert.equal(h.removed.length, 2);
test('concurrent requests share work and explicit retry can recover', async () => {
let calls=0, complete;
const service=factory({query:()=>{calls++; return new Promise(resolve=>{complete=resolve;});}});
const first=service.request('123'); const second=service.request(123);
assert.equal(first,second); await Promise.resolve();
complete({ok:false,httpStatus:401}); await assert.rejects(first);
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);
});
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);
test('other errors do not retry and successful empty results are preserved', async()=>{
const failed=factory({query:async()=>({ok:false,error:'Forbidden',httpStatus:403})});
await assert.rejects(failed.request('123'),/Forbidden/);
const ok=factory({query:async()=>({ok:true,items:[]})}); assert.equal((await ok.request('123')).length,0);
});