14 Commits

Author SHA1 Message Date
2ae057cc90 Release 1.0.3 2026-10-13 11:14:12 -03:00
8b4d88b226 Implemented the Create Internal Service Request functionality 2026-10-09 18:41:39 -03:00
c9de750261 Implemented the calendar view for opportunities 2026-10-05 18:35:28 -03:00
7a7143b6e7 Implemented the functionality to create Service Requests 2026-10-05 16:30:29 -03:00
e9c295be91 Some visual adjustments to the badges 2026-10-02 18:44:47 -03:00
f512ef9928 Loading comcipapic to establish a session for SR requests 2026-10-02 02:37:35 -03:00
3fd89a307c The sessions required for Internal SR API calls are now loaded via an iframe 2026-09-29 17:48:03 -03:00
4d3f1f9760 Implemented a tooltip on the SR to display the Internal SR 2026-09-29 13:53:38 -03:00
a182609415 Implemented the functionality to add a WL Action 2026-09-28 17:32:32 -03:00
1a3decbeb6 Replaced labels with icons in the SR and Action badges 2026-09-27 19:18:24 -03:00
de4da0eec6 Implemented filters for Service Requests and Actions 2026-09-25 16:37:29 -03:00
cc567c8ff1 Added the Refresh All button to invalidate caches and force requests to be sent again 2026-09-25 15:26:09 -03:00
6a3e9e6adf Optimized requests to retrieve SRs and Actions 2026-09-25 11:08:02 -03:00
86973f7542 Updated the Service Requests badge to display the hours logged on the SR 2026-09-24 19:17:44 -03:00
96 changed files with 13432 additions and 722 deletions

132
README.md
View File

@@ -1,6 +1,25 @@
# Opportunities Extension 1.0.2
# Opportunities Extension 1.0.3
Browser extension that adds the "Opportunities Extension" shortcut to the permitted Oracle Fusion pages, retrieves opportunities, and displays them in a filterable table.
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.
## Opportunity Views
**Visao geral** is the default table view. **Calendario** displays opportunities
by Close Date (`EffectiveDate`) with month and week views only. Both views share
Reference, search and all existing filters; switching views does not send requests.
Events use date-only values without time-zone shifts, stage colors, and a read-only
detail with an opportunity link and copy button. Invalid or missing dates appear
in a separate list. Calendar navigation does not change the selected Reference.
Each day shows a filtered opportunity count, at most two events and an overflow
link to the remaining events. The grid fits the available height and reduces
visible events to one or zero when needed to keep every week visible. Missing
close dates are available in a collapsible list below the calendar.
Fiscal labels use `FY27-Q2-W19`: June-May fiscal
years, Sunday-Saturday weeks, and W1 for the week containing June 1. A quarter is
assigned by the week's Sunday, except the first fiscal week which belongs to Q1.
FullCalendar Standard 6.1.19 is bundled locally under `src/vendor/fullcalendar/`
with its MIT license. It is initialized only on first use, without a runtime CDN.
## Development Structure
@@ -19,40 +38,111 @@ Browser extension that adds the "Opportunities Extension" shortcut to the permit
## CRM Service Request Badges
The header's **Refresh all** button (between theme and Preferences) invalidates
persisted Products and activity caches and clears in-memory data views. It reloads
the selected Reference, fetching fresh products and then the permitted SR/Actions
queries. Customer details, rate cards, consumption charts and AI summaries are
requested again when opened. Preferences, cookies and authentication are preserved.
The button is disabled during invalidation and list reload; product/activity badges
continue to show subsequent background progress. Cache-clear failures are surfaced
instead of silently reusing cached data, and stale product responses cannot restore
an invalidated cache.
On the first Opportunities modal opening in a page session, `requestCurrentUser`
fetches SPA identity before tokenrelay or opportunity requests. Concurrent calls,
Reference changes and modal reopenings reuse that result. Users with `SALES_REP`
do not trigger CRM Service Request or Workload Actions queries or see their badges.
If identity or roles cannot be validated, only these two features stay disabled;
other opportunity features continue. Reload the page to refresh identity/roles.
The unused identity token is discarded and redacted from debug responses.
The identity token is retained in debug responses and hidden by default; the
explicit sensitive-values toggle reveals the original value.
Once per opening of the opportunities modal, `resquestCrmServiceRequest` fetches all pages of
Consumer token recovery also handles HTTP 401/403, in addition to HTTP 200 with
invalid JSON. It opens the existing SPA session URL in an inactive temporary tab,
preserves the originating window and Firefox cookie store, and retries within
the same five-attempt limit. Debug entries retain the HTTP status and recovery
reason. The auxiliary tab is closed after valid JSON or exhausted retries.
After a completed product-loading cycle with fresh API results, `resquestCrmServiceRequest` fetches all pages of
`CRMServiceRequest` records with `SVC_PENDING_DELIVERY` and `ORA_SVC_MY_SR_TEAM_SRS`,
using the existing general tokenrelay Bearer. It uses the Fusion `searchResources/latest`
query endpoint, independently of the opportunity query version. Calls appear in
the Service Requests debug category with masked credentials.
After a complete successful SR query (HTTP 200 with valid responses on all pages),
its result is reused for subsequent Reference/product cycles in the current page
session. Refresh all explicitly clears it. Failed pages are retried up to five
total attempts each; completed pages are not requested again. Resetting the query
stops further retries from the previous run.
Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indicates
one or more matches; its tooltip lists each SR number, title, reported hours, owner,
email and contact flags. A red badge indicates no match within that query's scope.
A failed lookup shows `SR ?`, not a false no-match result, and opportunities still load.
Badges follow Ramp Months and do not wait for product downloads. SR results remain
in memory only. Reference changes share the in-flight lookup and reuse its result
(including failures) without another request. Closing resets this state; reopening
starts a fresh lookup, and responses from previous openings are ignored.
Badges follow Ramp Months. Successful SR and Actions responses are persisted in
extension local storage, isolated by tokenrelay principal. The cache uses the same
Preferences lifetime as Products (24 hours by default), anchored to the product
timestamp rather than the activity response time. Actions entries match each
product's update; the SR snapshot matches the latest product update in the cycle.
SR snapshots are stored separately by update timestamp so changing Reference does
not overwrite a previous period's snapshot. Expired snapshots are removed on
subsequent SR cache writes; Refresh all invalidates every snapshot.
No credentials, request headers or debug envelopes are stored in this cache.
Products loaded from cache restore matching activity entries without network calls.
Missing, expired or mismatched entries are not treated as confirmed empty results;
they wait for a real product refresh before a new activity query is allowed.
Empty and entirely failed product cycles do not query activities. In mixed cycles,
only fresh products permit activity API calls on cache misses; an already-loaded
SR result is reused and associated with the new product update.
Activity lookups do not advance the existing Last update timestamp.
Closing and reopening the modal preserves results and does not
reload products or trigger these lookups. Already-running product requests may
finish in the background and update their related activities.
## Workload Actions
Workload Actions are queried in the background after product results arrive,
including cached products. Each distinct `RevnId` uses `requestWlActions` with
The missing Act badge includes a separate plus control that opens an Action form
with Owner, Role, Action, Team, Start date, End Date, Status, Complexity and Notes.
Opening the form loads team members and action lookups using the WWB-TS token.
Team members use the product RevnId; when multiple workloads are available, the
user selects one before its members are requested. Owner options use email for
both label and value; lookup options display name and submit code.
All Action selects support local autocomplete by label or code, arrow-key
navigation and Enter selection. Escape or leaving the field restores the last
selected option; typed search text is never submitted as an option value.
Fields are enabled only after both metadata requests return HTTP 200. Loading,
errors and manual retry are supported, and debug logs mask the credential.
Save validates all required fields and the date range before posting to the WWB
actions endpoint. Dates are sent at midnight UTC in ISO format; notes are an
array of strings (empty when omitted). The current user.id supplies createdBy
and updatedBy. While saving, editing and dismissal are locked to prevent duplicate
submissions. Success closes the form and displays a toast; failure preserves the
form and displays an error toast without automatically retrying the POST.
Cancel or Escape dismisses an idle form. The form is unavailable to SALES_REP
users, like the other Action features.
SR and Action filter badges appear before Status for users allowed to view these
activities. They filter the loaded data without issuing requests and combine with
the existing opportunity filters. The SR below 4h option replaces the other SR
selections and matches at least one linked SR with a numeric `ReportedHours_c < 4`
(including zero, excluding missing values). Action matches use the same ownership
rule as the row badge. Loading/failed lookups are not treated as missing records;
with both positive and negative options selected, all activity states remain
visible. These selections are kept in memory, not persisted.
Workload Actions are queried in the background after the product cycle completes,
and only after SR has completed successfully (or was restored/reused). If SR is
unavailable, Actions are not requested and remain unknown rather than absent.
Each failed workload lookup gets up to five total attempts, stopping on success
or when its session is superseded. Cached products still do not trigger network
lookups. Each distinct `RevnId` uses `requestWlActions` with
the `WWB-TS` credential in `Wwb-Provider-Authorization`. The Act badge beside SR
aggregates all products: green means an action's `owner` matches the general
tokenrelay response's `principal`; red means no matching owner was found. `Act ?`
means ownership could not be confirmed (including a missing principal). Tooltips
retain all returned actions, regardless of owner. Principal stays in memory only.
Queries are shared per modal opening (up to four concurrently), reused
across Reference changes, and discarded on close. Reopening queries again.
retain all returned actions, regardless of owner. Credentials stay in memory only;
the principal is used to scope persisted activity entries.
Queries are shared per product cycle (up to four concurrently). Reference changes
that load products from cache do not refresh activities.
Only the first page is requested for now; `hasNext` is indicated in the tooltip.
The debug panel groups these requests under Actions, with credentials masked.
@@ -179,8 +269,9 @@ HTTP methods/statuses, request/response sections and JSON tokens. Highlighting
uses safe text nodes only after the existing sensitive-value masking. Compact JSON,
supported protection prefixes and JSON documents wrapped in strings are normalized
and indented, including while sensitive values are visible. Large JSON responses
are captured as bounded, valid JSON previews with an explicit omission notice,
preserving indentation, highlighting and sensitive-field masking. Already incomplete
are captured in full, preserving indentation, highlighting and sensitive-field
masking. Original sensitive values are retained for explicit reveal, including
identity, tokenrelay and catalog tokens. Already incomplete
or invalid JSON remains plain text. The visibility toggle retains its existing
behavior. Focus, expansion and timeline scroll position survive log updates.
@@ -246,6 +337,17 @@ a focused chart fixture. Stop the server with Ctrl+C when finished.
- 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.
## Loading in Development Mode
### Chrome or Microsoft Edge

View File

@@ -14,7 +14,21 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
requestSrLookupPrimaryPillar: "opportunitiesExtension.requestSrLookupPrimaryPillar",
requestPostServiceRequest: "opportunitiesExtension.requestPostServiceRequest",
requestPostInternalServiceRequest: "opportunitiesExtension.requestPostInternalServiceRequest",
prepareSrSession: "opportunitiesExtension.prepareSrSession",
requestSrLookupLanguage: "opportunitiesExtension.requestSrLookupLanguage",
requestSrLookupTechPlatform: "opportunitiesExtension.requestSrLookupTechPlatform",
requestLookupTypePlatform: "opportunitiesExtension.requestLookupTypePlatform",
requestInternalSrServices: "opportunitiesExtension.requestInternalSrServices",
requestSrLookupOpportunityBusiness: "opportunitiesExtension.requestSrLookupOpportunityBusiness",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
@@ -22,45 +36,8 @@ function createMessageTypes() {
}
// Source: src/shared/debug-body.js
function createDebugBodyPreview(text, limit) {
if (text.length <= limit) return { value: text || "(empty response body)", truncated: false };
try {
const normalized = text.replace(/^\uFEFF/, "").trimStart()
.replace(/^\)\]\}',?\s*/, "").replace(/^while\s*\(1\);\s*/, "");
let value = JSON.parse(normalized);
for (let depth = 0; depth < 2 && typeof value === "string" && /^\s*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; }
}
// Bound retained data without cutting JSON syntax or losing sensitive field names.
let remaining = Math.max(0, limit - 256);
const visit = (node, depth = 0) => {
if (remaining <= 64 || depth >= 30) return "[omitted from debug preview]";
if (node === null || typeof node !== "object") {
const serialized = JSON.stringify(node);
if (serialized.length > remaining) {
remaining = 0;
return "[omitted from debug preview]";
}
remaining -= serialized.length + 1;
return node;
}
const result = Array.isArray(node) ? [] : Object.create(null);
remaining -= 2;
for (const key of Object.keys(node)) {
const cost = Array.isArray(node) ? 1 : JSON.stringify(key).length + 2;
if (remaining < cost + 64) break;
remaining -= cost;
result[key] = visit(node[key], depth + 1);
}
return result;
};
return {
value: JSON.stringify({ debugNotice: "Partial response preview: remaining fields/items omitted due to the debug size limit.", preview: visit(value) }),
truncated: true
};
} catch {
return { value: `${text.slice(0, limit)}\n... truncated ${text.length - limit} characters`, truncated: true };
}
function createDebugBodyPreview(text) {
return { value: text || "(empty response body)", truncated: false };
}
// Source: src/background/contracts.js
@@ -249,14 +226,22 @@ function createAuthConsumerSession({
const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE"
&& error.httpStatus === 200;
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
const retryReason = isSessionUnauthorized
? `HTTP ${error.httpStatus} response requires SPA session recovery`
: "HTTP 200 response did not contain valid JSON";
const debugEntry = debugRequests[debugRequests.length - 1];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) {
if (needsSessionRecovery) {
debugEntry.metadata.retryLimitReached = "true";
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
debugEntry.metadata.retryReason = retryReason;
debugEntry.error = isHtmlFallback
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
error.message = debugEntry.error;
}
}
@@ -284,7 +269,7 @@ function createAuthConsumerSession({
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
debugEntry.metadata.retryReason = retryReason;
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
}
@@ -412,6 +397,117 @@ function createAuthSpaCredentials({
return { normalizeMessageAuthorization, extractTokenServiceAuthorization };
}
// Source: src/auth/sr-request-headers.js
function createSrRequestHeaders({ runtimeApi }) {
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
const appPath = "/ic/builder/rt/SeaaS_initiation/";
const internalPath = "/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const storageKey = "opportunitiesExtension.srAppBuilderClientId.v1";
const maxAge = 60 * 60 * 1000;
let observed = null;
let started = false;
function valid(record) {
return record?.origin === origin && typeof record.value === "string"
&& /^[\x21-\x7e]{1,2048}$/.test(record.value)
&& Number.isFinite(record.observedAt) && record.observedAt <= Date.now()
&& Date.now() - record.observedAt < maxAge;
}
function observeHeaders(rawUrl, headers) {
let url;
try { url = new URL(rawUrl); } catch { return; }
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management)\/live(?:\/|;)/.test(url.pathname)) return;
// Read only the client ID, not cookies, bodies or authorization.
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
const record = { origin, value: value?.trim(), observedAt: Date.now() };
if (!valid(record)) return;
observed = record;
// Session storage survives service-worker suspension without persisting across browser sessions.
runtimeApi.storage?.session?.set({ [storageKey]: record }).catch(() => {});
}
function start() {
if (started || !runtimeApi?.webRequest) return;
started = true;
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`], types: ["xmlhttprequest"] };
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
}, filter, ["requestHeaders"]);
runtimeApi.webRequest.onHeadersReceived?.addListener(details => {
if (!details.incognito) observeHeaders(details.url, details.responseHeaders);
}, filter, ["responseHeaders"]);
}
async function previewTokenHeaders(url) {
const target = new URL(url);
if (target.origin !== origin || !target.pathname.startsWith(appPath) || !/\/Catalogue_c(?:\/\d+)?$/.test(target.pathname)) {
throw new Error("Unexpected Service Request token URL.");
}
return sessionHeaders(target.origin, "application/vnd.oracle.adf.action+json");
}
async function sessionHeaders(targetOrigin, contentType) {
if (!valid(observed)) {
try {
const stored = await runtimeApi.storage?.session?.get(storageKey);
if (valid(stored?.[storageKey]) && !valid(observed)) observed = stored[storageKey];
} catch { /* A missing capture must not prevent trying the other required headers. */ }
}
return {
Accept: "application/json", "Content-Type": contentType,
Authorization: "Session", Origin: targetOrigin,
...(valid(observed) ? { "x-appbuilder-client-id": observed.value } : {})
};
}
async function tokenHeaders(url, serviceRequestUrl) {
const headers = await previewTokenHeaders(url);
if (!/\/Catalogue_c\/\d+$/.test(new URL(url).pathname)) throw new Error("Missing Catalogue Id.");
const serviceTarget = new URL(serviceRequestUrl);
const isServiceRequest = serviceTarget.pathname.startsWith(appPath) && serviceTarget.pathname.endsWith("/set/seaas/mgmt/serviceRequests");
if (serviceTarget.origin !== headers.Origin || !isServiceRequest || serviceTarget.search || serviceTarget.hash) {
throw new Error("Unexpected Service Request URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the Service Request token header permissions.");
}
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
const catalogUrl = url.slice(0, url.lastIndexOf("/") + 1);
const escapedCatalogUrl = catalogUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedServiceUrl = serviceTarget.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ruleIds = [7201, 7202];
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: ruleIds,
addRules: [`^${escapedCatalogUrl}[0-9]+$`, `^${escapedServiceUrl}$`].map((regexFilter, index) => ({
id: ruleIds[index], priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
condition: { regexFilter, initiatorDomains: [extensionDomain],
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
}))
});
return headers;
}
async function internalServiceRequestHeaders(url) {
const target = new URL(url);
if (target.origin !== origin || target.pathname !== internalPath || target.search || target.hash) {
throw new Error("Unexpected Internal Service Request URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the Internal Service Request header permissions.");
}
const headers = await sessionHeaders(target.origin, "application/json");
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: [7203],
addRules: [{
id: 7203, priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [extensionDomain],
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
}]
});
return headers;
}
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, observeResponseHeaders: observeHeaders };
}
// Source: src/services/spa/accounts.js
function createSpaAccounts({
ACCOUNT_PLANS_PAYLOAD,
@@ -644,6 +740,463 @@ function createSpaWorkloadActions({ fetchJson }) {
return { requestWlActions };
}
// Source: src/services/spa/action-options.js
function createSpaActionOptions({ fetchJson }) {
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
const lookupKeys = ["WWB_ACTION_ROLE", "WWB_ACTION_NAME", "WWB_ACTION_TEAM", "WWB_ACTION_STATUS", "WWB_ACTION_COMPLEXITY"];
async function request(message, name, path, normalize) {
const debugRequests = [];
try {
const token = message.authorization?.value;
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const result = await fetchJson(`${baseUrl}/${path()}`, {
method: "GET", credentials: "include",
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
}, name, debugRequests);
if (debugRequests.at(-1)?.httpStatus !== 200) throw new Error(`${name} requires HTTP 200.`);
return { ok: true, ...normalize(result), debugRequests };
} catch (error) {
const entry = debugRequests.at(-1);
if (entry) { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests;
throw error;
} finally {
for (const entry of debugRequests) {
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
}
}
}
function requestActionTeamMembers(message) {
return request(message, "requestActionTeamMembers", () => {
const id = String(message.workloadId ?? "").trim();
if (!/^\d+$/.test(id)) throw new Error("A numeric RevnId is required.");
return `${encodeURIComponent(id)}/teamMembers?includeLeads=true`;
}, result => {
if (!Array.isArray(result) || result.some(item => typeof item?.email !== "string" || !item.email.trim())) {
throw new Error("requestActionTeamMembers returned an invalid collection.");
}
return { members: result.map(item => ({ email: item.email })) };
});
}
function requestActionFindLookup(message) {
return request(message, "requestActionFindLookup", () => "fndLookup", result => {
const lookups = {};
for (const key of lookupKeys) {
if (!Array.isArray(result?.[key]) || result[key].some(item => typeof item?.code !== "string" || !item.code.trim()
|| typeof item?.name !== "string" || !item.name.trim())) {
throw new Error(`requestActionFindLookup returned an invalid ${key} collection.`);
}
lookups[key] = result[key].map(({ code, name }) => ({ code, name }));
}
return { lookups };
});
}
async function requestMgmtAction(message) {
const debugRequests = [];
try {
const token = message.authorization?.value;
const payload = message.payload;
const action = payload?.action;
if (!token || typeof token !== "string") throw new Error("WWB-TS uiaasHeader.value is unavailable.");
if (payload?.parentType !== "WORKLOAD" || !/^\d+$/.test(String(payload.parentId))) throw new Error("A numeric RevnId is required.");
for (const key of ["ownerEmail", "role", "name", "team", "status", "complexity", "createdBy", "updatedBy"]) {
if (typeof action?.[key] !== "string" || !action[key].trim()) throw new Error(`Missing ${key}.`);
}
for (const key of ["startDate", "endDate"]) {
if (typeof action[key] !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(action[key]) || !Number.isFinite(Date.parse(action[key]))) throw new Error(`Invalid ${key}.`);
}
if (action.endDate < action.startDate) throw new Error("End date must not precede start date.");
if (!Array.isArray(action.notes) || action.notes.some(note => typeof note !== "string")) throw new Error("Invalid notes.");
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", "Wwb-Provider-Authorization": token.trim() },
body: JSON.stringify(payload)
}, "requestMgmtAction", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
for (const entry of debugRequests) {
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
}
}
}
return { requestActionTeamMembers, requestActionFindLookup, requestMgmtAction };
}
// Source: src/services/fusion/sr-details.js
function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFirefoxCookieStoreTabs, cookiesGetAll, cookieChanges, wait }) {
const sessions = new Map();
function cookiePart(cookie) {
if (!cookie) return null;
const match = /^ORA_OCIS_CG_SESSION_idcs-([a-zA-Z0-9-]+)_comcipapic-oalprod\.integration\.ocp\.oraclecloud\.com_([12])$/.exec(cookie.name);
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const cookieDomain = cookie.domain?.replace(/^\./, "");
if (!match || (cookieDomain && cookieDomain !== domain && !domain.endsWith(`.${cookieDomain}`))) return null;
return { key: `${match[1]}|${cookie.path || "/"}|${cookie.storeId || ""}`, part: match[2],
valid: Boolean(cookie.value) && (cookie.expirationDate == null || cookie.expirationDate > Date.now() / 1000) };
}
function watchSession(session, sender) {
const pairs = new Map();
let available;
session.cookieReady = new Promise(resolve => { available = resolve; });
const listener = change => {
const cookie = change.cookie;
const part = cookiePart(cookie);
if (!part) return;
if (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId) return;
if (!pairs.has(part.key)) pairs.set(part.key, new Map());
const pair = pairs.get(part.key);
if (change.removed || !part.valid) pair.delete(part.part);
else pair.set(part.part, cookie);
if (pair.size === 2 && [...pair.values()].every(item => cookiePart(item)?.valid)) available(new Set([part.key]));
session.wake?.();
};
cookieChanges?.addListener(listener);
session.unwatch = () => cookieChanges?.removeListener(listener);
}
function waitForSessionCookies(session, sender) {
if (session.cookieWait) return session.cookieWait;
session.cookieWait = (async () => {
const deadline = Date.now() + 1000;
for (let attempt = 0; attempt < 5; attempt += 1) {
// Install the wake-up before reading so a renewal during getAll is not missed.
const changed = new Promise(resolve => { session.wake = resolve; });
const current = await sessionCookies(sender);
const renewed = [...current].some(pair => !session.baseline.has(pair));
if (renewed || Date.now() >= deadline) {
session.baseline = current;
return current;
}
await Promise.race([changed, wait(250)]);
}
return sessionCookies(sender);
})().finally(() => { session.cookieWait = null; session.wake = null; });
return session.cookieWait;
}
async function sessionCookies(sender) {
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const details = { domain };
if (sender?.tab?.cookieStoreId) details.storeId = sender.tab.cookieStoreId;
try {
const groups = new Map();
for (const cookie of await cookiesGetAll(details)) {
const part = cookiePart(cookie);
if (!part?.valid) continue;
if (!groups.has(part.key)) groups.set(part.key, {});
groups.get(part.key)[part.part] = [cookie.name, cookie.value, cookie.expirationDate ?? null];
}
// Fingerprints remain in memory only; the browser sends the actual Cookie header.
return new Set([...groups.values()].filter(pair => pair[1] && pair[2]).map(pair => JSON.stringify([pair[1], pair[2]])));
} catch { return new Set(); }
}
async function requestLookupWithSession(operation, message, sender, initialize = false) {
const existingCookies = initialize ? await sessionCookies(sender) : null;
if (existingCookies?.size) return { ok: true, debugRequests: [] };
const logs = [];
const query = async () => {
try {
const result = await operation(message);
logs.push(...(result.debugRequests || []));
return { ...result, debugRequests: logs };
} catch (error) {
logs.push(...(error.debugRequests || []));
error.debugRequests = logs;
throw error;
}
};
if (!initialize) {
try { return await query(); } catch (error) { if (error.httpStatus !== 401) throw error; }
}
const key = sender?.tab?.cookieStoreId || "default";
let session = sessions.get(key);
if (!session) {
const details = {
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
active: false
};
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
if (supportsFirefoxCookieStoreTabs() && sender?.tab?.cookieStoreId) details.cookieStoreId = sender.tab.cookieStoreId;
session = { users: 0, closed: null, baseline: null };
watchSession(session, sender);
session.tab = (async () => {
session.baseline = existingCookies || await sessionCookies(sender);
return tabsCreate(details);
})();
sessions.set(key, session);
}
session.users += 1;
let tab;
try {
tab = await session.tab;
const deadline = Date.now() + 60000;
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) {
const pendingCookies = initialize && attempt === 0
? sessionCookies(sender) : waitForSessionCookies(session, sender);
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
if (initialize) {
// Proactive preparation observes cookies only; API requests belong to the form.
if (!cookies.size) continue;
await closeSessionTab();
return { ok: true, debugRequests: [] };
}
try {
const result = await query();
// Any successful lookup confirms the shared session; stop the page immediately.
await closeSessionTab();
return result;
} catch (error) {
if (error.httpStatus !== 401) throw error;
}
}
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry.");
} catch (error) {
error.debugRequests = logs;
throw error;
} finally {
session.users -= 1;
if (session.users === 0) {
sessions.delete(key);
session.unwatch();
await closeSessionTab();
}
}
function closeSessionTab() {
if (!session.closed && Number.isInteger(tab?.id)) {
session.closed = tabsRemove(tab.id).catch(() => {});
}
return session.closed;
}
}
async function requestInternalServiceRequest(message) {
const debugRequests = [];
try {
const id = String(message.srId ?? "");
if (!/^\d+$/.test(id)) throw new Error("A numeric SrId is required.");
const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId";
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests"
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
const query = async () => {
const result = await fetchJson(url, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, "requestInternalServiceRequest", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
return result;
};
const result = await query();
const items = result.items.flatMap(item => {
const children = item.ServiceRequest_SRToInternalSR_Tgt;
if (children == null) return [];
if (!Array.isArray(children) || children.some(child => !child || typeof child !== "object")) throw new Error("Invalid linked SR collection.");
return children;
});
return { ok: true, items, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
async function requestLookup(lookupType, requestName) {
const debugRequests = [];
try {
const url = `https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/lookups?lookupType=${lookupType}&type=STANDARD`;
const result = await fetchJson(url, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, requestName, debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => typeof item?.code !== "string" || typeof item?.name !== "string")) throw new Error(`Invalid ${requestName} response.`);
const items = result.items.filter(item => item.enabledFlag !== "N")
.sort((a, b) => (Number(a.displaySequence) || 0) - (Number(b.displaySequence) || 0))
.map(({ code, name }) => ({ code, name }));
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestSrLookupOpportunityBusiness(message) {
const debugRequests = [];
try {
if (typeof message.emailAddress !== "string" || !message.emailAddress.trim()) throw new Error("Current user email is unavailable.");
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/services"
+ `?emailAddress=${encodeURIComponent(message.emailAddress.trim())}&salesObject=OPPORTUNITY&serviceType=BUSINESS`;
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" } }, "requestSrLookupOpportunityBusiness", debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => !/^\d+$/.test(String(item?.Id ?? "")) || typeof item?.RecordName !== "string")) throw new Error("Invalid Services response.");
return { ok: true, items: result.items.map(({ Id, RecordName, Abstract_c }) => ({ Id: String(Id), RecordName, Abstract_c: Abstract_c == null ? "" : String(Abstract_c) })), debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestInternalSrOptions(message, services = false) {
const debugRequests = [];
const name = services ? "requestInternalSrServices" : "requestLookupTypePlatform";
try {
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetSeaaSOKECustomRestAPI/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/";
let path = "lookups?type=STANDARD&lookupType=SENGG_TECH_PLATFORM";
if (services) {
const id = String(message.parentServiceId ?? "");
if (!/^\d+$/.test(id)) throw new Error("A numeric parent SrId is required.");
if (typeof message.emailAddress !== "string" || !message.emailAddress.trim()) throw new Error("Current user email is unavailable.");
path = `services?emailAddress=${encodeURIComponent(message.emailAddress.trim())}&parentServiceId=${encodeURIComponent(id)}&serviceType=INTERNAL`;
}
const result = await fetchJson(base + path, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, name, debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => services
? !/^\d+$/.test(String(item?.Id ?? "")) || typeof item?.RecordName !== "string"
: typeof item?.code !== "string" || typeof item?.name !== "string")) throw new Error(`Invalid ${name} response.`);
const items = services
? result.items.map(item => ({ Id: String(item.Id), RecordName: item.RecordName, Description_c: String(item.Description_c ?? "") }))
: result.items.filter(item => item.enabledFlag !== "N").map(({ code, name }) => ({ code, name }));
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
return { requestLookupWithSession, requestInternalServiceRequest, requestSrLookupOpportunityBusiness,
requestLookupTypePlatform: () => requestInternalSrOptions({}),
requestInternalSrServices: message => requestInternalSrOptions(message, true),
prepareSession: sender => requestLookupWithSession(null, {}, sender, true),
requestSrLookupPrimaryPillar: () => requestLookup("SENGG_PRIMARY_PILLAR", "requestSrLookupPrimaryPillar"),
requestSrLookupLanguage: () => requestLookup("SENGG_LANGUAGE", "requestSrLookupLanguage"),
requestSrLookupTechPlatform: () => requestLookup("SENGG_TECH_PLATFORM", "requestSrLookupTechPlatform") };
}
// Source: src/services/fusion/sr-create.js
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders }) {
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/";
const catalog = base + "crmRestApiResources/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/11.13.18.05/Catalogue_c";
const resourceUsers = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers";
const endpoint = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/serviceRequests";
function numericId(value, name) {
if (!/^\d+$/.test(String(value ?? "")) || !Number.isSafeInteger(Number(value)) || Number(value) <= 0) throw new Error(`Missing or invalid ${name}.`);
return Number(value);
}
function text(value, name) {
if (typeof value !== "string" || !value.trim()) throw new Error(`Missing ${name}.`);
return value.trim();
}
function dateOnly(value, name) {
const date = text(value, name);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(`${date}T00:00:00Z`))
|| new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date) throw new Error(`Invalid ${name}.`);
return date;
}
function payloadFor(input) {
const payload = {};
for (const name of ["AccountPartyId", "OpportunityName_Id_c", "ServiceNew_Id_c"]) payload[name] = numericId(input?.[name], name);
for (const name of ["Title", "RegionText_c", "ProblemDescription", "Language_c", "CustomerDeadline_c", "TechPlatformLookUp_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(input?.[name], name);
dateOnly(payload.CustomerDeadline_c, "Customer deadline");
return { ...payload, SvcQuestionsCollection_c: [], SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" };
}
async function requestResourceUser(email, debugRequests) {
const emailAddress = text(email, "current user email");
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestResourceUser", debugRequests);
return numericId(resource?.resourcePartyId, "resourcePartyId");
}
async function requestPostInternalServiceRequest(message) {
const debugRequests = [];
try {
const source = message.payload;
const parentSrId = numericId(source?.ServiceRequest_Id_SRToInternalSR, "parent SrId");
const payload = {
Title: text(source?.Title, "Title"),
ServiceNew_Id_c: numericId(source?.ServiceNew_Id_c, "ServiceNew_Id_c"),
ProblemDescription: text(source?.ProblemDescription, "Description"),
CustomerDeadline_c: dateOnly(source?.CustomerDeadline_c, "Customer deadline"),
TechPlatformLookUp_c: text(source?.TechPlatformLookUp_c, "Platform"),
InternalDeadline_c: `${dateOnly(source?.InternalDeadline_c, "Internal Deadline")}T00:00:00.000Z`,
SvcQuestionsCollection_c: []
};
const parent = await requestSRInfo(parentSrId, debugRequests);
payload.ServiceRequest_Id_SRToInternalSR = String(parentSrId);
for (const name of ["AccountPartyId", "OpportunityName_Id_c"]) payload[name] = numericId(parent[name], name);
for (const name of ["OpportunityName_c", "RegionText_c", "SvcHealth_c", "Language_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(parent[name], name);
payload.SecondaryPillarNew_c = parent.SecondaryPillarNew_c == null ? null : text(parent.SecondaryPillarNew_c, "SecondaryPillarNew_c");
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const result = await fetchJson(url, { method: "POST", credentials: "include",
headers: await internalServiceRequestHeaders(url),
body: JSON.stringify(payload)
}, "requestPostInternalServiceRequest", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestSRInfo(parentSrId, debugRequests) {
const url = new URL("https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests");
url.searchParams.set("fields", "PrimaryPillarNew_c,SecondaryPillarNew_c,SvcHealth_c,SrNumber,AccountPartyId,CountryText_c,Language_c,OpportunityName_Id_c,OpportunityName_c,RegionText_c,ServiceNew_Id_c");
url.searchParams.set("onlyData", "true");
url.searchParams.set("orderBy", "LastUpdateDate:desc");
url.searchParams.set("q", `SrId=${parentSrId}`);
const result = await fetchJson(url.href, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestSRInfo", debugRequests);
if (!Array.isArray(result?.items) || result.items.length !== 1 || !result.items[0]
|| (result.items[0].SrId != null && String(result.items[0].SrId) !== String(parentSrId))) {
throw new Error("requestSRInfo did not return the selected parent Service Request.");
}
return result.items[0];
}
async function requestPostServiceRequest(message) {
const debugRequests = [];
try {
if (message.phase !== "prepare" && message.phase !== "confirm") throw new Error("Service Request confirmation is required.");
const payload = payloadFor(message.payload);
if (message.phase === "prepare") {
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
const sharedHeaders = await previewTokenHeaders(catalog);
return { ok: true, payload, debugRequests, headers: {
requestSrTokenCatalog: sharedHeaders,
requestPostServiceRequest: { ...sharedHeaders, "vb-proxy-header-x-oracle-ectoken": "(generated after confirmation)" }
} };
}
payload.ReportedByPartyId = numericId(message.payload?.ReportedByPartyId, "ReportedByPartyId");
const headers = await requestCreationHeaders(message.catalogId, endpoint, debugRequests);
const result = await fetchJson(endpoint, {
method: "POST", credentials: "include", headers,
body: JSON.stringify(payload)
}, "requestPostServiceRequest", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
annotateSigningRequest(debugRequests);
}
}
async function requestCreationHeaders(id, target, debugRequests) {
if (id == null) {
const result = await fetchJson(`${catalog}?onlyData=true&fields=Id&limit=1`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestSrCatalog", debugRequests);
id = result?.items?.[0]?.Id;
}
id = numericId(id, "Catalogue Id");
const tokenUrl = `${catalog}/${id}`;
let catalogHeaders = await tokenHeaders(tokenUrl, target);
const requestToken = () => fetchJson(tokenUrl, {
method: "POST", credentials: "include", headers: catalogHeaders, body: JSON.stringify({ name: "getJwtToken" })
}, "requestSrTokenCatalog", debugRequests);
let token;
try {
token = await requestToken();
} catch (error) {
if (error.httpStatus !== 403) throw error;
const refreshed = await previewTokenHeaders(tokenUrl);
if (!refreshed["x-appbuilder-client-id"] || refreshed["x-appbuilder-client-id"] === catalogHeaders["x-appbuilder-client-id"]) throw error;
catalogHeaders = refreshed;
token = await requestToken();
}
if (debugRequests.at(-1)?.httpStatus !== 200 || typeof token?.result !== "string" || !token.result.trim()) {
throw new Error("requestSrTokenCatalog did not return HTTP 200 with a token.");
}
return { ...catalogHeaders, "vb-proxy-header-x-oracle-ectoken": token.result.trim() };
}
function annotateSigningRequest(debugRequests) {
for (const entry of debugRequests) {
if (entry.name === "requestSrTokenCatalog") {
entry.metadata.appBuilderClientId = entry.requestHeaders?.["x-appbuilder-client-id"] ? "Observed in SeaaS requests" : "Not observed in SeaaS requests";
}
}
}
return { requestPostServiceRequest, requestPostInternalServiceRequest };
}
// Source: src/services/spa/current-user.js
function createSpaCurrentUser({ fetchJson }) {
async function requestCurrentUser() {
@@ -662,12 +1215,6 @@ function createSpaCurrentUser({ fetchJson }) {
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests;
throw error;
} finally {
// The identity token is not needed by content scripts or debug, including on failures.
for (const entry of debugRequests) {
entry.responseBody = JSON.stringify(user ? { user, token: "(redacted)" } : { error: "Identity response omitted." }, null, 2);
entry.responseBodyTruncated = false;
}
}
}
return { requestCurrentUser };
@@ -848,11 +1395,11 @@ function createSpaResponseShapes() {
// Source: src/services/http/background-json.js
function createHttpBackgroundJson({
previewDebugBody,
DEBUG_BODY_LIMIT,
observeResponseHeaders = () => {},
environment
}) {
const { fetch } = environment;
async function fetchJson(url, options, requestName, debugRequests = null) {
async function fetchJson(url, options, requestName, debugRequests = null, { allowEmpty = false } = {}) {
const startedAt = new Date();
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
let response;
@@ -865,6 +1412,7 @@ function createHttpBackgroundJson({
throw error;
}
observeResponseHeaders(response.url || url, response.headers);
const responsePayload = await readResponsePayload(response);
const responseBody = createDebugBody(responsePayload.text);
completeBackgroundDebugEntry(debugEntry, response, responseBody, "");
@@ -873,7 +1421,10 @@ function createHttpBackgroundJson({
appendBackgroundDebugEntry(debugRequests, debugEntry);
if (!response.ok) {
throw new Error(`${requestName} failed with status ${response.status}.`);
const error = new Error(`${requestName} failed with status ${response.status}.`);
error.httpStatus = response.status;
debugEntry.error = error.message;
throw error;
}
if (responsePayload.error) {
@@ -881,6 +1432,7 @@ function createHttpBackgroundJson({
}
try {
if (allowEmpty && !responsePayload.text.trim()) return null;
return parseJsonResponse(responsePayload.text, requestName);
} catch (error) {
error.httpStatus = response.status;
@@ -969,7 +1521,7 @@ function createHttpBackgroundJson({
}
function createDebugBody(text) {
return previewDebugBody(text, DEBUG_BODY_LIMIT);
return previewDebugBody(text);
}
function normalizeDebugHeaders(headers) {
@@ -1134,8 +1686,17 @@ function createProductsCache({
storageLocalGet,
storageLocalSet
}) {
let generation = 0;
async function invalidate() {
generation += 1;
productsCacheState.inFlightProducts.clear();
productsCacheState.productsCachePromise = Promise.resolve({});
await saveProductsCache({});
}
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
const currentGeneration = generation;
const cache = await getProductsCache();
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
const cachedEntry = cache[optyNumber];
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
@@ -1153,15 +1714,14 @@ function createProductsCache({
})
.then(async (items) => {
const fetchedAt = Date.now();
cache[optyNumber] = {
fetchedAt,
items
};
if (generation === currentGeneration) {
cache[optyNumber] = { fetchedAt, items };
await saveProductsCache(cache).catch(() => {});
}
return { items, fetchedAt };
})
.finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber);
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
});
}
@@ -1207,7 +1767,7 @@ function createProductsCache({
return productsCacheState.productsCacheWritePromise;
}
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
}
// Source: src/services/fusion/products.js
@@ -1720,12 +2280,27 @@ function startBackgroundApplication(dependencies) {
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
invalidateProductsCache,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestPostServiceRequest,
requestPostInternalServiceRequest,
requestInternalServiceRequest,
requestSrLookupPrimaryPillar,
requestSrLookupWithSession,
prepareSrSession,
requestSrLookupLanguage,
requestSrLookupTechPlatform,
requestLookupTypePlatform,
requestInternalSrServices,
requestSrLookupOpportunityBusiness,
requestCurrentUser,
requestConsumerTokenService,
requestConsumptionTrend,
@@ -1738,7 +2313,39 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
if (message.type === messageTypes.requestPostInternalServiceRequest) {
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestPostServiceRequest) {
requestPostServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.prepareSrSession) {
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
const srLookups = {
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
[messageTypes.requestSrLookupOpportunityBusiness]: requestSrLookupOpportunityBusiness
};
if (Object.hasOwn(srLookups, message.type)) {
requestSrLookupWithSession(srLookups[message.type], message, sender).then(sendResponse).catch(error => sendResponse({ ok: false,
httpStatus: error.httpStatus, error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.invalidateProductsCache) {
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
return true;
}
if (message.type === messageTypes.getXsrfToken) {
getXsrfTokenCookie()
.then((result) => {
@@ -1816,6 +2423,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
request(message, sender).then(sendResponse).catch(error => sendResponse({
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestWlActions) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",
@@ -1947,8 +2563,8 @@ function createBackgroundApplication(overrides = {}) {
});
const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({
observeResponseHeaders: (...args) => srRequestHeaders.observeResponseHeaders(...args),
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment
});
const productsBackgroundJobs = createProductsBackgroundJobs({
@@ -2006,6 +2622,9 @@ function createBackgroundApplication(overrides = {}) {
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
@@ -2013,8 +2632,29 @@ function createBackgroundApplication(overrides = {}) {
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
const srRequestHeaders = createSrRequestHeaders({ runtimeApi });
const fusionSrCreate = createFusionSrCreate({
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
previewTokenHeaders: url => srRequestHeaders.previewTokenHeaders(url)
});
const fusionSrDetails = createFusionSrDetails({
cookieChanges: runtimeApi?.cookies?.onChanged,
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
supportsFirefoxCookieStoreTabs: () => platformBackgroundApi.supportsFirefoxCookieStoreTabs(),
wait: (...args) => platformBackgroundApi.wait(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
});
function start() {
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
srRequestHeaders.start();
startBackgroundApplication({
requestLookupTypePlatform: () => fusionSrDetails.requestLookupTypePlatform(),
requestPostInternalServiceRequest: message => fusionSrCreate.requestPostInternalServiceRequest(message),
requestInternalSrServices: message => fusionSrDetails.requestInternalSrServices(message),
requestPostServiceRequest: message => fusionSrCreate.requestPostServiceRequest(message), prepareSrSession: sender => fusionSrDetails.prepareSession(sender), requestSrLookupWithSession: (...args) => fusionSrDetails.requestLookupWithSession(...args), messageTypes, requestSrLookupLanguage: () => fusionSrDetails.requestSrLookupLanguage(), requestSrLookupTechPlatform: () => fusionSrDetails.requestSrLookupTechPlatform(), requestSrLookupOpportunityBusiness: message => fusionSrDetails.requestSrLookupOpportunityBusiness(message), requestSrLookupPrimaryPillar: () => fusionSrDetails.requestSrLookupPrimaryPillar(), requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

2551
dist/chromium/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,14 +2,17 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.2",
"version": "1.0.3",
"permissions": [
"cookies",
"storage"
"storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
],
"host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*",
"https://spa.oracle.com/*"
"https://spa.oracle.com/*",
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
],
"content_scripts": [
{

View File

@@ -14,7 +14,21 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
requestSrLookupPrimaryPillar: "opportunitiesExtension.requestSrLookupPrimaryPillar",
requestPostServiceRequest: "opportunitiesExtension.requestPostServiceRequest",
requestPostInternalServiceRequest: "opportunitiesExtension.requestPostInternalServiceRequest",
prepareSrSession: "opportunitiesExtension.prepareSrSession",
requestSrLookupLanguage: "opportunitiesExtension.requestSrLookupLanguage",
requestSrLookupTechPlatform: "opportunitiesExtension.requestSrLookupTechPlatform",
requestLookupTypePlatform: "opportunitiesExtension.requestLookupTypePlatform",
requestInternalSrServices: "opportunitiesExtension.requestInternalSrServices",
requestSrLookupOpportunityBusiness: "opportunitiesExtension.requestSrLookupOpportunityBusiness",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
@@ -22,45 +36,8 @@ function createMessageTypes() {
}
// Source: src/shared/debug-body.js
function createDebugBodyPreview(text, limit) {
if (text.length <= limit) return { value: text || "(empty response body)", truncated: false };
try {
const normalized = text.replace(/^\uFEFF/, "").trimStart()
.replace(/^\)\]\}',?\s*/, "").replace(/^while\s*\(1\);\s*/, "");
let value = JSON.parse(normalized);
for (let depth = 0; depth < 2 && typeof value === "string" && /^\s*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; }
}
// Bound retained data without cutting JSON syntax or losing sensitive field names.
let remaining = Math.max(0, limit - 256);
const visit = (node, depth = 0) => {
if (remaining <= 64 || depth >= 30) return "[omitted from debug preview]";
if (node === null || typeof node !== "object") {
const serialized = JSON.stringify(node);
if (serialized.length > remaining) {
remaining = 0;
return "[omitted from debug preview]";
}
remaining -= serialized.length + 1;
return node;
}
const result = Array.isArray(node) ? [] : Object.create(null);
remaining -= 2;
for (const key of Object.keys(node)) {
const cost = Array.isArray(node) ? 1 : JSON.stringify(key).length + 2;
if (remaining < cost + 64) break;
remaining -= cost;
result[key] = visit(node[key], depth + 1);
}
return result;
};
return {
value: JSON.stringify({ debugNotice: "Partial response preview: remaining fields/items omitted due to the debug size limit.", preview: visit(value) }),
truncated: true
};
} catch {
return { value: `${text.slice(0, limit)}\n... truncated ${text.length - limit} characters`, truncated: true };
}
function createDebugBodyPreview(text) {
return { value: text || "(empty response body)", truncated: false };
}
// Source: src/background/contracts.js
@@ -249,14 +226,22 @@ function createAuthConsumerSession({
const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE"
&& error.httpStatus === 200;
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
const retryReason = isSessionUnauthorized
? `HTTP ${error.httpStatus} response requires SPA session recovery`
: "HTTP 200 response did not contain valid JSON";
const debugEntry = debugRequests[debugRequests.length - 1];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) {
if (needsSessionRecovery) {
debugEntry.metadata.retryLimitReached = "true";
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
debugEntry.metadata.retryReason = retryReason;
debugEntry.error = isHtmlFallback
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
error.message = debugEntry.error;
}
}
@@ -284,7 +269,7 @@ function createAuthConsumerSession({
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
debugEntry.metadata.retryReason = retryReason;
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
}
@@ -412,6 +397,117 @@ function createAuthSpaCredentials({
return { normalizeMessageAuthorization, extractTokenServiceAuthorization };
}
// Source: src/auth/sr-request-headers.js
function createSrRequestHeaders({ runtimeApi }) {
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
const appPath = "/ic/builder/rt/SeaaS_initiation/";
const internalPath = "/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const storageKey = "opportunitiesExtension.srAppBuilderClientId.v1";
const maxAge = 60 * 60 * 1000;
let observed = null;
let started = false;
function valid(record) {
return record?.origin === origin && typeof record.value === "string"
&& /^[\x21-\x7e]{1,2048}$/.test(record.value)
&& Number.isFinite(record.observedAt) && record.observedAt <= Date.now()
&& Date.now() - record.observedAt < maxAge;
}
function observeHeaders(rawUrl, headers) {
let url;
try { url = new URL(rawUrl); } catch { return; }
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management)\/live(?:\/|;)/.test(url.pathname)) return;
// Read only the client ID, not cookies, bodies or authorization.
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
const record = { origin, value: value?.trim(), observedAt: Date.now() };
if (!valid(record)) return;
observed = record;
// Session storage survives service-worker suspension without persisting across browser sessions.
runtimeApi.storage?.session?.set({ [storageKey]: record }).catch(() => {});
}
function start() {
if (started || !runtimeApi?.webRequest) return;
started = true;
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`], types: ["xmlhttprequest"] };
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
}, filter, ["requestHeaders"]);
runtimeApi.webRequest.onHeadersReceived?.addListener(details => {
if (!details.incognito) observeHeaders(details.url, details.responseHeaders);
}, filter, ["responseHeaders"]);
}
async function previewTokenHeaders(url) {
const target = new URL(url);
if (target.origin !== origin || !target.pathname.startsWith(appPath) || !/\/Catalogue_c(?:\/\d+)?$/.test(target.pathname)) {
throw new Error("Unexpected Service Request token URL.");
}
return sessionHeaders(target.origin, "application/vnd.oracle.adf.action+json");
}
async function sessionHeaders(targetOrigin, contentType) {
if (!valid(observed)) {
try {
const stored = await runtimeApi.storage?.session?.get(storageKey);
if (valid(stored?.[storageKey]) && !valid(observed)) observed = stored[storageKey];
} catch { /* A missing capture must not prevent trying the other required headers. */ }
}
return {
Accept: "application/json", "Content-Type": contentType,
Authorization: "Session", Origin: targetOrigin,
...(valid(observed) ? { "x-appbuilder-client-id": observed.value } : {})
};
}
async function tokenHeaders(url, serviceRequestUrl) {
const headers = await previewTokenHeaders(url);
if (!/\/Catalogue_c\/\d+$/.test(new URL(url).pathname)) throw new Error("Missing Catalogue Id.");
const serviceTarget = new URL(serviceRequestUrl);
const isServiceRequest = serviceTarget.pathname.startsWith(appPath) && serviceTarget.pathname.endsWith("/set/seaas/mgmt/serviceRequests");
if (serviceTarget.origin !== headers.Origin || !isServiceRequest || serviceTarget.search || serviceTarget.hash) {
throw new Error("Unexpected Service Request URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the Service Request token header permissions.");
}
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
const catalogUrl = url.slice(0, url.lastIndexOf("/") + 1);
const escapedCatalogUrl = catalogUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedServiceUrl = serviceTarget.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ruleIds = [7201, 7202];
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: ruleIds,
addRules: [`^${escapedCatalogUrl}[0-9]+$`, `^${escapedServiceUrl}$`].map((regexFilter, index) => ({
id: ruleIds[index], priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
condition: { regexFilter, initiatorDomains: [extensionDomain],
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
}))
});
return headers;
}
async function internalServiceRequestHeaders(url) {
const target = new URL(url);
if (target.origin !== origin || target.pathname !== internalPath || target.search || target.hash) {
throw new Error("Unexpected Internal Service Request URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the Internal Service Request header permissions.");
}
const headers = await sessionHeaders(target.origin, "application/json");
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: [7203],
addRules: [{
id: 7203, priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [extensionDomain],
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
}]
});
return headers;
}
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, observeResponseHeaders: observeHeaders };
}
// Source: src/services/spa/accounts.js
function createSpaAccounts({
ACCOUNT_PLANS_PAYLOAD,
@@ -644,6 +740,463 @@ function createSpaWorkloadActions({ fetchJson }) {
return { requestWlActions };
}
// Source: src/services/spa/action-options.js
function createSpaActionOptions({ fetchJson }) {
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
const lookupKeys = ["WWB_ACTION_ROLE", "WWB_ACTION_NAME", "WWB_ACTION_TEAM", "WWB_ACTION_STATUS", "WWB_ACTION_COMPLEXITY"];
async function request(message, name, path, normalize) {
const debugRequests = [];
try {
const token = message.authorization?.value;
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const result = await fetchJson(`${baseUrl}/${path()}`, {
method: "GET", credentials: "include",
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
}, name, debugRequests);
if (debugRequests.at(-1)?.httpStatus !== 200) throw new Error(`${name} requires HTTP 200.`);
return { ok: true, ...normalize(result), debugRequests };
} catch (error) {
const entry = debugRequests.at(-1);
if (entry) { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests;
throw error;
} finally {
for (const entry of debugRequests) {
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
}
}
}
function requestActionTeamMembers(message) {
return request(message, "requestActionTeamMembers", () => {
const id = String(message.workloadId ?? "").trim();
if (!/^\d+$/.test(id)) throw new Error("A numeric RevnId is required.");
return `${encodeURIComponent(id)}/teamMembers?includeLeads=true`;
}, result => {
if (!Array.isArray(result) || result.some(item => typeof item?.email !== "string" || !item.email.trim())) {
throw new Error("requestActionTeamMembers returned an invalid collection.");
}
return { members: result.map(item => ({ email: item.email })) };
});
}
function requestActionFindLookup(message) {
return request(message, "requestActionFindLookup", () => "fndLookup", result => {
const lookups = {};
for (const key of lookupKeys) {
if (!Array.isArray(result?.[key]) || result[key].some(item => typeof item?.code !== "string" || !item.code.trim()
|| typeof item?.name !== "string" || !item.name.trim())) {
throw new Error(`requestActionFindLookup returned an invalid ${key} collection.`);
}
lookups[key] = result[key].map(({ code, name }) => ({ code, name }));
}
return { lookups };
});
}
async function requestMgmtAction(message) {
const debugRequests = [];
try {
const token = message.authorization?.value;
const payload = message.payload;
const action = payload?.action;
if (!token || typeof token !== "string") throw new Error("WWB-TS uiaasHeader.value is unavailable.");
if (payload?.parentType !== "WORKLOAD" || !/^\d+$/.test(String(payload.parentId))) throw new Error("A numeric RevnId is required.");
for (const key of ["ownerEmail", "role", "name", "team", "status", "complexity", "createdBy", "updatedBy"]) {
if (typeof action?.[key] !== "string" || !action[key].trim()) throw new Error(`Missing ${key}.`);
}
for (const key of ["startDate", "endDate"]) {
if (typeof action[key] !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(action[key]) || !Number.isFinite(Date.parse(action[key]))) throw new Error(`Invalid ${key}.`);
}
if (action.endDate < action.startDate) throw new Error("End date must not precede start date.");
if (!Array.isArray(action.notes) || action.notes.some(note => typeof note !== "string")) throw new Error("Invalid notes.");
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", "Wwb-Provider-Authorization": token.trim() },
body: JSON.stringify(payload)
}, "requestMgmtAction", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
for (const entry of debugRequests) {
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
}
}
}
return { requestActionTeamMembers, requestActionFindLookup, requestMgmtAction };
}
// Source: src/services/fusion/sr-details.js
function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFirefoxCookieStoreTabs, cookiesGetAll, cookieChanges, wait }) {
const sessions = new Map();
function cookiePart(cookie) {
if (!cookie) return null;
const match = /^ORA_OCIS_CG_SESSION_idcs-([a-zA-Z0-9-]+)_comcipapic-oalprod\.integration\.ocp\.oraclecloud\.com_([12])$/.exec(cookie.name);
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const cookieDomain = cookie.domain?.replace(/^\./, "");
if (!match || (cookieDomain && cookieDomain !== domain && !domain.endsWith(`.${cookieDomain}`))) return null;
return { key: `${match[1]}|${cookie.path || "/"}|${cookie.storeId || ""}`, part: match[2],
valid: Boolean(cookie.value) && (cookie.expirationDate == null || cookie.expirationDate > Date.now() / 1000) };
}
function watchSession(session, sender) {
const pairs = new Map();
let available;
session.cookieReady = new Promise(resolve => { available = resolve; });
const listener = change => {
const cookie = change.cookie;
const part = cookiePart(cookie);
if (!part) return;
if (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId) return;
if (!pairs.has(part.key)) pairs.set(part.key, new Map());
const pair = pairs.get(part.key);
if (change.removed || !part.valid) pair.delete(part.part);
else pair.set(part.part, cookie);
if (pair.size === 2 && [...pair.values()].every(item => cookiePart(item)?.valid)) available(new Set([part.key]));
session.wake?.();
};
cookieChanges?.addListener(listener);
session.unwatch = () => cookieChanges?.removeListener(listener);
}
function waitForSessionCookies(session, sender) {
if (session.cookieWait) return session.cookieWait;
session.cookieWait = (async () => {
const deadline = Date.now() + 1000;
for (let attempt = 0; attempt < 5; attempt += 1) {
// Install the wake-up before reading so a renewal during getAll is not missed.
const changed = new Promise(resolve => { session.wake = resolve; });
const current = await sessionCookies(sender);
const renewed = [...current].some(pair => !session.baseline.has(pair));
if (renewed || Date.now() >= deadline) {
session.baseline = current;
return current;
}
await Promise.race([changed, wait(250)]);
}
return sessionCookies(sender);
})().finally(() => { session.cookieWait = null; session.wake = null; });
return session.cookieWait;
}
async function sessionCookies(sender) {
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const details = { domain };
if (sender?.tab?.cookieStoreId) details.storeId = sender.tab.cookieStoreId;
try {
const groups = new Map();
for (const cookie of await cookiesGetAll(details)) {
const part = cookiePart(cookie);
if (!part?.valid) continue;
if (!groups.has(part.key)) groups.set(part.key, {});
groups.get(part.key)[part.part] = [cookie.name, cookie.value, cookie.expirationDate ?? null];
}
// Fingerprints remain in memory only; the browser sends the actual Cookie header.
return new Set([...groups.values()].filter(pair => pair[1] && pair[2]).map(pair => JSON.stringify([pair[1], pair[2]])));
} catch { return new Set(); }
}
async function requestLookupWithSession(operation, message, sender, initialize = false) {
const existingCookies = initialize ? await sessionCookies(sender) : null;
if (existingCookies?.size) return { ok: true, debugRequests: [] };
const logs = [];
const query = async () => {
try {
const result = await operation(message);
logs.push(...(result.debugRequests || []));
return { ...result, debugRequests: logs };
} catch (error) {
logs.push(...(error.debugRequests || []));
error.debugRequests = logs;
throw error;
}
};
if (!initialize) {
try { return await query(); } catch (error) { if (error.httpStatus !== 401) throw error; }
}
const key = sender?.tab?.cookieStoreId || "default";
let session = sessions.get(key);
if (!session) {
const details = {
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
active: false
};
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
if (supportsFirefoxCookieStoreTabs() && sender?.tab?.cookieStoreId) details.cookieStoreId = sender.tab.cookieStoreId;
session = { users: 0, closed: null, baseline: null };
watchSession(session, sender);
session.tab = (async () => {
session.baseline = existingCookies || await sessionCookies(sender);
return tabsCreate(details);
})();
sessions.set(key, session);
}
session.users += 1;
let tab;
try {
tab = await session.tab;
const deadline = Date.now() + 60000;
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) {
const pendingCookies = initialize && attempt === 0
? sessionCookies(sender) : waitForSessionCookies(session, sender);
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
if (initialize) {
// Proactive preparation observes cookies only; API requests belong to the form.
if (!cookies.size) continue;
await closeSessionTab();
return { ok: true, debugRequests: [] };
}
try {
const result = await query();
// Any successful lookup confirms the shared session; stop the page immediately.
await closeSessionTab();
return result;
} catch (error) {
if (error.httpStatus !== 401) throw error;
}
}
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry.");
} catch (error) {
error.debugRequests = logs;
throw error;
} finally {
session.users -= 1;
if (session.users === 0) {
sessions.delete(key);
session.unwatch();
await closeSessionTab();
}
}
function closeSessionTab() {
if (!session.closed && Number.isInteger(tab?.id)) {
session.closed = tabsRemove(tab.id).catch(() => {});
}
return session.closed;
}
}
async function requestInternalServiceRequest(message) {
const debugRequests = [];
try {
const id = String(message.srId ?? "");
if (!/^\d+$/.test(id)) throw new Error("A numeric SrId is required.");
const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId";
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests"
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
const query = async () => {
const result = await fetchJson(url, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, "requestInternalServiceRequest", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
return result;
};
const result = await query();
const items = result.items.flatMap(item => {
const children = item.ServiceRequest_SRToInternalSR_Tgt;
if (children == null) return [];
if (!Array.isArray(children) || children.some(child => !child || typeof child !== "object")) throw new Error("Invalid linked SR collection.");
return children;
});
return { ok: true, items, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
async function requestLookup(lookupType, requestName) {
const debugRequests = [];
try {
const url = `https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/lookups?lookupType=${lookupType}&type=STANDARD`;
const result = await fetchJson(url, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, requestName, debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => typeof item?.code !== "string" || typeof item?.name !== "string")) throw new Error(`Invalid ${requestName} response.`);
const items = result.items.filter(item => item.enabledFlag !== "N")
.sort((a, b) => (Number(a.displaySequence) || 0) - (Number(b.displaySequence) || 0))
.map(({ code, name }) => ({ code, name }));
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestSrLookupOpportunityBusiness(message) {
const debugRequests = [];
try {
if (typeof message.emailAddress !== "string" || !message.emailAddress.trim()) throw new Error("Current user email is unavailable.");
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/services"
+ `?emailAddress=${encodeURIComponent(message.emailAddress.trim())}&salesObject=OPPORTUNITY&serviceType=BUSINESS`;
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" } }, "requestSrLookupOpportunityBusiness", debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => !/^\d+$/.test(String(item?.Id ?? "")) || typeof item?.RecordName !== "string")) throw new Error("Invalid Services response.");
return { ok: true, items: result.items.map(({ Id, RecordName, Abstract_c }) => ({ Id: String(Id), RecordName, Abstract_c: Abstract_c == null ? "" : String(Abstract_c) })), debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestInternalSrOptions(message, services = false) {
const debugRequests = [];
const name = services ? "requestInternalSrServices" : "requestLookupTypePlatform";
try {
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetSeaaSOKECustomRestAPI/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/";
let path = "lookups?type=STANDARD&lookupType=SENGG_TECH_PLATFORM";
if (services) {
const id = String(message.parentServiceId ?? "");
if (!/^\d+$/.test(id)) throw new Error("A numeric parent SrId is required.");
if (typeof message.emailAddress !== "string" || !message.emailAddress.trim()) throw new Error("Current user email is unavailable.");
path = `services?emailAddress=${encodeURIComponent(message.emailAddress.trim())}&parentServiceId=${encodeURIComponent(id)}&serviceType=INTERNAL`;
}
const result = await fetchJson(base + path, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, name, debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => services
? !/^\d+$/.test(String(item?.Id ?? "")) || typeof item?.RecordName !== "string"
: typeof item?.code !== "string" || typeof item?.name !== "string")) throw new Error(`Invalid ${name} response.`);
const items = services
? result.items.map(item => ({ Id: String(item.Id), RecordName: item.RecordName, Description_c: String(item.Description_c ?? "") }))
: result.items.filter(item => item.enabledFlag !== "N").map(({ code, name }) => ({ code, name }));
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
return { requestLookupWithSession, requestInternalServiceRequest, requestSrLookupOpportunityBusiness,
requestLookupTypePlatform: () => requestInternalSrOptions({}),
requestInternalSrServices: message => requestInternalSrOptions(message, true),
prepareSession: sender => requestLookupWithSession(null, {}, sender, true),
requestSrLookupPrimaryPillar: () => requestLookup("SENGG_PRIMARY_PILLAR", "requestSrLookupPrimaryPillar"),
requestSrLookupLanguage: () => requestLookup("SENGG_LANGUAGE", "requestSrLookupLanguage"),
requestSrLookupTechPlatform: () => requestLookup("SENGG_TECH_PLATFORM", "requestSrLookupTechPlatform") };
}
// Source: src/services/fusion/sr-create.js
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders }) {
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/";
const catalog = base + "crmRestApiResources/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/11.13.18.05/Catalogue_c";
const resourceUsers = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers";
const endpoint = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/serviceRequests";
function numericId(value, name) {
if (!/^\d+$/.test(String(value ?? "")) || !Number.isSafeInteger(Number(value)) || Number(value) <= 0) throw new Error(`Missing or invalid ${name}.`);
return Number(value);
}
function text(value, name) {
if (typeof value !== "string" || !value.trim()) throw new Error(`Missing ${name}.`);
return value.trim();
}
function dateOnly(value, name) {
const date = text(value, name);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(`${date}T00:00:00Z`))
|| new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date) throw new Error(`Invalid ${name}.`);
return date;
}
function payloadFor(input) {
const payload = {};
for (const name of ["AccountPartyId", "OpportunityName_Id_c", "ServiceNew_Id_c"]) payload[name] = numericId(input?.[name], name);
for (const name of ["Title", "RegionText_c", "ProblemDescription", "Language_c", "CustomerDeadline_c", "TechPlatformLookUp_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(input?.[name], name);
dateOnly(payload.CustomerDeadline_c, "Customer deadline");
return { ...payload, SvcQuestionsCollection_c: [], SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" };
}
async function requestResourceUser(email, debugRequests) {
const emailAddress = text(email, "current user email");
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestResourceUser", debugRequests);
return numericId(resource?.resourcePartyId, "resourcePartyId");
}
async function requestPostInternalServiceRequest(message) {
const debugRequests = [];
try {
const source = message.payload;
const parentSrId = numericId(source?.ServiceRequest_Id_SRToInternalSR, "parent SrId");
const payload = {
Title: text(source?.Title, "Title"),
ServiceNew_Id_c: numericId(source?.ServiceNew_Id_c, "ServiceNew_Id_c"),
ProblemDescription: text(source?.ProblemDescription, "Description"),
CustomerDeadline_c: dateOnly(source?.CustomerDeadline_c, "Customer deadline"),
TechPlatformLookUp_c: text(source?.TechPlatformLookUp_c, "Platform"),
InternalDeadline_c: `${dateOnly(source?.InternalDeadline_c, "Internal Deadline")}T00:00:00.000Z`,
SvcQuestionsCollection_c: []
};
const parent = await requestSRInfo(parentSrId, debugRequests);
payload.ServiceRequest_Id_SRToInternalSR = String(parentSrId);
for (const name of ["AccountPartyId", "OpportunityName_Id_c"]) payload[name] = numericId(parent[name], name);
for (const name of ["OpportunityName_c", "RegionText_c", "SvcHealth_c", "Language_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(parent[name], name);
payload.SecondaryPillarNew_c = parent.SecondaryPillarNew_c == null ? null : text(parent.SecondaryPillarNew_c, "SecondaryPillarNew_c");
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const result = await fetchJson(url, { method: "POST", credentials: "include",
headers: await internalServiceRequestHeaders(url),
body: JSON.stringify(payload)
}, "requestPostInternalServiceRequest", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestSRInfo(parentSrId, debugRequests) {
const url = new URL("https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests");
url.searchParams.set("fields", "PrimaryPillarNew_c,SecondaryPillarNew_c,SvcHealth_c,SrNumber,AccountPartyId,CountryText_c,Language_c,OpportunityName_Id_c,OpportunityName_c,RegionText_c,ServiceNew_Id_c");
url.searchParams.set("onlyData", "true");
url.searchParams.set("orderBy", "LastUpdateDate:desc");
url.searchParams.set("q", `SrId=${parentSrId}`);
const result = await fetchJson(url.href, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestSRInfo", debugRequests);
if (!Array.isArray(result?.items) || result.items.length !== 1 || !result.items[0]
|| (result.items[0].SrId != null && String(result.items[0].SrId) !== String(parentSrId))) {
throw new Error("requestSRInfo did not return the selected parent Service Request.");
}
return result.items[0];
}
async function requestPostServiceRequest(message) {
const debugRequests = [];
try {
if (message.phase !== "prepare" && message.phase !== "confirm") throw new Error("Service Request confirmation is required.");
const payload = payloadFor(message.payload);
if (message.phase === "prepare") {
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
const sharedHeaders = await previewTokenHeaders(catalog);
return { ok: true, payload, debugRequests, headers: {
requestSrTokenCatalog: sharedHeaders,
requestPostServiceRequest: { ...sharedHeaders, "vb-proxy-header-x-oracle-ectoken": "(generated after confirmation)" }
} };
}
payload.ReportedByPartyId = numericId(message.payload?.ReportedByPartyId, "ReportedByPartyId");
const headers = await requestCreationHeaders(message.catalogId, endpoint, debugRequests);
const result = await fetchJson(endpoint, {
method: "POST", credentials: "include", headers,
body: JSON.stringify(payload)
}, "requestPostServiceRequest", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
annotateSigningRequest(debugRequests);
}
}
async function requestCreationHeaders(id, target, debugRequests) {
if (id == null) {
const result = await fetchJson(`${catalog}?onlyData=true&fields=Id&limit=1`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestSrCatalog", debugRequests);
id = result?.items?.[0]?.Id;
}
id = numericId(id, "Catalogue Id");
const tokenUrl = `${catalog}/${id}`;
let catalogHeaders = await tokenHeaders(tokenUrl, target);
const requestToken = () => fetchJson(tokenUrl, {
method: "POST", credentials: "include", headers: catalogHeaders, body: JSON.stringify({ name: "getJwtToken" })
}, "requestSrTokenCatalog", debugRequests);
let token;
try {
token = await requestToken();
} catch (error) {
if (error.httpStatus !== 403) throw error;
const refreshed = await previewTokenHeaders(tokenUrl);
if (!refreshed["x-appbuilder-client-id"] || refreshed["x-appbuilder-client-id"] === catalogHeaders["x-appbuilder-client-id"]) throw error;
catalogHeaders = refreshed;
token = await requestToken();
}
if (debugRequests.at(-1)?.httpStatus !== 200 || typeof token?.result !== "string" || !token.result.trim()) {
throw new Error("requestSrTokenCatalog did not return HTTP 200 with a token.");
}
return { ...catalogHeaders, "vb-proxy-header-x-oracle-ectoken": token.result.trim() };
}
function annotateSigningRequest(debugRequests) {
for (const entry of debugRequests) {
if (entry.name === "requestSrTokenCatalog") {
entry.metadata.appBuilderClientId = entry.requestHeaders?.["x-appbuilder-client-id"] ? "Observed in SeaaS requests" : "Not observed in SeaaS requests";
}
}
}
return { requestPostServiceRequest, requestPostInternalServiceRequest };
}
// Source: src/services/spa/current-user.js
function createSpaCurrentUser({ fetchJson }) {
async function requestCurrentUser() {
@@ -662,12 +1215,6 @@ function createSpaCurrentUser({ fetchJson }) {
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests;
throw error;
} finally {
// The identity token is not needed by content scripts or debug, including on failures.
for (const entry of debugRequests) {
entry.responseBody = JSON.stringify(user ? { user, token: "(redacted)" } : { error: "Identity response omitted." }, null, 2);
entry.responseBodyTruncated = false;
}
}
}
return { requestCurrentUser };
@@ -848,11 +1395,11 @@ function createSpaResponseShapes() {
// Source: src/services/http/background-json.js
function createHttpBackgroundJson({
previewDebugBody,
DEBUG_BODY_LIMIT,
observeResponseHeaders = () => {},
environment
}) {
const { fetch } = environment;
async function fetchJson(url, options, requestName, debugRequests = null) {
async function fetchJson(url, options, requestName, debugRequests = null, { allowEmpty = false } = {}) {
const startedAt = new Date();
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
let response;
@@ -865,6 +1412,7 @@ function createHttpBackgroundJson({
throw error;
}
observeResponseHeaders(response.url || url, response.headers);
const responsePayload = await readResponsePayload(response);
const responseBody = createDebugBody(responsePayload.text);
completeBackgroundDebugEntry(debugEntry, response, responseBody, "");
@@ -873,7 +1421,10 @@ function createHttpBackgroundJson({
appendBackgroundDebugEntry(debugRequests, debugEntry);
if (!response.ok) {
throw new Error(`${requestName} failed with status ${response.status}.`);
const error = new Error(`${requestName} failed with status ${response.status}.`);
error.httpStatus = response.status;
debugEntry.error = error.message;
throw error;
}
if (responsePayload.error) {
@@ -881,6 +1432,7 @@ function createHttpBackgroundJson({
}
try {
if (allowEmpty && !responsePayload.text.trim()) return null;
return parseJsonResponse(responsePayload.text, requestName);
} catch (error) {
error.httpStatus = response.status;
@@ -969,7 +1521,7 @@ function createHttpBackgroundJson({
}
function createDebugBody(text) {
return previewDebugBody(text, DEBUG_BODY_LIMIT);
return previewDebugBody(text);
}
function normalizeDebugHeaders(headers) {
@@ -1134,8 +1686,17 @@ function createProductsCache({
storageLocalGet,
storageLocalSet
}) {
let generation = 0;
async function invalidate() {
generation += 1;
productsCacheState.inFlightProducts.clear();
productsCacheState.productsCachePromise = Promise.resolve({});
await saveProductsCache({});
}
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
const currentGeneration = generation;
const cache = await getProductsCache();
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
const cachedEntry = cache[optyNumber];
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
@@ -1153,15 +1714,14 @@ function createProductsCache({
})
.then(async (items) => {
const fetchedAt = Date.now();
cache[optyNumber] = {
fetchedAt,
items
};
if (generation === currentGeneration) {
cache[optyNumber] = { fetchedAt, items };
await saveProductsCache(cache).catch(() => {});
}
return { items, fetchedAt };
})
.finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber);
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
});
}
@@ -1207,7 +1767,7 @@ function createProductsCache({
return productsCacheState.productsCacheWritePromise;
}
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
}
// Source: src/services/fusion/products.js
@@ -1720,12 +2280,27 @@ function startBackgroundApplication(dependencies) {
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
invalidateProductsCache,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestPostServiceRequest,
requestPostInternalServiceRequest,
requestInternalServiceRequest,
requestSrLookupPrimaryPillar,
requestSrLookupWithSession,
prepareSrSession,
requestSrLookupLanguage,
requestSrLookupTechPlatform,
requestLookupTypePlatform,
requestInternalSrServices,
requestSrLookupOpportunityBusiness,
requestCurrentUser,
requestConsumerTokenService,
requestConsumptionTrend,
@@ -1738,7 +2313,39 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
if (message.type === messageTypes.requestPostInternalServiceRequest) {
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestPostServiceRequest) {
requestPostServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.prepareSrSession) {
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
const srLookups = {
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
[messageTypes.requestSrLookupOpportunityBusiness]: requestSrLookupOpportunityBusiness
};
if (Object.hasOwn(srLookups, message.type)) {
requestSrLookupWithSession(srLookups[message.type], message, sender).then(sendResponse).catch(error => sendResponse({ ok: false,
httpStatus: error.httpStatus, error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.invalidateProductsCache) {
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
return true;
}
if (message.type === messageTypes.getXsrfToken) {
getXsrfTokenCookie()
.then((result) => {
@@ -1816,6 +2423,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
request(message, sender).then(sendResponse).catch(error => sendResponse({
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestWlActions) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",
@@ -1947,8 +2563,8 @@ function createBackgroundApplication(overrides = {}) {
});
const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({
observeResponseHeaders: (...args) => srRequestHeaders.observeResponseHeaders(...args),
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment
});
const productsBackgroundJobs = createProductsBackgroundJobs({
@@ -2006,6 +2622,9 @@ function createBackgroundApplication(overrides = {}) {
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
@@ -2013,8 +2632,29 @@ function createBackgroundApplication(overrides = {}) {
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
const srRequestHeaders = createSrRequestHeaders({ runtimeApi });
const fusionSrCreate = createFusionSrCreate({
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
previewTokenHeaders: url => srRequestHeaders.previewTokenHeaders(url)
});
const fusionSrDetails = createFusionSrDetails({
cookieChanges: runtimeApi?.cookies?.onChanged,
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
supportsFirefoxCookieStoreTabs: () => platformBackgroundApi.supportsFirefoxCookieStoreTabs(),
wait: (...args) => platformBackgroundApi.wait(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
});
function start() {
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
srRequestHeaders.start();
startBackgroundApplication({
requestLookupTypePlatform: () => fusionSrDetails.requestLookupTypePlatform(),
requestPostInternalServiceRequest: message => fusionSrCreate.requestPostInternalServiceRequest(message),
requestInternalSrServices: message => fusionSrDetails.requestInternalSrServices(message),
requestPostServiceRequest: message => fusionSrCreate.requestPostServiceRequest(message), prepareSrSession: sender => fusionSrDetails.prepareSession(sender), requestSrLookupWithSession: (...args) => fusionSrDetails.requestLookupWithSession(...args), messageTypes, requestSrLookupLanguage: () => fusionSrDetails.requestSrLookupLanguage(), requestSrLookupTechPlatform: () => fusionSrDetails.requestSrLookupTechPlatform(), requestSrLookupOpportunityBusiness: message => fusionSrDetails.requestSrLookupOpportunityBusiness(message), requestSrLookupPrimaryPillar: () => fusionSrDetails.requestSrLookupPrimaryPillar(), requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

2551
dist/firefox/content.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -2,14 +2,17 @@
"manifest_version": 3,
"name": "Opportunities Extension",
"description": "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
"version": "1.0.2",
"version": "1.0.3",
"permissions": [
"cookies",
"storage"
"storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
],
"host_permissions": [
"https://eeho.fa.us2.oraclecloud.com/*",
"https://spa.oracle.com/*"
"https://spa.oracle.com/*",
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
],
"content_scripts": [
{

View File

@@ -12,7 +12,7 @@ pass until executed and recorded separately for each browser.
- Run `node scripts/build.mjs --check` and `node scripts/test.cjs` first.
- Load `dist/chromium` using Load unpacked in Chrome/Edge, or load
`dist/firefox/manifest.json` as a temporary Firefox add-on. Verify the source
directory and version 1.0.2; avoid testing a previously installed stale copy.
directory and version 1.0.3; avoid testing a previously installed stale copy.
- Keep debug secrets hidden. Record only status codes, request names, durations
and sanitized errors. Do not export real tokens, response bodies or account data.
- AI generation and tests requiring a deliberately expired session must use an

View File

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

View File

@@ -14,14 +14,17 @@ const baseManifest = {
manifest_version: 3,
name: "Opportunities Extension",
description: "Adiciona um atalho de Opportunities Extension nas paginas Oracle Fusion permitidas.",
version: "1.0.2",
version: "1.0.3",
permissions: [
"cookies",
"storage"
"storage",
"webRequest",
"declarativeNetRequestWithHostAccess"
],
host_permissions: [
"https://eeho.fa.us2.oraclecloud.com/*",
"https://spa.oracle.com/*"
"https://spa.oracle.com/*",
"https://comcipapic-oalprod.integration.ocp.oraclecloud.com/*"
],
content_scripts: [
{

View File

@@ -2,6 +2,7 @@ const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const sources = require("./sources.cjs");
const vendorSources = { content: ["src/vendor/fullcalendar/index.global.min.js"], background: [] };
const root = path.resolve(__dirname, "..");
@@ -16,7 +17,10 @@ function composeScript(target, { bootstrap = true } = {}) {
return `// Source: ${file}\n${source}`;
});
if (target === "content") {
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"]
const calendarLibrary = fs.readFileSync(path.join(root, vendorSources.content[0]), "utf8");
const calendarLicense = fs.readFileSync(path.join(root, "src/vendor/fullcalendar/LICENSE.md"), "utf8");
parts.unshift(`function loadOpportunityCalendarLibrary() {\n/* ${calendarLicense} */\n${calendarLibrary}\nreturn FullCalendar;\n}`);
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
}
@@ -27,4 +31,4 @@ function composeScript(target, { bootstrap = true } = {}) {
return output;
}
module.exports = { composeScript, sources };
module.exports = { composeScript, sources, vendorSources };

View File

@@ -16,6 +16,7 @@ module.exports = {
"src/features/opportunities/dashboard-state.js",
"src/features/debug/state.js",
"src/features/opportunities/columns.js",
"src/features/opportunities/badge-content.js",
"src/content/fusion-page.js",
"src/content/modal.js",
"src/content/theme.js",
@@ -32,10 +33,23 @@ module.exports = {
"src/services/fusion/opportunities.js",
"src/services/fusion/crm-service-requests.js",
"src/features/opportunities/crm-service-requests.js",
"src/features/opportunities/product-activities.js",
"src/features/opportunities/refresh-all.js",
"src/features/opportunities/activity-cache.js",
"src/features/opportunities/activity-progress.js",
"src/features/opportunities/activity-filters.js",
"src/features/opportunities/crm-sr-popover.js",
"src/features/opportunities/crm-sr-form.js",
"src/features/opportunities/internal-sr-form.js",
"src/features/opportunities/searchable-select.js",
"src/features/opportunities/internal-sr-session.js",
"src/features/opportunities/sr-session-preparation.js",
"src/features/opportunities/workload-actions.js",
"src/features/opportunities/workload-action-form.js",
"src/features/opportunities/workload-action-options.js",
"src/features/opportunities/fiscal-periods.js",
"src/features/opportunities/table.js",
"src/features/opportunities/calendar.js",
"src/features/opportunities/dashboard.js",
"src/features/opportunities/filters.js",
"src/features/opportunities/formatters.js",
@@ -73,10 +87,14 @@ module.exports = {
"src/features/products/cache-state.js",
"src/auth/consumer-session.js",
"src/auth/spa-credentials.js",
"src/auth/sr-request-headers.js",
"src/services/spa/accounts.js",
"src/services/spa/install-base.js",
"src/services/spa/customer-service-requests.js",
"src/services/spa/workload-actions.js",
"src/services/spa/action-options.js",
"src/services/fusion/sr-details.js",
"src/services/fusion/sr-create.js",
"src/services/spa/current-user.js",
"src/services/spa/consumption.js",
"src/services/spa/response-shapes.js",

View File

@@ -103,14 +103,22 @@ function createAuthConsumerSession({
const isHtmlFallback = error
&& error.code === "INVALID_JSON_RESPONSE"
&& error.httpStatus === 200;
const isSessionUnauthorized = error && (error.httpStatus === 401 || error.httpStatus === 403);
const needsSessionRecovery = isHtmlFallback || isSessionUnauthorized;
const retryReason = isSessionUnauthorized
? `HTTP ${error.httpStatus} response requires SPA session recovery`
: "HTTP 200 response did not contain valid JSON";
const debugEntry = debugRequests[debugRequests.length - 1];
if (!isHtmlFallback || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (!needsSessionRecovery || attempt >= CUSTOMER_TOKEN_MAX_ATTEMPTS) {
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
if (isHtmlFallback) {
if (needsSessionRecovery) {
debugEntry.metadata.retryLimitReached = "true";
debugEntry.error = `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
debugEntry.metadata.retryReason = retryReason;
debugEntry.error = isHtmlFallback
? `requestConsumerTokenService returned invalid JSON after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`
: `requestConsumerTokenService failed with status ${error.httpStatus} after ${CUSTOMER_TOKEN_MAX_ATTEMPTS} attempts.`;
error.message = debugEntry.error;
}
}
@@ -138,7 +146,7 @@ function createAuthConsumerSession({
const retryDelayMs = getConsumerTokenRetryDelay(attempt);
if (debugEntry) {
debugEntry.metadata.attempt = String(attempt);
debugEntry.metadata.retryReason = "HTTP 200 response did not contain valid JSON";
debugEntry.metadata.retryReason = retryReason;
debugEntry.metadata.nextRetryDelayMs = String(retryDelayMs);
}

View File

@@ -1,11 +1,13 @@
function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
let pending = null;
let allowed = false;
let userId = "";
function ensure() {
if (!pending) {
pending = sendRuntimeMessage({ type: messageTypes.requestCurrentUser }).then(response => {
appendBackgroundRequestLog(response?.debugRequests);
const roles = response?.user?.roles;
userId = response?.ok === true && typeof response.user?.id === "string" ? response.user.id : "";
allowed = response?.ok === true && Array.isArray(roles)
&& roles.every(role => typeof role === "string") && !roles.includes("SALES_REP");
return allowed;
@@ -13,5 +15,5 @@ function createCurrentUser({ sendRuntimeMessage, appendBackgroundRequestLog, mes
}
return pending;
}
return { ensure, allowsCrmActivities: () => allowed };
return { ensure, allowsCrmActivities: () => allowed, getUserId: () => userId };
}

View File

@@ -0,0 +1,109 @@
function createSrRequestHeaders({ runtimeApi }) {
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
const appPath = "/ic/builder/rt/SeaaS_initiation/";
const internalPath = "/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const storageKey = "opportunitiesExtension.srAppBuilderClientId.v1";
const maxAge = 60 * 60 * 1000;
let observed = null;
let started = false;
function valid(record) {
return record?.origin === origin && typeof record.value === "string"
&& /^[\x21-\x7e]{1,2048}$/.test(record.value)
&& Number.isFinite(record.observedAt) && record.observedAt <= Date.now()
&& Date.now() - record.observedAt < maxAge;
}
function observeHeaders(rawUrl, headers) {
let url;
try { url = new URL(rawUrl); } catch { return; }
if (url.origin !== origin || !/^\/ic\/builder\/rt\/(?:SeaaS_initiation|SeaaS_SR_management)\/live(?:\/|;)/.test(url.pathname)) return;
// Read only the client ID, not cookies, bodies or authorization.
const value = typeof headers?.get === "function" ? headers.get("x-appbuilder-client-id")
: headers?.find(item => item.name.toLowerCase() === "x-appbuilder-client-id")?.value;
const record = { origin, value: value?.trim(), observedAt: Date.now() };
if (!valid(record)) return;
observed = record;
// Session storage survives service-worker suspension without persisting across browser sessions.
runtimeApi.storage?.session?.set({ [storageKey]: record }).catch(() => {});
}
function start() {
if (started || !runtimeApi?.webRequest) return;
started = true;
const filter = { urls: [`${origin}${appPath}*`, `${origin}/ic/builder/rt/SeaaS_SR_management/*`], types: ["xmlhttprequest"] };
runtimeApi.webRequest.onBeforeSendHeaders?.addListener(details => {
if (details.tabId >= 0 && !details.incognito) observeHeaders(details.url, details.requestHeaders);
}, filter, ["requestHeaders"]);
runtimeApi.webRequest.onHeadersReceived?.addListener(details => {
if (!details.incognito) observeHeaders(details.url, details.responseHeaders);
}, filter, ["responseHeaders"]);
}
async function previewTokenHeaders(url) {
const target = new URL(url);
if (target.origin !== origin || !target.pathname.startsWith(appPath) || !/\/Catalogue_c(?:\/\d+)?$/.test(target.pathname)) {
throw new Error("Unexpected Service Request token URL.");
}
return sessionHeaders(target.origin, "application/vnd.oracle.adf.action+json");
}
async function sessionHeaders(targetOrigin, contentType) {
if (!valid(observed)) {
try {
const stored = await runtimeApi.storage?.session?.get(storageKey);
if (valid(stored?.[storageKey]) && !valid(observed)) observed = stored[storageKey];
} catch { /* A missing capture must not prevent trying the other required headers. */ }
}
return {
Accept: "application/json", "Content-Type": contentType,
Authorization: "Session", Origin: targetOrigin,
...(valid(observed) ? { "x-appbuilder-client-id": observed.value } : {})
};
}
async function tokenHeaders(url, serviceRequestUrl) {
const headers = await previewTokenHeaders(url);
if (!/\/Catalogue_c\/\d+$/.test(new URL(url).pathname)) throw new Error("Missing Catalogue Id.");
const serviceTarget = new URL(serviceRequestUrl);
const isServiceRequest = serviceTarget.pathname.startsWith(appPath) && serviceTarget.pathname.endsWith("/set/seaas/mgmt/serviceRequests");
if (serviceTarget.origin !== headers.Origin || !isServiceRequest || serviceTarget.search || serviceTarget.hash) {
throw new Error("Unexpected Service Request URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the Service Request token header permissions.");
}
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
const catalogUrl = url.slice(0, url.lastIndexOf("/") + 1);
const escapedCatalogUrl = catalogUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedServiceUrl = serviceTarget.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ruleIds = [7201, 7202];
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: ruleIds,
addRules: [`^${escapedCatalogUrl}[0-9]+$`, `^${escapedServiceUrl}$`].map((regexFilter, index) => ({
id: ruleIds[index], priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
condition: { regexFilter, initiatorDomains: [extensionDomain],
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
}))
});
return headers;
}
async function internalServiceRequestHeaders(url) {
const target = new URL(url);
if (target.origin !== origin || target.pathname !== internalPath || target.search || target.hash) {
throw new Error("Unexpected Internal Service Request URL.");
}
if (!runtimeApi?.declarativeNetRequest?.updateSessionRules || !runtimeApi.runtime?.getURL) {
throw new Error("Reload the extension to enable the Internal Service Request header permissions.");
}
const headers = await sessionHeaders(target.origin, "application/json");
const extensionDomain = new URL(runtimeApi.runtime.getURL("/")).hostname;
const escapedUrl = target.href.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await runtimeApi.declarativeNetRequest.updateSessionRules({
removeRuleIds: [7203],
addRules: [{
id: 7203, priority: 1,
action: { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: headers.Origin }] },
condition: { regexFilter: `^${escapedUrl}$`, initiatorDomains: [extensionDomain],
requestMethods: ["post"], resourceTypes: ["xmlhttprequest"] }
}]
});
return headers;
}
return { start, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders, observeResponseHeaders: observeHeaders };
}

View File

@@ -92,10 +92,7 @@ function createAuthTokenRelay({
throw new Error("Token relay response does not contain access_token.");
}
logRequestSuccess(requestEntry, response, createDebugBody(JSON.stringify({
...responseData,
access_token: maskToken(responseData.access_token)
}, null, 2)));
logRequestSuccess(requestEntry, response, responseBody);
if (url === TOKEN_RELAY_URL) {
authSessionState.principal = typeof responseData.principal === "string" ? responseData.principal.trim() : "";
@@ -122,7 +119,7 @@ function createAuthTokenRelay({
method: "GET",
url,
headers: {
"x-xsrf-token": xsrfTokenSource.token ? maskToken(xsrfTokenSource.token) : "(missing)"
"x-xsrf-token": xsrfTokenSource.token || "(missing)"
},
metadata: {
xsrfCookieName: xsrfTokenSource.cookieName || "(missing)",

View File

@@ -56,8 +56,8 @@ function createBackgroundApplication(overrides = {}) {
});
const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({
observeResponseHeaders: (...args) => srRequestHeaders.observeResponseHeaders(...args),
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment
});
const productsBackgroundJobs = createProductsBackgroundJobs({
@@ -115,6 +115,9 @@ function createBackgroundApplication(overrides = {}) {
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const spaWorkloadActions = createSpaWorkloadActions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestWlActions = (...args) => spaWorkloadActions.requestWlActions(...args);
const spaActionOptions = createSpaActionOptions({ fetchJson: (...args) => httpBackgroundJson.fetchJson(...args) });
const requestActionTeamMembers = (...args) => spaActionOptions.requestActionTeamMembers(...args);
const requestActionFindLookup = (...args) => spaActionOptions.requestActionFindLookup(...args);
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
@@ -122,8 +125,29 @@ function createBackgroundApplication(overrides = {}) {
const requestRatecardByPlan = (...args) => spaConsumption.requestRatecardByPlan(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
const srRequestHeaders = createSrRequestHeaders({ runtimeApi });
const fusionSrCreate = createFusionSrCreate({
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
tokenHeaders: (...args) => srRequestHeaders.tokenHeaders(...args),
internalServiceRequestHeaders: url => srRequestHeaders.internalServiceRequestHeaders(url),
previewTokenHeaders: url => srRequestHeaders.previewTokenHeaders(url)
});
const fusionSrDetails = createFusionSrDetails({
cookieChanges: runtimeApi?.cookies?.onChanged,
cookiesGetAll: (...args) => platformBackgroundApi.cookiesGetAll(...args),
tabsCreate: (...args) => platformBackgroundApi.tabsCreate(...args),
tabsRemove: (...args) => platformBackgroundApi.tabsRemove(...args),
supportsFirefoxCookieStoreTabs: () => platformBackgroundApi.supportsFirefoxCookieStoreTabs(),
wait: (...args) => platformBackgroundApi.wait(...args),
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args)
});
function start() {
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
srRequestHeaders.start();
startBackgroundApplication({
requestLookupTypePlatform: () => fusionSrDetails.requestLookupTypePlatform(),
requestPostInternalServiceRequest: message => fusionSrCreate.requestPostInternalServiceRequest(message),
requestInternalSrServices: message => fusionSrDetails.requestInternalSrServices(message),
requestPostServiceRequest: message => fusionSrCreate.requestPostServiceRequest(message), prepareSrSession: sender => fusionSrDetails.prepareSession(sender), requestSrLookupWithSession: (...args) => fusionSrDetails.requestLookupWithSession(...args), messageTypes, requestSrLookupLanguage: () => fusionSrDetails.requestSrLookupLanguage(), requestSrLookupTechPlatform: () => fusionSrDetails.requestSrLookupTechPlatform(), requestSrLookupOpportunityBusiness: message => fusionSrDetails.requestSrLookupOpportunityBusiness(message), requestSrLookupPrimaryPillar: () => fusionSrDetails.requestSrLookupPrimaryPillar(), requestInternalServiceRequest: (message, sender) => fusionSrDetails.requestInternalServiceRequest(message, sender), requestMgmtAction: message => spaActionOptions.requestMgmtAction(message), invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestActionTeamMembers, requestActionFindLookup, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

View File

@@ -1,12 +1,27 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
invalidateProductsCache,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestWlActions,
requestActionTeamMembers,
requestActionFindLookup,
requestMgmtAction,
requestPostServiceRequest,
requestPostInternalServiceRequest,
requestInternalServiceRequest,
requestSrLookupPrimaryPillar,
requestSrLookupWithSession,
prepareSrSession,
requestSrLookupLanguage,
requestSrLookupTechPlatform,
requestLookupTypePlatform,
requestInternalSrServices,
requestSrLookupOpportunityBusiness,
requestCurrentUser,
requestConsumerTokenService,
requestConsumptionTrend,
@@ -19,7 +34,39 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || !message.type) {
return false;
}
if (message.type === messageTypes.requestPostInternalServiceRequest) {
requestPostInternalServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.requestPostServiceRequest) {
requestPostServiceRequest(message).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.prepareSrSession) {
prepareSrSession(sender).then(sendResponse).catch(error => sendResponse({ ok: false,
error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
const srLookups = {
[messageTypes.requestLookupTypePlatform]: requestLookupTypePlatform,
[messageTypes.requestInternalSrServices]: requestInternalSrServices,
[messageTypes.requestSrLookupPrimaryPillar]: requestSrLookupPrimaryPillar,
[messageTypes.requestSrLookupLanguage]: requestSrLookupLanguage,
[messageTypes.requestSrLookupTechPlatform]: requestSrLookupTechPlatform,
[messageTypes.requestSrLookupOpportunityBusiness]: requestSrLookupOpportunityBusiness
};
if (Object.hasOwn(srLookups, message.type)) {
requestSrLookupWithSession(srLookups[message.type], message, sender).then(sendResponse).catch(error => sendResponse({ ok: false,
httpStatus: error.httpStatus, error: error.message, debugRequests: error.debugRequests || [] }));
return true;
}
if (message.type === messageTypes.invalidateProductsCache) {
invalidateProductsCache().then(() => sendResponse({ ok: true })).catch(error => sendResponse({ ok: false, error: error.message || "Unable to clear products cache." }));
return true;
}
if (message.type === messageTypes.getXsrfToken) {
getXsrfTokenCookie()
.then((result) => {
@@ -97,6 +144,15 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if ([messageTypes.requestActionTeamMembers, messageTypes.requestActionFindLookup, messageTypes.requestMgmtAction, messageTypes.requestInternalServiceRequest].includes(message.type)) {
const request = message.type === messageTypes.requestInternalServiceRequest ? requestInternalServiceRequest : message.type === messageTypes.requestMgmtAction ? requestMgmtAction : message.type === messageTypes.requestActionTeamMembers ? requestActionTeamMembers : requestActionFindLookup;
request(message, sender).then(sendResponse).catch(error => sendResponse({
ok: false, httpStatus: error.httpStatus, error: error.message || "Unable to complete request.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestWlActions) {
requestWlActions(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load workload actions.",

View File

@@ -24,12 +24,14 @@ function createContentApplication(overrides = {}) {
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args)
});
const contracts = createContentContracts();
const searchableSelect = createSearchableSelect({ environment });
const authSessionState = createAuthSessionState(contracts);
const authSalesCommonState = createAuthSalesCommonState();
const opportunitiesQueryState = createOpportunitiesQueryState();
const opportunitiesTableState = createOpportunitiesTableState();
const opportunitiesFilterState = createOpportunitiesFilterState(contracts);
const preferencesState = createPreferencesState(contracts);
const activityCache = createActivityCache({ authSessionState, preferencesState, environment });
const productsContentState = createProductsContentState();
const aiSummaryState = createAiSummaryState();
const consumptionPlansState = createConsumptionPlansState();
@@ -54,6 +56,11 @@ function createContentApplication(overrides = {}) {
environment
});
const contentModal = createContentModal({
prepareSrSession: () => srSessionPreparation.ensure(),
closeActionForm: (...args) => internalSrForm.close(...args) || opportunityCalendar.closeDetails(...args) || crmSrForm.close(...args) || workloadActionForm.close(...args),
mountOpportunityViews: (...args) => opportunityCalendar.mount(...args),
createActivityFilterControls: (...args) => activityFilters.createControls(...args),
closeCrmSrPopover: () => crmSrPopover.close(),
ensureCurrentUser: () => currentUser.ensure(),
ACCOUNT_PLANS_MODAL_ID: contracts.ACCOUNT_PLANS_MODAL_ID,
AUTH_STATUS: contracts.AUTH_STATUS,
@@ -88,6 +95,7 @@ function createContentApplication(overrides = {}) {
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
createSettingsIcon: (...args) => contentIcons.createSettingsIcon(...args),
createRefreshAllButton: (...args) => refreshAll.createButton(...args),
debugState,
ensureExtensionStyles: (...args) => contentStyles.ensureExtensionStyles(...args),
getCustomerOptions: (...args) => opportunitiesFormatters.getCustomerOptions(...args),
@@ -110,10 +118,6 @@ function createContentApplication(overrides = {}) {
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
resetCustomerAndOwnerFiltersForPeriod: (...args) => opportunitiesFilters.resetCustomerAndOwnerFiltersForPeriod(...args),
resetOpportunitiesTable: (...args) => opportunitiesController.resetOpportunitiesTable(...args),
resetWorkloadActions: (...args) => opportunityWorkloadActions.reset(...args),
reopenWorkloadActions: () => opportunityWorkloadActions.reopen(),
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
restoreFilterPreferences: (...args) => preferencesStorage.restoreFilterPreferences(...args),
saveFilterPreferences: (...args) => preferencesStorage.saveFilterPreferences(...args),
toggleCustomerFilterPanel: (...args) => opportunitiesFilters.toggleCustomerFilterPanel(...args),
@@ -189,7 +193,6 @@ function createContentApplication(overrides = {}) {
productsContentState,
renderProductsProgress: (...args) => productsContentJobs.renderProductsProgress(...args),
requestOpportunitiesPage: (...args) => fusionOpportunities.requestOpportunitiesPage(...args),
loadCrmServiceRequests: (...args) => opportunityCrmServiceRequests.load(...args),
resetCrmServiceRequests: () => opportunityCrmServiceRequests.reset(),
requestOpptyProductsInBackground: (...args) => productsContentJobs.requestOpptyProductsInBackground(...args),
requestTokenRelayOnce: (...args) => authContentSession.requestTokenRelayOnce(...args),
@@ -216,19 +219,96 @@ function createContentApplication(overrides = {}) {
readResponseBodyForDebug: (...args) => debugContentLog.readResponseBodyForDebug(...args),
maskToken: (...args) => debugContentLog.maskToken(...args), environment
});
const internalSrSession = createInternalSrSession({
environment,
query: async srId => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestInternalServiceRequest, srId });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
return result;
}
});
const crmSrPopover = createCrmSrPopover({
openInternalSrForm: (parent, trigger) => internalSrForm.open(parent, trigger),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestDetails: srId => internalSrSession.request(srId),
MODAL_ID: contracts.MODAL_ID,
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
});
const opportunityBadgeContent = createOpportunityBadgeContent({ environment });
async function requestSrFormLookup(type, fields = {}) {
const result = await platformContentMessages.sendRuntimeMessage({ type, ...fields });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Service Request options.");
return result.items;
}
const internalSrForm = createInternalSrForm({
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
requestSave: async payload => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostInternalServiceRequest, payload, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to create Internal Service Request.");
return result.result;
},
getOpportunity: number => number == null ? null : opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
MODAL_ID: contracts.MODAL_ID, environment, searchableSelect,
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestPlatform: () => requestSrFormLookup(messageTypes.requestLookupTypePlatform),
requestServices: parentServiceId => requestSrFormLookup(messageTypes.requestInternalSrServices, { parentServiceId, emailAddress: currentUser.getUserId() })
});
const crmSrForm = createCrmSrForm({
requestSave: async fields => {
const result = await platformContentMessages.sendRuntimeMessage({ type: messageTypes.requestPostServiceRequest, ...fields, emailAddress: currentUser.getUserId() });
debugContentLog.appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to save Service Request.");
return result;
},
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
requestLanguage: () => requestSrFormLookup(messageTypes.requestSrLookupLanguage),
requestPlatform: () => requestSrFormLookup(messageTypes.requestSrLookupTechPlatform),
requestServices: () => requestSrFormLookup(messageTypes.requestSrLookupOpportunityBusiness, { emailAddress: currentUser.getUserId() }),
searchableSelect,
MODAL_ID: contracts.MODAL_ID, environment,
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
isMissing: number => opportunityCrmServiceRequests.getSummary(number)?.state === "missing",
createCloseIcon: () => contentIcons.createCloseIcon(),
requestPillars: () => requestSrFormLookup(messageTypes.requestSrLookupPrimaryPillar)
});
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
createSrButton: number => crmSrForm.createButton(number),
closeSrForm: () => { internalSrForm.close(false); crmSrForm.close(false); },
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
activityCache,
reportProgress: (...args) => activityProgress.update("sr", ...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
bindPopover: (...args) => crmSrPopover.bind(...args),
closePopover: (...args) => crmSrPopover.close(...args),
requestCrmServiceRequest: (...args) => fusionCrmServiceRequests.requestCrmServiceRequest(...args),
isCurrentPeriodRequest: (...args) => opportunitiesController.isCurrentPeriodRequest(...args), environment
});
const opportunityCalendar = createOpportunityCalendar({
createViewIcon: view => contentIcons.createViewIcon(view),
environment, loadLibrary: () => loadOpportunityCalendarLibrary(),
getState: () => opportunitiesTableState.opportunitiesTableState,
getItems: () => opportunitiesFilters.getSortedOpportunities(),
getVersion: () => opportunitiesQueryState.periodRequestVersion,
formatRevenue: item => opportunitiesFormatters.formatRevenue(item),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
getOwnerName: item => opportunitiesFormatters.getOwnerName(item),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createCopyButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createCloseIcon: () => contentIcons.createCloseIcon(),
createStageBadge: value => opportunitiesLinks.createStageBadge(value),
createStatusBadge: value => opportunitiesLinks.createStatusBadge(value),
opportunityUrl: contracts.OPPORTUNITY_DETAIL_URL, modalId: contracts.MODAL_ID,
closeSrPopover: () => crmSrPopover.close()
});
const opportunitiesTable = createOpportunitiesTable({
renderCalendar: (...args) => opportunityCalendar.render(...args),
renderActivityFilters: () => activityFilters.render(),
closeCrmSrPopover: (...args) => crmSrPopover.close(...args),
createWorkloadActionsBadge: (...args) => opportunityWorkloadActions.createBadge(...args),
ACCOUNT_DETAIL_URL: contracts.ACCOUNT_DETAIL_URL,
@@ -266,6 +346,7 @@ function createContentApplication(overrides = {}) {
environment
});
const opportunitiesFilters = createOpportunitiesFilters({
matchesActivities: item => activityFilters.matches(item),
CUSTOMER_FILTER_BUTTON_ID: contracts.CUSTOMER_FILTER_BUTTON_ID,
CUSTOMER_FILTER_LIST_ID: contracts.CUSTOMER_FILTER_LIST_ID,
CUSTOMER_FILTER_PANEL_ID: contracts.CUSTOMER_FILTER_PANEL_ID,
@@ -329,8 +410,57 @@ function createContentApplication(overrides = {}) {
requestOpportunitiesForPeriod: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
environment
});
const refreshAll = createRefreshAll({
createRefreshIcon: () => contentIcons.createRefreshIcon(),
messageTypes, activityCache, opportunitiesQueryState, productsContentState,
consumptionPlansState, consumptionTrendState, aiSummaryState, environment,
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
reload: (...args) => opportunitiesController.requestOpportunitiesForPeriod(...args),
onFailure: error => opportunitiesTable.setOpportunitiesTableState({ status: "error", message: error.message || "Unable to refresh cached data." }),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
resetRelatedData: () => {
opportunityCrmServiceRequests.reset();
opportunityWorkloadActions.reset();
consumptionPlansView.closeAccountPlansModal();
installBaseView.closeInstallBaseModal();
serviceRequestsView.closeServiceRequestsModal();
installBaseAction.resetRequests();
serviceRequestsAction.resetRequests();
ratecardController.resetRequests();
consumptionPlanDetailController.resetRequests();
productsContentJobs.renderProductsProgress();
opportunitiesTable.setOpportunitiesTableState({ status: "loading", message: "Refreshing data..." });
}
});
const activityProgress = createActivityProgress({ MODAL_ID: contracts.MODAL_ID, environment });
const srSessionPreparation = createSrSessionPreparation({
ensureCurrentUser: () => currentUser.ensure(),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
sendRequest: () => platformContentMessages.sendRuntimeMessage({ type: messageTypes.prepareSrSession }),
reportProgress: state => activityProgress.update("session", state),
appendLog: entries => debugContentLog.appendBackgroundRequestLog(entries)
});
const activityFilters = createActivityFilters({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
getSrSummary: number => opportunityCrmServiceRequests.getSummary(number),
getActionSummary: number => opportunityWorkloadActions.getSummary(number),
renderTable: () => opportunitiesTable.renderOpportunitiesTable(),
environment
});
const productActivities = createProductActivities({
productsContentState, opportunitiesQueryState,
loadCrm: (...args) => opportunityCrmServiceRequests.load(...args),
loadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
blockActions: products => opportunityWorkloadActions.blockProducts(products),
render: () => {
productsContentJobs.renderProductsProgress();
productsContentJobs.queueProductsTableRender();
}
});
const productsContentJobs = createProductsContentJobs({
loadWorkloadActions: (...args) => opportunityWorkloadActions.loadProducts(...args),
beginProductActivities: (...args) => productActivities.begin(...args),
completeProductActivities: (...args) => productActivities.complete(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
messageTypes,
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
@@ -342,7 +472,35 @@ function createContentApplication(overrides = {}) {
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
environment
});
const workloadActionOptions = createWorkloadActionOptions({
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
messageTypes
});
const workloadActionForm = createWorkloadActionForm({
searchableSelect,
onActionSaved: (...args) => opportunityWorkloadActions.applySavedAction(...args),
saveAction: (...args) => workloadActionOptions.save(...args),
notify: (...args) => opportunitiesLinks.showCopyConfirmation(...args),
getCurrentUserId: () => currentUser.getUserId(),
requestTeamMembers: (...args) => workloadActionOptions.requestTeamMembers(...args),
requestLookup: (...args) => workloadActionOptions.requestLookup(...args),
getProducts: number => productsContentState.opptyProductsByNumber.get(String(number))?.items || [],
getOpportunity: number => opportunitiesTableState.opportunitiesTableState.items.find(item => String(item.OptyNumber) === String(number)),
formatDate: value => opportunitiesFormatters.formatOracleResponseDate(value),
MODAL_ID: contracts.MODAL_ID,
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
isMissingAction: number => opportunityWorkloadActions.getSummary(number)?.state === "missing",
createCloseIcon: () => contentIcons.createCloseIcon(),
environment
});
const opportunityWorkloadActions = createOpportunityWorkloadActions({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
createActionButton: number => workloadActionForm.createButton(number),
closeActionForm: (...args) => workloadActionForm.close(...args),
activityCache,
reportProgress: (...args) => activityProgress.update("actions", ...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
authSessionState,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
@@ -352,6 +510,7 @@ function createContentApplication(overrides = {}) {
productsContentState, messageTypes, environment
});
const productsDetails = createProductsDetails({
setBadgeContent: (...args) => opportunityBadgeContent.setContent(...args),
OPPORTUNITIES_COLUMNS: columns,
createAiSummaryAlert: (...args) => aiSummaryContent.createAiSummaryAlert(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
@@ -497,7 +656,6 @@ function createContentApplication(overrides = {}) {
});
const debugContentLog = createDebugContentLog({
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
});
@@ -515,6 +673,9 @@ function createContentApplication(overrides = {}) {
startContentApplication({ environment, page: contentFusionPage, styles: contentStyles, products: productsContentJobs });
}
return {
opportunityCalendar,
workloadActionForm,
activityFilters,
start, contracts,
opportunityWorkloadActions,
fusionCrmServiceRequests, opportunityCrmServiceRequests,
@@ -552,6 +713,11 @@ function createContentApplication(overrides = {}) {
preferencesStorage,
preferencesView,
productsContentJobs,
productActivities,
activityProgress,
srSessionPreparation,
refreshAll,
activityCache,
productsDetails,
aiSummaryContent,
consumptionPlansAction,

View File

@@ -70,5 +70,40 @@ function createContentIcons({
return svg;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon };
function createRefreshIcon() {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
path.setAttribute("d", "M20 7v5h-5M4 17v-5h5M6.1 6.1A8 8 0 0 1 19.7 10M4.3 14a8 8 0 0 0 13.6 3.9");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.8");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.append(path);
return svg;
}
function createViewIcon(view) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
const calendarFrame = "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z";
path.setAttribute("d", view === "today" ? `${calendarFrame}M9 16l2 2 4-4`
: view === "week" ? `${calendarFrame}M6 14h12v4H6Z M10 14v4m4-4v4`
: view === "calendar"
? "M8 2v4m8-4v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2ZM7 14h2m6 0h2m-10 4h2m6 0h2"
: "M4 3h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1ZM3 9h18M9 9v12M3 15h18");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.8");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.append(path);
return svg;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon, createViewIcon };
}

View File

@@ -28,10 +28,14 @@ function createContentModal({
closeAccountPlansModal,
closeInstallBaseModal,
closeServiceRequestsModal,
closeActionForm,
closeCustomerFilterPanel,
closeOwnerFilterPanel,
closePreferencesModal,
createSettingsIcon,
createRefreshAllButton,
createActivityFilterControls,
mountOpportunityViews,
debugState,
ensureExtensionStyles,
getCustomerOptions,
@@ -51,12 +55,10 @@ function createContentModal({
renderStageFilterButtons,
renderStatusFilterButtons,
requestOpportunitiesForPeriod,
loadCrmServiceRequests,
resetCrmServiceRequests,
resetWorkloadActions,
reopenWorkloadActions,
closeCrmSrPopover,
requestTokenRelayOnce,
ensureCurrentUser,
prepareSrSession,
resetCustomerAndOwnerFiltersForPeriod,
resetOpportunitiesTable,
restoreFilterPreferences,
@@ -83,15 +85,13 @@ function createContentModal({
if (existingModal) {
existingModal.hidden = false;
prepareSrSession?.();
existingModal.querySelector("select").focus();
document.documentElement.classList.add("opportunities-extension-scroll-lock");
ensureEscapeKeyHandler();
ensureCurrentUser().then(() => {
if (existingModal.hidden) return;
reopenWorkloadActions();
return requestTokenRelayOnce().then(token => {
if (!existingModal.hidden) return loadCrmServiceRequests(token, opportunitiesQueryState.periodRequestVersion);
});
return requestTokenRelayOnce();
})
.then(() => renderOpportunitiesTable()).catch(() => {});
return;
@@ -114,7 +114,6 @@ function createContentModal({
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "opportunities-extension-title");
overlay.setAttribute("aria-describedby", "opportunities-extension-subtitle");
const shell = document.createElement("section");
shell.className = "opportunities-extension-shell";
@@ -126,10 +125,6 @@ function createContentModal({
title.id = "opportunities-extension-title";
title.textContent = "Opportunities Extension";
const subtitle = document.createElement("p");
subtitle.id = "opportunities-extension-subtitle";
subtitle.textContent = "Lista de oportunidades do HCM Opportunities List";
const authBadge = document.createElement("span");
authBadge.className = "opportunities-extension-auth-badge opportunities-extension-auth-badge-pending";
authBadge.setAttribute("data-auth-status", AUTH_STATUS.pending);
@@ -149,9 +144,10 @@ function createContentModal({
productsCacheTimestamp.className = "opportunities-extension-products-cache-timestamp";
productsCacheTimestamp.setAttribute("role", "status");
productsCacheTimestamp.hidden = true;
subtitleRow.append(subtitle, authBadge, productsProgressBadge, productsCacheTimestamp);
subtitleRow.append(authBadge, productsProgressBadge, productsCacheTimestamp);
const titleBlock = document.createElement("div");
titleBlock.className = "opportunities-extension-title-row";
titleBlock.append(title, subtitleRow);
const closeButton = document.createElement("button");
@@ -180,7 +176,7 @@ function createContentModal({
const headerActions = document.createElement("div");
headerActions.className = "opportunities-extension-header-actions";
headerActions.append(themeButton, preferencesButton, closeButton);
headerActions.append(themeButton, createRefreshAllButton(() => select.value), preferencesButton, closeButton);
header.append(titleBlock, headerActions);
@@ -389,7 +385,8 @@ function createContentModal({
statusFilter.append(statusFilterLabel, statusFilters);
form.addEventListener("submit", (event) => event.preventDefault());
form.append(field, customerFilter, ownerFilter, tableSearchField, statusFilter, stageFilter);
const badgeFilters = createActivityFilterControls(form, statusFilter, stageFilter);
form.append(field, customerFilter, ownerFilter, tableSearchField, badgeFilters);
body.append(form);
const stageDashboard = document.createElement("section");
@@ -407,7 +404,7 @@ function createContentModal({
opportunitiesTable.setAttribute("aria-label", "Opportunities");
tableSurface.append(opportunitiesTable);
body.append(tableSurface);
mountOpportunityViews(body, tableSurface);
const debugPanel = document.createElement("aside");
debugPanel.id = DEBUG_PANEL_ID;
@@ -426,6 +423,7 @@ function createContentModal({
renderOwnerFilter();
renderOpportunitiesTable();
requestOpportunitiesForPeriod(select.value);
prepareSrSession?.();
select.focus();
}
@@ -469,8 +467,7 @@ function createContentModal({
function closeOpportunitiesModal() {
resetAuthCompletion();
resetWorkloadActions(false);
resetCrmServiceRequests();
closeCrmSrPopover();
const modal = document.getElementById(MODAL_ID);
if (modal) {
@@ -478,6 +475,7 @@ function createContentModal({
closeAccountPlansModal();
closeInstallBaseModal();
closeServiceRequestsModal();
closeActionForm(false);
modal.hidden = true;
}
@@ -506,6 +504,10 @@ function createContentModal({
}
if (event.key === "Escape") {
if (closeActionForm()) {
event.preventDefault();
return;
}
if (closeServiceRequestsModal()) {
event.preventDefault();
return;

View File

@@ -39,7 +39,10 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
current.promise = (async () => {
try {
if (!subPlanNum) throw new Error("Sub plan number is unavailable.");
if (!pending.has(subPlanNum)) pending.set(subPlanNum, request(subPlanNum).finally(() => pending.delete(subPlanNum)));
if (!pending.has(subPlanNum)) {
const promise = request(subPlanNum).finally(() => { if (pending.get(subPlanNum) === promise) pending.delete(subPlanNum); });
pending.set(subPlanNum, promise);
}
const items = await pending.get(subPlanNum);
if (!section.isConnected || states.get(section) !== current) return;
current.status = "ready";
@@ -59,5 +62,5 @@ function createConsumptionPlanDetailController({ messageTypes, requestConsumerTo
})();
return current.promise;
}
return { createPlanDetailSection, loadPlanDetailForPanel };
return { createPlanDetailSection, loadPlanDetailForPanel, resetRequests: () => pending.clear() };
}

View File

@@ -31,7 +31,8 @@ function createConsumptionPlansAction({
return;
}
consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" });
const currentRequests = consumptionPlansState.accountPlansRequestState;
currentRequests.set(optyNumber, { status: "loading" });
openAccountPlansModal(opportunity, [], "loading");
renderOpportunitiesTable();
requestConsumerTokenServiceOnce()
@@ -41,6 +42,7 @@ function createConsumptionPlansAction({
authorizations
}))
.then((response) => {
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
appendBackgroundRequestLog(response && response.debugRequests);
if (!response || !response.ok) {
@@ -54,6 +56,7 @@ function createConsumptionPlansAction({
renderOpportunitiesTable();
openAccountPlansModal(opportunity, response.plans || [], "ready");
}).catch((error) => {
if (consumptionPlansState.accountPlansRequestState !== currentRequests) return;
const errorMessage = error.message || "Unable to load cloud consumption.";
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
status: "error",

View File

@@ -1,6 +1,5 @@
function createDebugContentLog({
previewDebugBody,
DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel
}) {
@@ -99,7 +98,7 @@ function createDebugContentLog({
}
function createDebugBody(text) {
return previewDebugBody(text, DEBUG_BODY_LIMIT);
return previewDebugBody(text);
}
function headersToObject(headers) {
@@ -126,7 +125,7 @@ function createDebugContentLog({
.join("\n");
}
function formatDebugBody(body) {
function formatDebugBody(body, requestName = "") {
const text = body || "(none)";
try {
const normalized = String(text).replace(/^\uFEFF/, "").trimStart()
@@ -137,8 +136,14 @@ function createDebugContentLog({
for (let depth = 0; depth < 2 && typeof value === "string" && /^[\s]*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; }
}
if (!debugState.debugSecretsVisible && requestName === "requestSrTokenCatalog") {
value = value && typeof value === "object" && !Array.isArray(value)
? { ...value, ...(Object.hasOwn(value, "result") ? { result: formatHiddenDebugValue(value.result) } : {}) }
: formatHiddenDebugValue(value);
}
return JSON.stringify(debugState.debugSecretsVisible ? value : maskSensitiveDebugValue(value), null, 2);
} catch (error) {
if (!debugState.debugSecretsVisible && /^(requestSrTokenCatalog|requestCurrentUser|requestTokenRelay.*)$/.test(requestName)) return formatHiddenDebugValue(text);
return text;
}
}
@@ -162,7 +167,7 @@ function createDebugContentLog({
}
function isSensitiveDebugKey(key) {
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$/i.test(key);
return /authorization|cookie|password|secret|(^|[_-])token($|[_-])|token$|x-appbuilder-client-id/i.test(key);
}
function formatHiddenDebugValue(value) {

View File

@@ -23,10 +23,11 @@ function createDebugContentView({
if (/^request(?:CurrentUser|TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
if (entry.name === "requestOpportunities") return "opportunities";
if (entry.name === "requestOpptyProducts") return "products";
if (entry.name === "requestWlActions") return "actions";
if (["requestWlActions", "requestActionTeamMembers", "requestActionFindLookup", "requestMgmtAction"].includes(entry.name)) return "actions";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase";
if (entry.name === "requestCustomerServiceRequest" || entry.name === "resquestCrmServiceRequest") return "serviceRequests";
if (["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";
}
@@ -222,7 +223,14 @@ function createDebugContentView({
method.className = "opportunities-extension-debug-method";
method.textContent = entry.method || "GET";
method.setAttribute("data-method", method.textContent.toUpperCase());
summaryMeta.append(startedTime, method, httpStatus, status);
const duration = document.createElement("span");
duration.className = "opportunities-extension-debug-duration";
const hasDuration = typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0;
duration.textContent = hasDuration
? entry.durationMs < 1000 ? `${Math.round(entry.durationMs)} ms` : `${(entry.durationMs / 1000).toFixed(2)} s`
: entry.status === "pending" ? "In progress" : "N/D";
duration.title = hasDuration ? `Request duration: ${entry.durationMs} ms` : "Request duration not yet available";
summaryMeta.append(startedTime, method, httpStatus, status, duration);
heading.append(name, summaryMeta);
@@ -242,7 +250,7 @@ function createDebugContentView({
appendDebugBlock(details, "Request Headers", formatHeaderBlock(entry.requestHeaders));
appendDebugBlock(details, "Request Body", formatDebugBody(entry.requestBody));
appendDebugBlock(details, "Response Headers", formatHeaderBlock(entry.responseHeaders));
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody));
appendDebugBlock(details, "Response Body", formatDebugBody(entry.responseBody, entry.name));
item.append(heading, details);
list.append(item);

View File

@@ -214,3 +214,10 @@
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
outline-color: #8cc8d3;
}
.opportunities-extension-debug-duration {
color: #55514d;
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-duration { color: #d6d2ce; }

View File

@@ -39,7 +39,7 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
try {
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
if (!pending.has(partyNumber)) {
const request = load(partyNumber).finally(() => pending.delete(partyNumber));
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
pending.set(partyNumber, request);
}
view.render(await pending.get(partyNumber));
@@ -49,5 +49,5 @@ function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce
});
return button;
}
return { createInstallBaseButton };
return { createInstallBaseButton, resetRequests: () => pending.clear() };
}

View File

@@ -0,0 +1,67 @@
function createActivityCache({ authSessionState, preferencesState, environment }) {
const { browser, chrome } = environment;
const invalidatedKey = "opportunitiesExtension.activities.invalidatedAt.v1";
async function invalidate() {
await storage("set", { [invalidatedKey]: Date.now() });
const entries = await storage("get", null);
const keys = Object.keys(entries || {}).filter(key => key.startsWith("opportunitiesExtension.activities.v1:"));
if (keys.length) await storage("remove", keys);
}
function storage(method, argument) {
if (browser?.storage?.local) return browser.storage.local[method](argument);
if (!chrome?.storage?.local) return Promise.reject(new Error("Extension storage unavailable."));
return new Promise((resolve, reject) => {
chrome.storage.local[method](argument, result => {
const error = chrome.runtime?.lastError;
if (error) reject(new Error(error.message));
else resolve(result);
});
});
}
function context(kind, id, timestamp) {
const principal = authSessionState.principal;
const fetchedAt = Number(timestamp);
const hours = Number(preferencesState.opptyProductsCacheHours);
const ttl = Number.isFinite(hours) && hours > 0 ? Math.min(hours, 720) * 3600000 : 86400000;
if (!principal || !Number.isFinite(fetchedAt) || fetchedAt <= 0 || fetchedAt > Date.now() || Date.now() - fetchedAt >= ttl) return null;
const identity = [principal, kind, String(id)];
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`;
// SR snapshots from different product updates must not overwrite each other.
if (kind === "sr") identity.push(fetchedAt);
return { key: `opportunitiesExtension.activities.v1:${JSON.stringify(identity)}`, legacyKey, fetchedAt, ttl };
}
async function read(kind, id, timestamp) {
const scope = context(kind, id, timestamp);
if (!scope) return null;
try {
const invalidated = await storage("get", invalidatedKey);
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return null;
const result = await storage("get", scope.key);
let entry = result?.[scope.key];
if (!entry && scope.key !== scope.legacyKey) {
const legacy = await storage("get", scope.legacyKey);
entry = legacy?.[scope.legacyKey];
}
return entry?.fetchedAt === scope.fetchedAt && Array.isArray(entry.items)
&& entry.items.every(item => item && typeof item === "object" && !Array.isArray(item))
&& typeof entry.hasNext === "boolean" ? entry : null;
} catch { return null; }
}
async function write(kind, id, timestamp, items, hasNext = false) {
const scope = context(kind, id, timestamp);
if (!scope || !Array.isArray(items)) return;
try {
const invalidated = await storage("get", invalidatedKey);
if (scope.fetchedAt <= Number(invalidated?.[invalidatedKey] || 0)) return;
await storage("set", { [scope.key]: { fetchedAt: scope.fetchedAt, items, hasNext: Boolean(hasNext) } });
if (kind === "sr") {
const entries = await storage("get", null);
const prefix = `${scope.legacyKey.slice(0, -1)},`;
const expired = Object.keys(entries || {}).filter(key => key.startsWith(prefix)
&& Number(entries[key]?.fetchedAt) <= Math.max(Date.now() - scope.ttl, Number(invalidated?.[invalidatedKey] || 0)));
if (expired.length) await storage("remove", expired);
}
} catch { /* Storage failure must not discard successfully fetched data. */ }
}
return { invalidate, read, write };
}

View File

@@ -0,0 +1,91 @@
function createActivityFilters({ allowsCrmActivities, getSrSummary, getActionSummary, renderTable, setBadgeContent, environment }) {
const selections = { sr: new Set(["matched", "missing"]), actions: new Set(["matched", "missing"]) };
let container = null;
let form = null;
const groups = [];
const buttons = [];
function createControls(parentForm, statusFilter, stageFilter) {
form = parentForm;
groups.length = 0;
buttons.length = 0;
container = environment.document.createElement("div");
container.className = "opportunities-extension-badge-filters";
for (const [kind, title, options] of [
["sr", "Service Request", ["matched", "under4", "missing"]],
["actions", "WL Action", ["matched", "missing"]]
]) {
const section = environment.document.createElement("section");
section.className = "opportunities-extension-activity-filter";
const label = environment.document.createElement("span");
label.textContent = title;
const tooltip = kind === "sr"
? "Whether a Service Request has been created for the opportunities."
: "Whether the Action is registered in Workload Workbench.";
label.title = tooltip;
const controls = environment.document.createElement("div");
controls.className = "opportunities-extension-activity-filter-controls";
controls.setAttribute("role", "group");
controls.setAttribute("aria-label", `Filter by ${title}`);
for (const value of options) {
const button = environment.document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-activity-filter-button";
button.setAttribute("data-value", value);
setBadgeContent(button, kind, value === "under4" ? "< 4h" : "");
const description = value === "under4"
? "At least one SR with reported hours below 4; replaces other SR filters."
: `${kind === "sr" ? "Service Request" : "Action"} ${value === "matched" ? "registered" : "not registered"}`;
button.title = `${tooltip}\n${description}`;
button.setAttribute("aria-label", description);
button.addEventListener("click", () => toggle(kind, value));
buttons.push({ button, kind, value });
controls.append(button);
}
section.append(label, controls);
groups.push(section);
container.append(section);
}
container.append(statusFilter, stageFilter);
render();
return container;
}
function render() {
if (!container) return;
const allowed = allowsCrmActivities();
form.setAttribute("data-activity-filters", String(allowed));
for (const group of groups) group.hidden = !allowed;
for (const { button, kind, value } of buttons) {
button.setAttribute("aria-pressed", String(selections[kind].has(value)));
}
}
function toggle(kind, value) {
const selected = selections[kind];
if (!selected || !allowsCrmActivities()) return;
if (selected.has(value)) selected.delete(value);
else {
if (value === "under4") selected.clear();
else selected.delete("under4");
selected.add(value);
}
render();
renderTable();
}
function matchesSelection(selected, summary) {
// Unrestricted filters retain rows whose activities are not known yet.
if (selected.has("matched") && selected.has("missing")) return true;
if (!summary) return false;
if (selected.has("under4")) return summary.state === "matched" && summary.hasUnderFourHours;
return selected.has(summary.state);
}
function matches(item) {
if (!allowsCrmActivities()) return true;
return matchesSelection(selections.sr, getSrSummary(item.OptyNumber))
&& matchesSelection(selections.actions, getActionSummary(item.OptyNumber));
}
return { createControls, render, toggle, matches };
}

View File

@@ -0,0 +1,39 @@
function createActivityProgress({ MODAL_ID, environment }) {
const { document, setTimeout, clearTimeout, matchMedia } = environment;
const entries = new Map();
function update(key, state, completed = 0, total = null) {
const host = document?.querySelector?.(`#${MODAL_ID} .opportunities-extension-subtitle-row`);
if (!host) return;
let entry = entries.get(key);
if (!entry) {
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-progress";
badge.setAttribute("role", "status");
badge.setAttribute("aria-live", "polite");
host.append(badge);
entry = { badge, timer: null, hideTimer: null, state: "idle" };
entries.set(key, entry);
}
if (state === "complete" && entry.state === "complete") return;
clearTimeout(entry.timer);
clearTimeout(entry.hideTimer);
entry.state = state;
const label = key === "session" ? "SR session" : key === "sr" ? "Service Requests" : "Actions";
entry.badge.hidden = state === "idle";
entry.badge.setAttribute("data-state", state);
entry.badge.setAttribute("data-fading", "false");
entry.badge.setAttribute("aria-busy", String(state === "running"));
const count = total === null ? String(completed) : `${completed}/${total}`;
entry.badge.textContent = state === "error" ? `${label} unavailable`
: state === "complete-with-errors" ? `${label} ${count} (errors)`
: state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`;
if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading";
entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters";
if (state === "complete") entry.timer = setTimeout(() => {
entry.badge.setAttribute("data-fading", "true");
const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
entry.hideTimer = setTimeout(() => { entry.badge.hidden = true; }, reduced ? 0 : 300);
}, 5000);
}
return { update };
}

View File

@@ -0,0 +1,63 @@
/*
* Lucide icons: package, chart-no-axes-column-increasing, clipboard-list, list-checks.
* https://github.com/lucide-icons/lucide (ISC License)
* Copyright (c) 2026 Lucide Icons and Contributors
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
function createOpportunityBadgeContent({ environment }) {
const shapes = {
products: [
["path", { d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }],
["path", { d: "M12 22V12" }],
["polyline", { points: "3.29 7 12 12 20.71 7" }],
["path", { d: "m7.5 4.27 9 5.15" }]
],
ramp: [
["path", { d: "M5 21v-6" }],
["path", { d: "M12 21V9" }],
["path", { d: "M19 21V3" }]
],
sr: [
["rect", { width: "8", height: "4", x: "8", y: "2", rx: "1", ry: "1" }],
["path", { d: "M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" }],
["path", { d: "M12 11h4" }],
["path", { d: "M12 16h4" }],
["path", { d: "M8 11h.01" }],
["path", { d: "M8 16h.01" }]
],
actions: [
["path", { d: "M13 5h8" }],
["path", { d: "M13 12h8" }],
["path", { d: "M13 19h8" }],
["path", { d: "m3 17 2 2 4-4" }],
["path", { d: "m3 7 2 2 4-4" }]
]
};
function setContent(badge, kind, value) {
badge.textContent = String(value);
const namespace = "http://www.w3.org/2000/svg";
const icon = environment.document.createElementNS(namespace, "svg");
for (const [name, attribute] of Object.entries({
class: "opportunities-extension-badge-icon", viewBox: "0 0 24 24",
width: "16", height: "16", fill: "none", stroke: "currentColor",
"stroke-width": "2.25", "stroke-linecap": "round", "stroke-linejoin": "round",
"aria-hidden": "true", focusable: "false", "data-badge-icon": kind
})) icon.setAttribute(name, attribute);
for (const [tag, attributes] of shapes[kind]) {
const shape = environment.document.createElementNS(namespace, tag);
for (const [name, attribute] of Object.entries(attributes)) shape.setAttribute(name, attribute);
icon.append(shape);
}
badge.append(icon);
}
return { setContent };
}

View File

@@ -0,0 +1,105 @@
.opportunities-extension-views { min-height: 0; min-width: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 8px; }
.opportunities-extension-views > [hidden] { display: none; }
.opportunities-extension-view-tabs { display: flex; gap: 20px; border-bottom: 1px solid #dedbd7; }
.opportunities-extension-view-tabs button { border: 0; border-bottom: 3px solid transparent; background: transparent; color: #625d58; padding: 6px 2px 9px; font: 600 13px/20px Arial, sans-serif; cursor: pointer; }
.opportunities-extension-view-tabs button[aria-selected="true"] { border-bottom-color: #00758f; color: #00657b; }
.opportunities-extension-view-tabs button:focus-visible { outline: 2px solid #00758f; outline-offset: -2px; }
.opportunities-extension-view-tabs button { display: inline-flex; align-items: center; gap: 6px; }
.opportunities-extension-view-tabs button svg { width: 16px; height: 16px; flex: 0 0 16px; }
.opportunities-extension-calendar {
--fc-border-color: #dedbd7; --fc-page-bg-color: #fff; --fc-neutral-bg-color: #f5f4f2;
--fc-neutral-text-color: #625d58; --fc-today-bg-color: #eef7fa;
--fc-button-bg-color: #fff; --fc-button-text-color: #312d2a; --fc-button-border-color: #b9b4ae;
--fc-button-hover-bg-color: #e6f3f5; --fc-button-hover-border-color: #00758f;
--fc-button-active-bg-color: #dceff3; --fc-button-active-border-color: #00758f;
min-height: 0; min-width: 0; overflow: hidden; display: flex; flex-direction: column; padding: 12px; background: #fff; color: #312d2a;
border: 1px solid var(--fc-border-color); border-radius: 4px; font: 13px/1.4 Arial, sans-serif;
}
.opportunities-calendar-grid { flex: 1 1 0; min-height: 0; min-width: 0; }
.opportunities-extension-calendar .fc { min-width: 620px; }
.opportunities-extension-calendar .fc .fc-toolbar { gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 18px; line-height: 26px; letter-spacing: 0; }
.opportunities-extension-calendar .fc .fc-button { font-size: 13px; line-height: 20px; padding: 5px 10px; border-radius: 4px; box-shadow: none; }
.opportunities-extension-calendar .fc .fc-button:focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-calendar .fc .fc-button svg { width: 16px; height: 16px; margin-right: 6px; vertical-align: -3px; }
.opportunities-extension-calendar .fc a { color: inherit; text-decoration: none; }
.opportunities-extension-calendar .fc th { background: var(--fc-neutral-bg-color); font-size: 12px; font-weight: 600; padding: 4px 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-number { font-size: 11px; line-height: 18px; padding: 1px 6px; }
.opportunities-extension-calendar .fc .fc-daygrid-day-events { min-height: 0; margin-bottom: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-bottom { margin: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { min-height: 0; padding-top: 0; }
/* Event content must not contribute to the table's intrinsic row heights. */
.opportunities-extension-calendar .fc .fc-daygrid-body tbody { height: 100%; }
.opportunities-extension-calendar .fc .fc-daygrid-body tr { height: calc(100% / var(--opportunities-calendar-weeks, 5)); }
.opportunities-extension-calendar .fc .fc-daygrid-day { position: relative; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { position: absolute; inset: 0; }
.opportunities-extension-calendar .fc .fc-daygrid-week-number { top: 0; left: 0; padding: 3px 6px; background: var(--fc-neutral-bg-color); color: inherit; font: 600 10px/16px Arial, sans-serif; white-space: nowrap; }
.opportunities-calendar-day-count { position: absolute; top: 2px; left: 6px; min-width: 18px; height: 16px; padding: 0 4px; border-radius: 8px; background: #dceff3; color: #005467; font: 600 10px/16px Arial, sans-serif; text-align: center; }
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 82px; }
.opportunities-calendar-day-count[hidden] { display: none; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-day-count:not([data-empty="true"]) { background: #16454f; color: #b9eaf4; }
.opportunities-extension-calendar .fc .fc-day-other { background: var(--fc-neutral-bg-color); }
.opportunities-extension-calendar .fc .fc-daygrid-event { white-space: normal; margin: 3px 4px; border-radius: 4px; cursor: pointer; border-width: 0 0 0 3px; border-style: solid; }
.opportunities-extension-calendar .fc .fc-event-main { color: inherit; }
.opportunities-calendar-event-content { min-width: 0; padding: 5px 6px; overflow-wrap: anywhere; }
.opportunities-calendar-event-content strong { display: block; font-size: 12px; line-height: 16px; font-weight: 600; }
.opportunities-calendar-event-content span { display: block; margin-top: 3px; font-size: 11px; line-height: 15px; }
.opportunities-calendar-event-content strong,
.opportunities-calendar-event-content span,
.opportunities-calendar-day-item strong,
.opportunities-calendar-day-item span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.opportunities-extension-calendar .fc .fc-daygrid-more-link { color: #00657b; font-size: 11px; line-height: 16px; padding: 0 5px; }
.opportunities-calendar-day-list { min-height: 0; overflow-y: auto; padding: 8px 20px 20px; }
.opportunities-calendar-day-item { display: block; width: 100%; padding: 12px 0; border: 0; border-bottom: 1px solid #dedbd7; background: transparent; color: #00657b; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
.opportunities-calendar-day-item strong, .opportunities-calendar-day-item span { display: block; font: 600 13px/20px Arial, sans-serif; }
.opportunities-calendar-day-item span { margin-top: 4px; font-weight: 400; }
.opportunities-calendar-day-item:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-day-item { border-bottom-color: #514d48; color: #80d6e8; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-SQL { background: #dff1f4; border-color: #00829d; color: #005467; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-PIPELINE,
.opportunities-extension-calendar .fc .opportunities-calendar-stage-OTHER { background: #efeeec; border-color: #706a64; color: #49443f; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-UPSIDE { background: #fff0da; border-color: #ba7000; color: #774700; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-FORECAST { background: #f0e8f7; border-color: #8262a3; color: #624379; }
.opportunities-extension-calendar .fc .opportunities-calendar-stage-WON { background: #e2f1e5; border-color: #2f8050; color: #215939; }
.opportunities-calendar-message { margin: 0 0 12px; font-size: 13px; }
.opportunities-calendar-message[hidden], .opportunities-calendar-grid[hidden], .opportunities-calendar-undated[hidden] { display: none; }
.opportunities-calendar-undated { flex: 0 0 auto; max-height: 90px; overflow-y: auto; margin-top: 6px; border-top: 1px solid var(--fc-border-color); padding-top: 4px; }
.opportunities-calendar-undated summary { cursor: pointer; font-size: 12px; line-height: 18px; }
.opportunities-calendar-undated h3 { font-size: 13px; margin: 0 0 8px; }
.opportunities-calendar-undated button { display: block; background: none; border: 0; text-align: left; font: inherit; padding: 6px 0; color: inherit; text-decoration: underline; cursor: pointer; overflow-wrap: anywhere; }
.opportunities-calendar-detail .opportunities-extension-account-plans-header { align-items: start; gap: 16px; }
.opportunities-calendar-detail .opportunities-extension-action-form-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; }
.opportunities-calendar-detail h2 { font-size: 18px; overflow-wrap: anywhere; }
.opportunities-calendar-detail-content { padding: 20px; overflow-y: auto; }
.opportunities-calendar-detail-content a { color: #006b82; }
.opportunities-calendar-detail-badges { display: flex; gap: 8px; margin-top: 18px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs { border-color: #514d48; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs button { color: #c7c2bc; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-view-tabs button[aria-selected="true"] { color: #80d6e8; border-bottom-color: #80d6e8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar {
--fc-border-color: #514d48; --fc-page-bg-color: #292725; --fc-neutral-bg-color: #33302d;
--fc-neutral-text-color: #c7c2bc; --fc-today-bg-color: #223b3f;
--fc-button-bg-color: #292725; --fc-button-text-color: #eeeae6; --fc-button-border-color: #756f68;
--fc-button-hover-bg-color: #234850; --fc-button-hover-border-color: #80d6e8;
--fc-button-active-bg-color: #234850; --fc-button-active-border-color: #80d6e8;
background: #292725; color: #eeeae6;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-SQL { background: #16454f; color: #b9eaf4; border-color: #67cde0; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-PIPELINE,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-OTHER { background: #403c38; color: #e4dfd9; border-color: #b8b1a8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-UPSIDE { background: #533e1e; color: #ffe0a9; border-color: #dba44f; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-FORECAST { background: #493657; color: #ead5fc; border-color: #b998d8; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .opportunities-calendar-stage-WON { background: #244a32; color: #c7edce; border-color: #84c396; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-calendar .fc .fc-daygrid-more-link,
.opportunities-extension-modal[data-theme="dark"] .opportunities-calendar-detail-content a { color: #80d6e8; }
@media (max-width: 700px) {
.opportunities-extension-body { overflow-y: auto; grid-template-rows: max-content max-content minmax(480px, 1fr); }
.opportunities-extension-views { min-height: 480px; }
.opportunities-extension-calendar { padding: 10px; overflow-x: auto; }
.opportunities-extension-calendar .fc { min-width: 0; }
.opportunities-extension-calendar .fc .fc-view-harness { min-width: 560px; }
.opportunities-extension-calendar .fc .fc-toolbar-title { font-size: 16px; }
.opportunities-extension-calendar .fc .fc-daygrid-day-frame { padding-top: 22px; }
.opportunities-calendar-day-count { top: 25px; }
.fc-daygrid-week-number ~ .opportunities-calendar-day-count { left: 6px; }
}

View File

@@ -0,0 +1,292 @@
function createOpportunityCalendar({ environment, loadLibrary, getState, getItems, getVersion,
formatRevenue, formatDate, getOwnerName, createDetailLink, createCopyButton, createCloseIcon,
createStageBadge, createStatusBadge, opportunityUrl, modalId, closeSrPopover, createViewIcon }) {
const { document } = environment;
let elements = null;
let calendar = null;
let library = null;
let selectedView = "overview";
let anchoredVersion = null;
let details = null;
let lastEvents = "";
let dayCounts = new Map();
const dayBadges = new Map();
let resizeObserver = null;
let layoutFrame = null;
let eventLimit = 2;
function visibleEventLimit(height, weeks, headerHeight) {
return Math.max(0, Math.min(2, Math.floor((height / Math.max(1, weeks) - headerHeight - 26) / 48)));
}
function scheduleLayout() {
for (const [name, icon] of [["today", "today"], ["dayGridMonth", "calendar"], ["dayGridWeek", "week"]]) {
const button = elements?.calendarHost.querySelector?.(`.fc-${name}-button`);
if (button && !button.querySelector("svg")) button.prepend(createViewIcon(icon));
}
const win = environment.window;
if (!win?.requestAnimationFrame || layoutFrame !== null) return;
layoutFrame = win.requestAnimationFrame(() => {
layoutFrame = null;
if (!calendar || selectedView !== "calendar" || !elements.calendarHost.clientHeight) return;
calendar.updateSize();
const frame = elements.calendarHost.querySelector(".fc-daygrid-day-frame");
const events = frame?.querySelector(".fc-daygrid-day-events");
if (!frame || !events) return;
const scroller = frame.closest(".fc-scroller");
const weeks = elements.calendarHost.querySelectorAll(".fc-daygrid-body tr").length;
if (!scroller || !weeks) return;
elements.calendarHost.style.setProperty("--opportunities-calendar-weeks", String(weeks));
const headerHeight = events.getBoundingClientRect().top - frame.getBoundingClientRect().top;
const limit = visibleEventLimit(scroller.clientHeight, weeks, headerHeight);
if (limit !== eventLimit) { eventLimit = limit; calendar.setOption("dayMaxEvents", limit); }
});
}
function dateKey(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function fiscalWeekLabel(date) {
// FullCalendar's week hook supplies a UTC date marker, not a local instant.
const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
const end = new Date(start.getTime() + 6 * 86400000);
const year = end.getUTCMonth() >= 5 ? end.getUTCFullYear() : end.getUTCFullYear() - 1;
const fiscalStart = new Date(Date.UTC(year, 5, 1));
const firstSunday = fiscalStart.getTime() - fiscalStart.getUTCDay() * 86400000;
const representative = start < fiscalStart ? fiscalStart : start;
const quarter = Math.floor(((representative.getUTCMonth() + 7) % 12) / 3) + 1;
const week = Math.round((start.getTime() - firstSunday) / (7 * 86400000)) + 1;
return `FY${String(year + 1).slice(-2)}-Q${quarter}-W${week}`;
}
function updateDayBadge(badge, key) {
const count = dayCounts.get(key) || 0;
badge.hidden = count === 0;
badge.textContent = String(count);
badge.title = `${count} oportunidade${count === 1 ? "" : "s"}`;
badge.setAttribute("aria-label", badge.title);
badge.setAttribute("data-empty", String(count === 0));
}
function node(tag, className, text) {
const el = document.createElement(tag);
el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
function closeDate(value) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}(?:T|$)/.test(value)) return null;
const day = value.slice(0, 10);
const date = new Date(`${day}T00:00:00Z`);
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === day ? day : null;
}
function eventsFor(items) {
return items.flatMap((item, index) => {
const start = closeDate(item.EffectiveDate);
if (!start) return [];
return [{ id: String(item.OptyNumber || item.OptyId || index), title: String(item.Name || item.OptyNumber || "-"),
start, allDay: true, extendedProps: { item }, classNames: [`opportunities-calendar-stage-${stage(item)}`] }];
});
}
function stage(item) { return ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"].includes(item.ForecastGroup_c) ? item.ForecastGroup_c : "OTHER"; }
function initialDate(items, now = new Date()) {
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const dates = items.map(item => closeDate(item.EffectiveDate)).filter(Boolean).sort();
return dates.find(date => date >= today) || dates.at(-1) || today;
}
function closeDetails(restoreFocus = true) {
if (!details) return false;
const previous = details;
details = null;
previous.overlay.remove();
if (restoreFocus) (previous.trigger?.isConnected ? previous.trigger : elements?.tabs.calendar)?.focus();
return true;
}
function showDialog(heading, content, contentControls, trigger) {
closeDetails(false);
const host = document.getElementById(modalId);
if (!host || host.hidden) return;
const overlay = node("section", "opportunities-extension-action-form-modal opportunities-calendar-detail");
overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "opportunities-calendar-detail-title");
const panel = node("div", "opportunities-extension-action-form-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const title = node("h2", "", heading);
title.id = "opportunities-calendar-detail-title";
const dismiss = node("button", "opportunities-extension-icon-button");
dismiss.type = "button"; dismiss.title = "Close"; dismiss.setAttribute("aria-label", "Close opportunity details");
dismiss.append(createCloseIcon()); dismiss.addEventListener("click", () => closeDetails());
header.append(title, dismiss);
panel.append(header, content); overlay.append(panel); host.append(overlay);
details = { overlay, trigger };
overlay.addEventListener("click", event => { if (event.target === overlay) closeDetails(); });
overlay.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeDetails(); }
if (event.key === "Tab") {
const controls = [dismiss, ...contentControls];
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
}
});
dismiss.focus();
}
function showDetails(item, trigger) {
const content = node("div", "opportunities-calendar-detail-content");
const link = createDetailLink(item.OptyNumber || "-", item.OptyNumber ? opportunityUrl + encodeURIComponent(item.OptyNumber) : "");
const identity = node("div", "opportunities-extension-opty-number-content");
identity.append(link);
const copy = item.OptyNumber ? createCopyButton(item.OptyNumber) : null;
if (copy) identity.append(copy);
const summary = node("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [["Customer", item.CustomerAccount?.PartyUniqueName], ["Owner", getOwnerName(item)],
["Close Date", closeDate(item.EffectiveDate) ? formatDate(item.EffectiveDate) : "N/D"], ["Amount", formatRevenue(item)]]) {
summary.append(node("dt", "", label), node("dd", "", String(value || "-")));
}
const badges = node("div", "opportunities-calendar-detail-badges");
badges.append(createStatusBadge(item.StatusCode), createStageBadge(item.ForecastGroup_c));
content.append(identity, summary, badges);
showDialog(item.Name || item.OptyNumber || "Opportunity", content, item.OptyNumber ? [link, copy] : [], trigger);
}
function showDayOpportunities({ date, allSegs, jsEvent }) {
jsEvent.preventDefault();
const trigger = jsEvent.currentTarget || elements?.tabs.calendar;
const content = node("div", "opportunities-calendar-day-list");
const controls = [];
const events = new Map(allSegs.map(({ event }) => [event.id, event]));
for (const event of events.values()) {
const item = event.extendedProps.item;
const button = node("button", "opportunities-calendar-day-item"); button.type = "button";
const summary = `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`;
button.append(node("strong", "", event.title), node("span", "", summary));
button.title = `${event.title}\n${summary}`;
button.addEventListener("click", () => showDetails(item, trigger));
controls.push(button); content.append(button);
}
showDialog(`${formatDate(date.toISOString().slice(0, 10))} | ${events.size} oportunidades`, content, controls, trigger);
// A truthy non-string result prevents FullCalendar from opening its measured popover.
return true;
}
function mount(body, tableSurface) {
resizeObserver?.disconnect();
if (layoutFrame !== null) environment.window?.cancelAnimationFrame(layoutFrame);
layoutFrame = null; eventLimit = 2;
calendar?.destroy(); calendar = null; lastEvents = ""; anchoredVersion = null; selectedView = "overview";
dayBadges.clear(); dayCounts.clear();
closeDetails(false);
const workspace = node("section", "opportunities-extension-views");
const tablist = node("div", "opportunities-extension-view-tabs");
tablist.setAttribute("role", "tablist"); tablist.setAttribute("aria-label", "Visualiza\u00e7\u00e3o das oportunidades");
const tabs = {};
const surface = node("section", "opportunities-extension-calendar");
const message = node("p", "opportunities-calendar-message"); message.setAttribute("role", "status");
const calendarHost = node("div", "opportunities-calendar-grid");
const undated = node("details", "opportunities-calendar-undated");
surface.append(message, calendarHost, undated);
for (const [key, label, panel] of [["overview", "Vis\u00e3o geral", tableSurface], ["calendar", "Calend\u00e1rio", surface]]) {
const tab = node("button", ""); tab.type = "button"; tab.id = `opportunities-view-${key}-tab`;
tab.append(createViewIcon(key), node("span", "", label));
panel.id = `opportunities-view-${key}`; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tab.id);
tab.setAttribute("role", "tab"); tab.setAttribute("aria-controls", panel.id);
tab.addEventListener("click", () => selectView(key));
tab.addEventListener("keydown", event => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const next = event.key === "Home" ? "overview" : event.key === "End" ? "calendar" : key === "overview" ? "calendar" : "overview";
selectView(next); tabs[next].focus();
});
tabs[key] = tab; tablist.append(tab);
}
workspace.append(tablist, tableSurface, surface); body.append(workspace);
elements = { workspace, tableSurface, surface, message, calendarHost, undated, tabs };
if (environment.window?.ResizeObserver) {
resizeObserver = new environment.window.ResizeObserver(scheduleLayout);
resizeObserver.observe(calendarHost);
}
selectView("overview");
}
function selectView(view) {
if (!elements || !["overview", "calendar"].includes(view)) return;
closeDetails(false); closeSrPopover(); selectedView = view;
elements.tableSurface.hidden = view !== "overview"; elements.surface.hidden = view !== "calendar";
for (const [key, tab] of Object.entries(elements.tabs)) {
tab.setAttribute("aria-selected", String(view === key)); tab.tabIndex = view === key ? 0 : -1;
}
render(getItems(), getState());
if (view === "calendar") calendar?.updateSize();
scheduleLayout();
}
function render(items, state) {
if (!elements || selectedView !== "calendar") return;
const { surface, message, calendarHost, undated } = elements;
surface.setAttribute("aria-busy", String(state.status === "loading"));
if (["loading", "error", "empty", "idle"].includes(state.status)) {
message.textContent = state.message || (state.status === "loading" ? "Loading opportunities..." : "No opportunities found.");
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; closeDetails(false); return;
}
message.textContent = items.length ? "" : "No opportunities found."; message.hidden = !!items.length;
calendarHost.hidden = false;
const events = eventsFor(items);
dayCounts = new Map();
for (const event of events) dayCounts.set(event.start, (dayCounts.get(event.start) || 0) + 1);
for (const { badge, key } of dayBadges.values()) updateDayBadge(badge, key);
const anchor = initialDate(state.items);
if (!calendar) {
try {
library ||= loadLibrary();
calendar = new library.Calendar(calendarHost, {
initialView: "dayGridMonth", initialDate: anchor, height: "100%", expandRows: true, fixedWeekCount: false,
datesSet: scheduleLayout,
locale: "pt-br", firstDay: 0, editable: false, selectable: false, eventInteractive: true,
headerToolbar: { left: "prev,next today", center: "title", right: "dayGridMonth,dayGridWeek" },
buttonText: { today: "Hoje", month: "M\u00eas", week: "Semana" },
buttonHints: { prev: "Anterior", next: "Pr\u00f3ximo", today: "Hoje", dayGridMonth: "M\u00eas", dayGridWeek: "Semana" },
dayMaxEvents: 2, moreLinkText: count => `... +${count}`, displayEventTime: false,
moreLinkHint: count => `Ver mais ${count} oportunidades`,
moreLinkClick: showDayOpportunities,
weekNumbers: true,
weekNumberContent: ({ date }) => ({ domNodes: [node("span", "opportunities-calendar-fiscal-week", fiscalWeekLabel(date))] }),
dayCellDidMount: ({ el, date }) => {
const badge = node("span", "opportunities-calendar-day-count");
const key = dateKey(date);
updateDayBadge(badge, key);
el.querySelector(".fc-daygrid-day-frame").append(badge);
dayBadges.set(el, { badge, key });
},
dayCellWillUnmount: ({ el }) => { dayBadges.get(el)?.badge.remove(); dayBadges.delete(el); },
eventOrder: "title", eventDisplay: "block", events,
eventContent: ({ event }) => {
const item = event.extendedProps.item;
const card = node("div", "opportunities-calendar-event-content");
const summary = node("span", "", `${item.OptyNumber || "-"} | ${item.ForecastGroup_c || "N/D"} | ${formatRevenue(item)}`);
summary.title = summary.textContent;
card.append(node("strong", "", event.title), summary);
return { domNodes: [card] };
},
eventDidMount: ({ el, event }) => {
el.title = `${event.title}\n${event.extendedProps.item.CustomerAccount?.PartyUniqueName || ""}\n${formatDate(event.startStr)} | ${event.extendedProps.item.ForecastGroup_c || "-"}`;
el.setAttribute("role", "button"); el.setAttribute("aria-label", el.title);
},
eventClick: ({ event, el, jsEvent }) => { jsEvent.preventDefault(); showDetails(event.extendedProps.item, el); }
});
calendar.render(); lastEvents = JSON.stringify(events); anchoredVersion = getVersion();
} catch {
calendar?.destroy(); calendar = null; message.textContent = "Unable to display the calendar. Use the overview or try again.";
message.hidden = false; calendarHost.hidden = true; undated.hidden = true; return;
}
}
const signature = JSON.stringify(events);
calendar.batchRendering(() => {
if (anchoredVersion !== getVersion()) { calendar.gotoDate(anchor); anchoredVersion = getVersion(); closeDetails(false); }
if (signature !== lastEvents) { calendar.removeAllEvents(); calendar.addEventSource(events); lastEvents = signature; closeDetails(false); }
});
const missing = items.filter(item => !closeDate(item.EffectiveDate));
undated.hidden = !missing.length; undated.textContent = "";
if (missing.length) {
undated.append(node("summary", "", `Sem Close Date v\u00e1lido (${missing.length})`));
for (const item of missing) {
const button = node("button", "", `${item.OptyNumber || "-"} | ${item.Name || "-"}`); button.type = "button";
button.addEventListener("click", () => showDetails(item, button)); undated.append(button);
}
}
scheduleLayout();
}
return { mount, render, selectView, closeDetails, closeDate, eventsFor, initialDate, fiscalWeekLabel, visibleEventLimit };
}

View File

@@ -13,7 +13,6 @@ function createOpportunitiesController({
productsContentState,
renderProductsProgress,
requestOpportunitiesPage,
loadCrmServiceRequests,
resetCrmServiceRequests,
resetWorkloadActions,
requestOpptyProductsInBackground,
@@ -24,6 +23,7 @@ function createOpportunitiesController({
async function requestOpportunitiesForPeriod(period) {
const dateRange = getFiscalPeriodRange(period, new Date());
const requestVersion = ++opportunitiesQueryState.periodRequestVersion;
resetWorkloadActions();
await ensureCurrentUser();
if (!isCurrentPeriodRequest(requestVersion)) return;
@@ -70,7 +70,6 @@ function createOpportunitiesController({
failed: 0,
cacheHits: 0
};
productsContentState.productsLastCachedAt = 0;
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
@@ -84,7 +83,6 @@ function createOpportunitiesController({
}
try {
await loadCrmServiceRequests(token, requestVersion);
if (!isCurrentPeriodRequest(requestVersion)) return;
const allItems = await requestOpportunitiesPage(token, period, dateRange, 0, [], requestVersion, 1);

View File

@@ -1,20 +1,38 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, environment }) {
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, createSrButton, closeSrForm, allowsCrmActivities, reportProgress, activityCache, setBadgeContent, formatDate, environment }) {
let status = "idle";
let byOpportunity = new Map();
let pending = null;
let generation = 0;
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); }
function load(token, version) {
let loadedItems = [];
function reset() { closeSrForm(); closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); loadedItems = []; reportProgress("idle"); }
function load(token, version, cacheOptions = null) {
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
if (!pending) pending = run(token, version, generation);
if (status === "ready") {
return Promise.resolve(cacheOptions ? activityCache.write("sr", "all", cacheOptions.cachedAt, loadedItems) : undefined).then(() => true);
}
if (!pending) {
const request = run(token, version, generation, cacheOptions);
pending = request;
request.finally(() => { if (pending === request) pending = null; });
}
return pending;
}
async function run(token, version, currentGeneration) {
async function run(token, version, currentGeneration, cacheOptions) {
const isActive = () => currentGeneration === generation;
status = "loading";
try {
const items = await requestCrmServiceRequest(token, version, isActive);
if (!items || !isActive()) return;
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
if (!isActive()) return false;
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return false; }
let items = cached?.items;
if (!items) {
reportProgress("running", 0);
items = await requestCrmServiceRequest(token, version, isActive, (count, total) => {
if (isActive()) reportProgress("running", count, total);
});
if (items && isActive() && cacheOptions) await activityCache.write("sr", "all", cacheOptions.cachedAt, items);
}
if (!items || !isActive()) return false;
const grouped = new Map();
for (const item of items) {
const number = item?.OpportunityName_Id_c?.OptyNumber;
@@ -24,23 +42,44 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
grouped.get(key).push(item);
}
byOpportunity = grouped;
loadedItems = items;
status = "ready";
if (!cached) reportProgress("complete", items.length, items.length);
return true;
} catch {
if (isActive()) status = "error";
if (isActive()) { status = "error"; reportProgress("error"); }
return false;
}
}
function getSummary(optyNumber) {
if (!allowsCrmActivities() || !optyNumber) return null;
const items = byOpportunity.get(String(optyNumber)) || [];
const hours = items.map(item => item.ReportedHours_c).filter(value =>
(typeof value === "number" || (typeof value === "string" && value.trim() !== "")) && Number.isFinite(Number(value))
).map(Number);
return {
state: status === "ready" ? items.length ? "matched" : "missing" : status,
reportedHours: hours.length === items.length ? hours.reduce((total, value) => total + value, 0) : null,
hasUnderFourHours: status === "ready" && hours.some(value => value < 4)
};
}
function createBadge(optyNumber) {
if (!allowsCrmActivities() || !optyNumber || status === "idle" || status === "loading") return null;
const items = byOpportunity.get(String(optyNumber)) || [];
const badge = environment.document.createElement(items.length && status === "ready" ? "button" : "span");
badge.className = "opportunities-extension-crm-sr-badge";
badge.setAttribute("data-state", status === "error" ? "error" : items.length ? "matched" : "missing");
badge.textContent = status === "error" ? "SR ?" : items.length ? "\u2705 SR" : "\u26d4 SR";
const summary = getSummary(optyNumber);
const reportedHours = summary.reportedHours !== null
? new Intl.NumberFormat("en-US", { useGrouping: false, maximumFractionDigits: 10 }).format(summary.reportedHours)
: "N/D";
setBadgeContent(badge, "sr", status === "error" ? "?" : items.length ? `${reportedHours}${reportedHours === "N/D" ? "" : "h"}` : "0h");
const display = value => value === null || value === undefined || value === "" ? "N/D" : String(value);
const descriptions = items.map(item => [
`Title: ${display(item.Title)}`,
`Opportunity: ${display(item.OpportunityName_Id_c?.OptyNumber)}`,
`Reported Hours: ${display(item.ReportedHours_c)}`,
`Customer Deadline: ${display(formatDate(item.CustomerDeadline_c))}`,
`Owner: ${[item.Owner?.PersonFirstName, item.Owner?.PersonLastName].filter(Boolean).join(" ") || "N/D"}`,
`Email: ${display(item.Owner?.PrimaryEmail?.EmailAddress)}`,
`Do Not Call: ${display(item.DoNotCallFlag)}`, `Do Not Email: ${display(item.DoNotEmailFlag)}`
@@ -49,10 +88,11 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
: !items.length ? "No matching CRM service request with status SVC_PENDING_DELIVERY in your SR team."
: items.map((item, index) => `SR Number: ${display(item.SrNumber)}\n${descriptions[index]}`).join("\n\n");
badge.setAttribute("aria-label", description);
badge.title = "Whether a Service Request has been created for the opportunities.";
if (items.length && status === "ready") bindPopover(badge, items, descriptions);
else badge.title = description;
if (!items.length && status === "ready") badge.append(createSrButton(String(optyNumber)));
badge.tabIndex = 0;
return badge;
}
return { reset, load, createBadge };
return { reset, load, createBadge, getSummary };
}

View File

@@ -0,0 +1,416 @@
function createCrmSrForm({ MODAL_ID, allowsCrmActivities, isMissing, getOpportunity, requestSave, notify, formatDate, requestPillars, requestLanguage, requestPlatform, requestServices, createCloseIcon, searchableSelect, environment }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
const element = document.createElement(tag);
if (className) element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving) return true;
const previous = active;
active = null;
previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createButton(number) {
const button = node("button", "opportunities-extension-add-action", "+");
button.type = "button";
button.title = "Add Service Request";
button.setAttribute("aria-label", `Add Service Request for ${number}`);
button.setAttribute("aria-haspopup", "dialog");
button.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); open(number, button); });
return button;
}
function open(number, trigger) {
if (active?.saving) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden || !allowsCrmActivities() || !isMissing(number)) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-sr-form-title");
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-sr-form-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const heading = node("div", "opportunities-extension-action-form-heading");
const title = node("h2", "", "Add Service Request");
title.id = "opportunities-extension-sr-form-title";
heading.append(title, node("p", "", String(number)));
const opportunity = getOpportunity(number);
const details = node("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [
["Customer", opportunity?.CustomerAccount?.PartyUniqueName],
["Name", opportunity?.Name],
["Close Date", formatDate(opportunity?.EffectiveDate)]
]) {
details.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "-" : String(value)));
}
heading.append(details);
const dismiss = node("button", "opportunities-extension-icon-button");
dismiss.type = "button";
dismiss.setAttribute("aria-label", "Close Service Request form");
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const form = node("form", "opportunities-extension-action-form");
form.noValidate = true;
const fields = new Map();
const combos = new Map();
const loadingFields = new Set(["primaryPillar", "language", "platform", "services"]);
const availableFields = new Set();
let serviceItems = [];
const serviceRadios = [];
let serviceGroup;
const field = node("label", "opportunities-extension-action-form-field");
const select = node("select");
select.id = "opportunities-extension-sr-primary-pillar";
select.name = "primaryPillar";
select.required = true;
field.htmlFor = select.id;
field.append(node("span", "", "Primary pillar (required)"), select);
const autocomplete = searchableSelect(select, field);
fields.set("primaryPillar", select);
combos.set(select, autocomplete);
const status = node("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
status.setAttribute("role", "status");
const footer = node("footer", "opportunities-extension-action-form-footer");
const retry = node("button", "", "Retry");
retry.type = "button";
const cancel = node("button", "", "Cancel");
const save = node("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
save.disabled = true;
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(retry, save, cancel);
form.append(field);
const extraControls = [];
const extraLoads = [];
function textField(name, label, type) {
const wrapper = node("label", "opportunities-extension-action-form-field" + (type === "textarea" ? " opportunities-extension-action-form-wide" : ""));
const input = node(type === "textarea" ? "textarea" : "input");
if (type === "textarea") input.rows = 4;
else input.type = type;
input.id = `opportunities-extension-sr-${name}`;
input.name = name;
input.required = true;
fields.set(name, input);
wrapper.htmlFor = input.id;
wrapper.append(node("span", "", `${label} (required)`), input);
form.append(wrapper);
extraControls.push(input);
}
textField("customerDeadline", "Customer deadline", "date");
function lookupField(name, label, request, radios = false) {
const wrapper = node(radios ? "fieldset" : "div", "opportunities-extension-sr-lookup" + (radios ? " opportunities-extension-action-form-wide" : ""));
const content = node("div", radios ? "opportunities-extension-sr-services" : "");
const message = node("p", "opportunities-extension-action-form-status");
const retryOption = node("button", "opportunities-extension-sr-lookup-retry", "Retry");
retryOption.type = "button";
retryOption.setAttribute("aria-label", `Retry ${label}`);
retryOption.hidden = true;
extraControls.push(retryOption);
let input, combo;
if (radios) { wrapper.append(node("legend", "", `${label} (required)`)); serviceGroup = wrapper; }
else {
const field = node("label", "opportunities-extension-action-form-field");
input = node("select");
input.id = `opportunities-extension-sr-${name}`;
input.name = name;
input.required = true;
fields.set(name, input);
field.htmlFor = input.id;
field.append(node("span", "", `${label} (required)`), input);
combo = searchableSelect(input, field);
combos.set(input, combo);
content.append(field);
extraControls.push(combo.input);
}
wrapper.append(content, message, retryOption);
form.append(wrapper);
let loading = false;
async function loadOptions() {
if (loading || !current()) return;
loading = true;
loadingFields.add(name);
availableFields.delete(name);
updateSave();
retryOption.hidden = true;
message.hidden = false;
message.setAttribute("role", "status");
message.textContent = `Loading ${label.toLowerCase()}...`;
wrapper.setAttribute("aria-busy", "true");
if (input) { input.disabled = true; input.textContent = ""; combo.sync(); }
try {
const items = await request();
if (!current()) return;
if (items.length) availableFields.add(name);
if (input) {
const blank = node("option", "", items.length ? `Select ${label.toLowerCase()}` : "No options available");
blank.value = "";
input.append(blank);
items.forEach(item => { const option = node("option", "", item.name); option.value = item.code; input.append(option); });
input.value = "";
input.disabled = !items.length;
} else {
content.textContent = "";
serviceItems = items;
serviceRadios.length = 0;
const seen = new Set();
items.forEach((item, index) => {
if (seen.has(String(item.Id))) return;
seen.add(String(item.Id));
const row = node("label", "opportunities-extension-sr-service-option");
const radio = node("input");
radio.type = "radio";
radio.name = "service";
radio.required = true;
serviceRadios.push(radio);
radio.value = String(item.Id);
radio.id = `opportunities-extension-sr-service-${index}`;
const copy = node("span");
const abstract = node("span", "opportunities-extension-sr-service-abstract", item.Abstract_c || "");
abstract.id = `${radio.id}-description`;
radio.setAttribute("aria-describedby", abstract.id);
row.htmlFor = radio.id;
copy.append(node("span", "opportunities-extension-sr-service-name", item.RecordName), abstract);
row.append(radio, copy);
content.append(row);
extraControls.push(radio);
});
}
message.textContent = items.length ? "" : "No options available.";
message.hidden = !!items.length;
} catch (error) {
if (!current()) return;
message.setAttribute("role", "alert");
message.textContent = error.message || `Unable to load ${label.toLowerCase()}.`;
retryOption.hidden = false;
} finally {
loading = false;
loadingFields.delete(name);
if (current()) updateSave();
if (current()) { combo?.sync(); wrapper.setAttribute("aria-busy", "false"); }
}
}
retryOption.addEventListener("click", loadOptions);
extraLoads.push(loadOptions);
}
lookupField("language", "Language of delivery", requestLanguage);
lookupField("platform", "Platform", requestPlatform);
textField("description", "Description", "textarea");
lookupField("services", "Services", requestServices, true);
form.append(status, footer);
dialog.append(header, form);
modal.append(dialog);
host.append(modal);
const view = { modal, trigger, loading: false };
active = view;
const current = () => active === view && allowsCrmActivities();
function updateSave() { save.disabled = loadingFields.size > 0 || availableFields.size < 4 || Boolean(view.saving); }
function confirmPayload(payload, requestHeaders = {}) {
return new Promise(resolve => {
const overlay = node("section", "opportunities-extension-action-form-modal opportunities-extension-sr-confirmation");
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-labelledby", "opportunities-extension-sr-confirm-title");
const panel = node("div", "opportunities-extension-action-form-dialog opportunities-extension-sr-confirm-dialog");
const heading = node("header", "opportunities-extension-account-plans-header");
const title = node("h2", "", "Confirm Service Request");
title.id = "opportunities-extension-sr-confirm-title";
heading.append(title);
const body = node("div", "opportunities-extension-sr-confirm-body");
const label = node("p", "", "requestPostServiceRequest");
label.id = "opportunities-extension-sr-confirm-payload-label";
const preview = node("pre", "opportunities-extension-sr-confirm-payload", JSON.stringify(payload, null, 2));
preview.tabIndex = 0;
preview.setAttribute("aria-labelledby", label.id);
body.append(label, preview);
const headerPreviews = [];
for (const name of ["requestSrTokenCatalog", "requestPostServiceRequest"]) {
if (!requestHeaders[name]) continue;
const headerLabel = node("p", "", `${name} - Headers`);
headerLabel.id = `opportunities-extension-sr-confirm-${name}-headers`;
const headerPreview = node("pre", "opportunities-extension-sr-confirm-payload", JSON.stringify(requestHeaders[name], null, 2));
headerPreview.tabIndex = 0;
headerPreview.setAttribute("aria-labelledby", headerLabel.id);
headerPreviews.push(headerPreview);
body.append(headerLabel, headerPreview);
if (name === "requestSrTokenCatalog" && !requestHeaders[name]["x-appbuilder-client-id"]) {
body.append(node("p", "opportunities-extension-action-form-status", "x-appbuilder-client-id: not yet observed in SeaaS requests."));
}
}
const footer = node("footer", "opportunities-extension-action-form-footer");
const cancel = node("button", "", "Cancel");
const confirm = node("button", "", "Confirm Save");
cancel.type = confirm.type = "button";
footer.append(cancel, confirm);
panel.append(heading, body, footer);
overlay.append(panel);
dialog.inert = true;
dialog.setAttribute("aria-hidden", "true");
modal.setAttribute("aria-modal", "false");
modal.append(overlay);
let settled = false;
function finish(confirmed) {
if (settled) return;
settled = true;
overlay.remove();
dialog.inert = false;
dialog.setAttribute("aria-hidden", "false");
modal.setAttribute("aria-modal", "true");
resolve(confirmed);
}
cancel.addEventListener("click", () => finish(false));
confirm.addEventListener("click", () => finish(true));
overlay.addEventListener("keydown", event => {
event.stopPropagation();
if (event.key === "Escape") { event.preventDefault(); finish(false); }
if (event.key === "Tab") {
const controls = [preview, ...headerPreviews, cancel, confirm];
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
}
});
cancel.focus();
});
}
form.addEventListener("submit", async event => {
event.preventDefault();
if (!current() || view.saving || save.disabled) return;
const invalid = [];
const value = name => String(fields.get(name)?.value || "").trim();
for (const [name, input] of fields) {
let valid = !!value(name);
if (combos.has(input)) valid = valid && Array.from(input.children).some(option => option.value === value(name));
if (name === "customerDeadline") {
const date = value(name);
valid = /^\d{4}-\d{2}-\d{2}$/.test(date) && Number.isFinite(Date.parse(`${date}T00:00:00Z`))
&& new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) === date;
}
const control = combos.get(input)?.input || input;
control.setAttribute("aria-invalid", String(!valid));
if (!valid) invalid.push(control);
}
const selected = serviceRadios.find(radio => radio.checked);
const service = serviceItems.find(item => String(item.Id) === selected?.value);
serviceGroup.setAttribute("aria-invalid", String(!service));
if (!service) invalid.push(serviceRadios[0]);
if (invalid.length) {
status.hidden = false;
status.setAttribute("role", "alert");
status.textContent = "Complete the highlighted required fields with valid values.";
invalid[0]?.focus();
return;
}
const payload = {
AccountPartyId: opportunity?.CustomerAccount?.PartyId,
OpportunityName_Id_c: opportunity?.OptyId,
Title: `${service.RecordName}-${number}-${opportunity?.CustomerAccount?.PartyUniqueName || ""}`,
RegionText_c: opportunity?.CustomerAccount?.Region_c,
CountryText_c: opportunity?.CustomerAccount?.PrimaryAddress?.CountryCode,
ServiceNew_Id_c: service.Id,
ProblemDescription: value("description"), Language_c: value("language"),
CustomerDeadline_c: value("customerDeadline"), TechPlatformLookUp_c: value("platform"),
PrimaryPillarNew_c: value("primaryPillar")
};
view.saving = true;
const controls = [dismiss, retry, cancel, ...fields.values(), ...serviceRadios, ...extraControls];
const disabled = controls.map(control => [control, control.disabled]);
controls.forEach(control => { control.disabled = true; });
combos.forEach(combo => combo.sync());
updateSave();
save.textContent = "Preparing...";
form.setAttribute("aria-busy", "true");
status.hidden = true;
try {
const prepared = await requestSave({ phase: "prepare", payload });
if (!current() || host.hidden || !await confirmPayload(prepared.payload, prepared.headers)) return;
save.textContent = "Saving...";
await requestSave({ phase: "confirm", payload: prepared.payload });
view.saving = false;
close();
notify("Service Request saved successfully.");
} catch (error) {
status.hidden = false;
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to save Service Request.";
notify(status.textContent, true);
} finally {
view.saving = false;
if (current()) {
disabled.forEach(([control, wasDisabled]) => { control.disabled = wasDisabled; });
combos.forEach(combo => combo.sync());
updateSave();
save.textContent = "Save";
form.setAttribute("aria-busy", "false");
save.focus();
}
}
});
function option(value, text) { const item = node("option", "", text); item.value = value; select.append(item); }
async function load() {
if (view.loading || !current()) return;
view.loading = true;
loadingFields.add("primaryPillar");
availableFields.delete("primaryPillar");
updateSave();
retry.hidden = true;
select.disabled = true;
select.textContent = "";
option("", "Loading...");
autocomplete.sync();
form.setAttribute("data-state", "loading");
form.setAttribute("aria-busy", "true");
status.hidden = false;
status.setAttribute("role", "status");
status.textContent = "Loading primary pillars...";
try {
const items = await requestPillars();
if (!current()) return;
select.textContent = "";
option("", items.length ? "Select primary pillar" : "No options available");
items.forEach(item => option(item.code, item.name));
select.value = "";
select.disabled = !items.length;
if (items.length) availableFields.add("primaryPillar");
form.setAttribute("data-state", "ready");
status.hidden = true;
} catch (error) {
if (!current()) return;
form.setAttribute("data-state", "error");
select.textContent = "";
option("", "Unavailable");
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to load primary pillars.";
retry.hidden = false;
} finally {
view.loading = false;
loadingFields.delete("primaryPillar");
if (current()) updateSave();
if (current()) { autocomplete.sync(); form.setAttribute("aria-busy", "false"); }
}
}
retry.addEventListener("click", load);
modal.addEventListener("click", event => { if (event.target === modal) close(); });
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key !== "Tab") return;
const controls = [dismiss, autocomplete.input, ...extraControls, retry, save, cancel].filter(control => !control.disabled && !control.hidden);
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey ? index === 0 : index === controls.length - 1)) {
event.preventDefault(); controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
});
dismiss.focus();
load();
extraLoads.forEach(loadOptions => loadOptions());
}
return { createButton, close, open };
}

View File

@@ -1,4 +1,4 @@
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, environment }) {
function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, requestDetails, formatDate, openInternalSrForm, environment }) {
const { document, window, setTimeout, clearTimeout } = environment;
let active = null;
let sequence = 0;
@@ -74,6 +74,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close(true));
panel.append(dismiss);
const detailLoads = [];
items.forEach((item, index) => {
const section = document.createElement("section");
const heading = document.createElement("div");
@@ -90,10 +91,79 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
label.textContent = "SR Number:";
heading.append(label, link);
if (number) heading.append(createCopyButton(String(number), "SR number", "SR Number"));
const add = document.createElement("button");
add.type = "button";
add.className = "opportunities-extension-copy-button opportunities-extension-internal-sr-add";
add.title = "Create Internal Service Request";
add.setAttribute("aria-label", add.title);
add.setAttribute("aria-haspopup", "dialog");
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M12 5v14M5 12h14");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "2");
path.setAttribute("stroke-linecap", "round");
icon.append(path);
add.append(icon);
add.addEventListener("click", event => {
event.preventDefault(); event.stopPropagation();
close();
openInternalSrForm(item, button);
});
heading.append(add);
const details = document.createElement("div");
details.className = "opportunities-extension-sr-details";
details.textContent = descriptions[index];
section.append(heading, details);
const children = document.createElement("div");
children.className = "opportunities-extension-sr-children";
children.setAttribute("aria-busy", "true");
children.setAttribute("role", "status");
children.textContent = "Loading SR details...";
section.append(children);
detailLoads.push(async () => {
try {
const records = await requestDetails(item.SrId);
if (active !== entry || entry.panel !== panel) return;
children.textContent = "";
if (!records.length) children.textContent = "No further details.";
for (const record of records) {
const child = document.createElement("section");
const row = document.createElement("div");
row.className = "opportunities-extension-sr-number";
const label = document.createElement("span");
label.textContent = "Internal SR Number:";
const link = document.createElement(record.SrNumber ? "a" : "span");
link.textContent = record.SrNumber ? String(record.SrNumber) : "N/D";
if (record.SrNumber) {
link.href = `https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=${encodeURIComponent(String(record.SrNumber))}&srId=${encodeURIComponent(String(record.SrId ?? ""))}`;
link.target = "_blank";
link.rel = "noopener noreferrer";
}
row.append(label, link);
if (record.SrNumber) row.append(createCopyButton(String(record.SrNumber), "SR number", "SR Number"));
const body = document.createElement("div");
body.className = "opportunities-extension-sr-details";
body.textContent = ["Title", "OpportunityName_c", "StatusCd", "ServiceNew_c", "AccountPartyName",
"LastUpdateDate", "SvcHealth_c", "AssigneePersonName", "OpenDate"].map(key =>
`${key === "ServiceNew_c" ? "Service Queue" : key}: ${record[key] == null || record[key] === "" ? "N/D" : String(key === "LastUpdateDate" || key === "OpenDate" ? formatDate(record[key]) : record[key])}`).join("\n");
child.append(row, body);
children.append(child);
}
} catch (error) {
if (active !== entry || entry.panel !== panel) return;
children.setAttribute("role", "alert");
children.textContent = error.message || "Unable to load SR details.";
} finally {
if (active === entry && entry.panel === panel) {
children.setAttribute("aria-busy", "false");
entry.position();
}
}
});
panel.append(section);
});
panel.addEventListener("pointerenter", () => clearTimeout(entry.timer));
@@ -101,6 +171,7 @@ function createCrmSrPopover({ MODAL_ID, createCopyButton, createCloseIcon, envir
panel.addEventListener("focusout", focusOut);
host.append(panel);
active = entry;
detailLoads.forEach(load => load());
button.setAttribute("aria-expanded", "true");
entry.position();
document.addEventListener("pointerdown", entry.outside, true);

View File

@@ -6,7 +6,7 @@ function createOpportunitiesDashboard({
environment
}) {
const { document, requestAnimationFrame, performance } = environment;
function renderStageDashboard(items) {
function renderStageDashboard(items, totalCount = items.length) {
const dashboard = document.getElementById(STAGE_DASHBOARD_ID);
if (!dashboard) {
@@ -23,12 +23,12 @@ function createOpportunitiesDashboard({
totalCard.setAttribute("data-stage", "TOTAL");
totalLabel.textContent = "Total Opportunities";
totalValue.className = "opportunities-extension-stage-dashboard-value";
totalValue.setAttribute("aria-label", "Total opportunities");
totalValue.setAttribute("aria-label", `${items.length} filtered opportunities out of ${totalCount} total`);
animateStageDashboardValue(
totalValue,
"TOTAL",
items.length,
(amount) => Math.round(amount).toLocaleString()
(amount) => `${Math.round(amount).toLocaleString()}/${totalCount.toLocaleString()}`
);
totalCard.append(totalLabel, totalValue);
dashboard.append(totalCard);

View File

@@ -24,6 +24,7 @@ function createOpportunitiesFilters({
renderOpportunitiesTable,
saveFilterPreferences,
stringifySearchValue,
matchesActivities,
environment
}) {
const { document } = environment;
@@ -335,12 +336,16 @@ function createOpportunitiesFilters({
function getSortedOpportunities() {
const normalizedSearch = opportunitiesFilterState.tableSearch.trim().toLocaleLowerCase();
const allStages = STAGE_OPTIONS.every(stage => opportunitiesFilterState.selectedStages.has(stage));
const allCustomers = getCustomerOptions().every(option => opportunitiesFilterState.selectedCustomerKeys.has(option.key));
const allOwners = getOwnerOptions().every(option => opportunitiesFilterState.selectedOwnerKeys.has(option.key));
const items = opportunitiesTableState.opportunitiesTableState.items.filter((item) => {
return opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c)
return (allStages || opportunitiesFilterState.selectedStages.has(item.ForecastGroup_c))
&& matchesSelectedStatus(item.StatusCode)
&& opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item))
&& opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item))
&& matchesTableSearch(item, normalizedSearch);
&& (allCustomers || opportunitiesFilterState.selectedCustomerKeys.has(getCustomerKey(item)))
&& (allOwners || opportunitiesFilterState.selectedOwnerKeys.has(getOwnerKey(item)))
&& matchesTableSearch(item, normalizedSearch)
&& matchesActivities(item);
});
const column = OPPORTUNITIES_COLUMNS.find((candidate) => candidate.key === opportunitiesTableState.opportunitiesTableState.sortKey);
@@ -391,6 +396,7 @@ function createOpportunitiesFilters({
}
function matchesSelectedStatus(statusCode) {
if (STATUS_OPTIONS.every(status => opportunitiesFilterState.selectedStatuses.has(status))) return true;
if (statusCode === "WON_PENDING") {
return opportunitiesFilterState.selectedStatuses.has("WON");
}

View File

@@ -79,7 +79,7 @@ function createOpportunitiesFormatters({
(items || opportunitiesTableState.opportunitiesTableState.items).forEach((item) => {
const key = getOwnerKey(item);
const label = getOwnerName(item);
const label = String(getOwnerName(item)).trim() || "-";
if (key && label && !optionsByKey.has(key)) {
optionsByKey.set(key, { key, label: String(label) });
@@ -96,7 +96,9 @@ function createOpportunitiesFormatters({
function getOwnerKey(item) {
const owner = item.Owner;
const partyId = owner && typeof owner === "object" ? owner.PartyId : "";
const name = getOwnerName(item);
const name = String(getOwnerName(item)).trim();
if (!name) return "owner:unassigned";
if (partyId !== null && partyId !== undefined && partyId !== "") {
return `party:${partyId}`;

View File

@@ -0,0 +1,197 @@
function createInternalSrForm({ MODAL_ID, searchableSelect, formatDate, getOpportunity, requestPlatform, requestServices, requestSave, notify, environment }) {
const { document } = environment;
let active = null;
function node(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text !== undefined) el.textContent = text;
return el;
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving) return true;
const previous = active;
active = null;
previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function open(parent, trigger) {
if (active?.saving) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return;
close(false);
const modal = node("section", "opportunities-extension-action-form-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "internal-sr-form-title");
const dialog = node("div", "opportunities-extension-action-form-dialog opportunities-extension-internal-sr-dialog");
const header = node("header", "opportunities-extension-account-plans-header");
const heading = node("div", "opportunities-extension-action-form-heading");
const title = node("h2", "", "Create Internal Service Request");
title.id = "internal-sr-form-title";
const context = node("dl", "opportunities-extension-action-form-context");
const opportunity = getOpportunity(parent.OpportunityName_Id_c?.OptyNumber);
for (const [label, value] of [["SR Number", parent.SrNumber], ["Opportunity", opportunity?.Name], ["Title", parent.Title]]) {
context.append(node("dt", "", label), node("dd", "", value == null || value === "" ? "N/D" : String(value)));
}
heading.append(title, context);
header.append(heading);
const form = node("form", "opportunities-extension-action-form");
form.noValidate = true;
const fields = new Map();
const combos = new Map();
const ready = new Set();
function field(name, label, type, wide = false) {
const wrapper = node("label", "opportunities-extension-action-form-field" + (wide ? " opportunities-extension-action-form-wide" : ""));
const input = node(type === "textarea" || type === "select" ? type : "input");
if (!["textarea", "select"].includes(type)) input.type = type;
if (type === "textarea") input.rows = 4;
input.id = `internal-sr-${name}`;
input.name = name;
fields.set(name, input);
input.required = name !== "customerDeadline";
input.setAttribute("aria-required", String(input.required));
wrapper.htmlFor = input.id;
wrapper.append(node("span", "", input.required ? `${label} (required)` : label), input);
form.append(wrapper);
return { input, wrapper };
}
const titleInput = field("title", "Title", "text", true).input;
const deadline = field("customerDeadline", "Customer Deadline", "text").input;
deadline.readOnly = true;
deadline.value = formatDate(parent.CustomerDeadline_c) || "N/D";
field("internalDeadline", "Internal Deadline", "date");
const platform = field("platform", "Platform", "select", true);
field("description", "Description", "textarea", true);
const services = field("services", "Services", "select", true);
const footer = node("footer", "opportunities-extension-action-form-footer");
const cancel = node("button", "", "Cancel");
const save = node("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
save.disabled = true;
const feedback = node("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
feedback.setAttribute("role", "status");
feedback.hidden = true;
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(save, cancel);
form.append(feedback, footer);
dialog.append(header, form);
modal.append(dialog);
host.append(modal);
const entry = { modal, trigger, saving: false };
active = entry;
const validDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(`${value}T00:00:00Z`))
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value;
form.addEventListener("submit", async event => {
event.preventDefault();
if (active !== entry || entry.saving || ready.size !== 2) return;
let firstInvalid = null;
for (const [name, input] of fields) {
if (!input.required) continue;
const value = String(input.value || "").trim();
const valid = !!value && (name !== "internalDeadline" || validDate(value))
&& (!combos.has(name) || Array.from(input.children).some(option => option.value === value));
input.setAttribute("aria-invalid", String(!valid));
const visible = combos.get(name)?.input || input;
visible.setAttribute("aria-invalid", String(!valid));
if (!valid && !firstInvalid) firstInvalid = visible;
}
feedback.hidden = false;
if (firstInvalid) {
feedback.setAttribute("role", "alert");
feedback.textContent = "Complete all required fields with valid values.";
firstInvalid.focus();
return;
}
const value = name => String(fields.get(name).value || "").trim();
const dateParts = value("customerDeadline").match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
const payload = {
Title: value("title"),
ServiceNew_Id_c: value("services"),
ProblemDescription: value("description"),
CustomerDeadline_c: dateParts ? `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}` : "",
TechPlatformLookUp_c: value("platform"),
ServiceRequest_Id_SRToInternalSR: String(parent.SrId ?? ""),
SvcQuestionsCollection_c: [], InternalDeadline_c: value("internalDeadline")
};
entry.saving = true;
form.setAttribute("aria-busy", "true");
save.disabled = cancel.disabled = true;
save.textContent = "Saving...";
for (const [name, input] of fields) { input.disabled = true; combos.get(name)?.sync(); }
feedback.setAttribute("role", "status");
feedback.textContent = "Creating Internal Service Request...";
try {
await requestSave(payload);
entry.saving = false;
close();
notify("Internal Service Request created successfully.");
} catch (error) {
if (active !== entry) return;
feedback.setAttribute("role", "alert");
feedback.textContent = error.message || "Unable to create Internal Service Request.";
notify(feedback.textContent, true);
} finally {
entry.saving = false;
if (active === entry) {
form.setAttribute("aria-busy", "false");
save.disabled = cancel.disabled = false;
save.textContent = "Save";
for (const [name, input] of fields) { input.disabled = false; combos.get(name)?.sync(); }
}
}
});
function load(target, request, service) {
const { input, wrapper } = target;
input.disabled = true;
const combo = searchableSelect(input, wrapper);
combos.set(input.name, combo);
const status = node("p", "opportunities-extension-action-form-status", "Loading...");
status.setAttribute("role", "status");
wrapper.append(status);
wrapper.setAttribute("aria-busy", "true");
Promise.resolve().then(request).then(items => {
if (active !== entry) return;
const placeholder = node("option", "", items.length ? "Select an option" : "No options available");
placeholder.value = "";
input.append(placeholder);
const seen = new Set();
for (const item of items) {
const value = String(service ? item.Id : item.code);
if (seen.has(value)) continue;
seen.add(value);
const option = node("option", "", service ? item.RecordName : item.name);
option.value = value;
if (service && item.Description_c) option.setAttribute("data-description", item.Description_c);
input.append(option);
}
input.value = "";
input.disabled = !items.length;
if (items.length) ready.add(input.name);
save.disabled = ready.size !== 2;
status.textContent = items.length ? "" : "No options available.";
status.hidden = !!items.length;
combo.sync();
}).catch(error => {
if (active !== entry) return;
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to load options. Close and reopen the form to retry.";
}).finally(() => { if (active === entry) wrapper.setAttribute("aria-busy", "false"); });
}
load(platform, requestPlatform, false);
load(services, () => requestServices(parent.SrId), true);
modal.addEventListener("keydown", event => {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); }
if (event.key === "Tab") {
const focusable = Array.from(form.querySelectorAll("input, textarea, button")).filter(el => !el.disabled && !el.hidden);
const first = focusable[0], last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
});
titleInput.focus();
}
return { open, close };
}

View File

@@ -0,0 +1,60 @@
function createInternalSrSession({ query, environment, sessionUrl, sessionLabel = "Internal SR" }) {
const { document, setTimeout } = environment;
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 || "");
}
async function warmSession(id) {
const frame = document.createElement("iframe");
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;
frame.setAttribute("aria-hidden", "true");
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; });
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 };
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.`);
} 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.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);
}
if (!result?.ok) throw new Error(result?.error || `Unable to load ${sessionLabel} data.`);
return result.items;
}
return { request };
}

View File

@@ -0,0 +1,44 @@
function createProductActivities({ productsContentState, opportunitiesQueryState,
loadCrm, loadActions, blockActions, render }) {
let cycle = null;
function begin(requestId, token, opportunityNumbers = null) {
cycle = { requestId, token, numbers: opportunityNumbers && new Set(opportunityNumbers.map(String)), version: opportunitiesQueryState.periodRequestVersion, completed: false };
}
function complete(requestId) {
const current = cycle;
if (!current || current.completed || current.requestId !== requestId
|| productsContentState.currentProductsRequestId !== requestId) return;
current.completed = true;
const entries = [...productsContentState.opptyProductsByNumber].filter(([number]) => !current.numbers || current.numbers.has(String(number)));
const products = entries.map(([, result]) => result).filter(result => !result.error);
if (!products.length) return;
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId
&& opportunitiesQueryState.periodRequestVersion === current.version;
// Product cache hits do not guarantee that the corresponding activity cache exists.
return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: false })).then(async success => {
if (!isActive()) return;
if (success === false) {
products.forEach(blockActions);
render();
return;
}
const pending = [];
const seen = new Set();
for (const result of products) {
const items = result.items.filter(item => {
const id = String(item?.RevnId ?? "");
if (seen.has(id)) return false;
seen.add(id);
return true;
});
pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: false }));
}
await Promise.allSettled(pending);
if (!isActive()) return;
if (cachedAt > 0) productsContentState.productsLastCachedAt = cachedAt;
render();
}).catch(() => { if (isActive()) { products.forEach(blockActions); render(); } });
}
return { begin, complete };
}

View File

@@ -0,0 +1,49 @@
function createRefreshAll({ messageTypes, sendRuntimeMessage, activityCache, resetRelatedData,
opportunitiesQueryState, productsContentState, consumptionPlansState, consumptionTrendState,
aiSummaryState, reload, onFailure, notify, createRefreshIcon, environment }) {
let pending = null;
function refresh(period) {
if (pending) return pending;
opportunitiesQueryState.periodRequestVersion += 1;
productsContentState.currentProductsRequestId = "";
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = { state: "idle", completed: 0, total: 0, failed: 0, cacheHits: 0 };
consumptionPlansState.accountPlansRequestState = new Map();
consumptionTrendState.consumptionTrendCache = new Map();
consumptionTrendState.consumptionTrendWindowOffsets = new Map();
consumptionTrendState.consumptionTrendSeriesFilters = new Map();
aiSummaryState.aiSummariesByOptyNumber = new Map();
resetRelatedData();
pending = (async () => {
await activityCache.invalidate();
const response = await sendRuntimeMessage({ type: messageTypes.invalidateProductsCache });
if (!response?.ok) throw new Error(response?.error || "Unable to clear product cache.");
await reload(period);
})().catch(error => { onFailure(error); throw error; }).finally(() => { pending = null; });
return pending;
}
function createButton(getPeriod) {
const button = environment.document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-icon-button opportunities-extension-refresh-all";
button.append(createRefreshIcon());
button.title = "Refresh all";
button.setAttribute("aria-label", "Refresh all");
button.addEventListener("click", async () => {
if (button.disabled) return;
button.disabled = true;
button.title = "Refreshing...";
button.setAttribute("aria-busy", "true");
try { await refresh(getPeriod()); }
catch (error) { notify(error.message || "Unable to refresh cached data.", true); }
finally {
button.disabled = false;
button.title = "Refresh all";
button.setAttribute("aria-busy", "false");
}
});
return button;
}
return { refresh, createButton };
}

View File

@@ -0,0 +1,115 @@
function createSearchableSelect({ environment }) {
const { document } = environment;
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function searchableSelect(select, field, onChange) {
const box = element("div", "opportunities-extension-action-combobox");
const input = element("input");
input.type = "text";
input.autocomplete = "off";
input.required = !!select.required;
input.setAttribute("aria-required", String(!!select.required));
select.addEventListener("invalid", event => { event.preventDefault(); input.focus(); });
input.id = `${select.id}-search`;
field.htmlFor = input.id;
const list = element("div", "opportunities-extension-action-options");
list.id = `${select.id}-options`;
list.setAttribute("role", "listbox");
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
input.setAttribute("aria-controls", list.id);
input.setAttribute("aria-expanded", "false");
select.hidden = true;
select.tabIndex = -1;
box.append(input, list);
field.append(box);
let matches = [], index = -1;
const options = () => Array.from(select.children).filter(item => item.value);
const normalize = value => String(value).normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
function closeList() {
list.hidden = true;
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
}
function sync() {
closeList();
input.disabled = !!select.disabled;
input.placeholder = select.children[0]?.textContent || "Select";
input.value = options().find(item => item.value === select.value)?.textContent || "";
input.title = input.value;
}
function highlight() {
Array.from(list.children).forEach((node, i) => node.setAttribute("aria-selected", String(i === index)));
input.setAttribute("aria-activedescendant", matches[index] ? `${list.id}-${index}` : "");
list.children[index]?.scrollIntoView?.({ block: "nearest" });
}
function choose(i) {
if (!matches[i]) return;
const changed = select.value !== matches[i].value;
select.value = matches[i].value;
sync();
if (changed) onChange?.();
}
function positionList() {
const bounds = input.closest?.(".opportunities-extension-action-form-dialog")?.getBoundingClientRect();
const anchor = input.getBoundingClientRect?.();
if (!bounds || !anchor) return;
const below = Math.max(0, bounds.bottom - anchor.bottom - 8);
const above = Math.max(0, anchor.top - bounds.top - 8);
const opensAbove = below < 180 && above > below;
list.setAttribute("data-placement", opensAbove ? "above" : "below");
list.style.maxHeight = `${Math.min(180, opensAbove ? above : below)}px`;
}
function render(query = "") {
if (input.disabled) return;
matches = options().filter(item => normalize(`${item.textContent} ${item.getAttribute?.("data-description") || ""} ${item.value}`).includes(normalize(query)));
index = -1;
list.textContent = "";
matches.forEach((item, i) => {
const row = element("div", "opportunities-extension-action-option", item.textContent);
const description = item.getAttribute?.("data-description");
if (description) row.append(element("small", "opportunities-extension-option-description", description));
row.id = `${list.id}-${i}`;
row.setAttribute("role", "option");
row.setAttribute("aria-selected", "false");
row.addEventListener("mousedown", event => event.preventDefault());
row.addEventListener("click", event => { event.preventDefault(); choose(i); });
list.append(row);
});
if (!matches.length) {
const empty = element("div", "opportunities-extension-action-option", "No results");
empty.setAttribute("role", "status");
list.append(empty);
}
positionList();
list.hidden = false;
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-activedescendant", "");
}
input.addEventListener("click", () => { render(); input.select?.(); });
input.addEventListener("input", () => render(input.value));
input.addEventListener("blur", sync);
input.addEventListener("keydown", event => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (list.hidden) render();
if (matches.length) index = (index + (event.key === "ArrowDown" ? 1 : index < 0 ? 0 : -1) + matches.length) % matches.length;
highlight();
} else if (event.key === "Enter" && !list.hidden) {
event.preventDefault();
choose(index < 0 && matches.length === 1 ? 0 : index);
} else if (event.key === "Escape" && !list.hidden) {
event.preventDefault();
event.stopPropagation();
sync();
} else if (event.key === "Tab") sync();
});
sync();
return { input, sync };
}
return searchableSelect;
}

View File

@@ -0,0 +1,21 @@
function createSrSessionPreparation({ ensureCurrentUser, allowsCrmActivities, sendRequest, reportProgress, appendLog }) {
let pending = null;
function ensure() {
if (pending) return pending;
pending = (async () => {
try {
await ensureCurrentUser();
if (!allowsCrmActivities()) return;
reportProgress("running");
const result = await sendRequest();
appendLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Session initialization failed.");
reportProgress("complete");
} catch {
reportProgress("error");
}
})();
return pending;
}
return { ensure };
}

View File

@@ -9,14 +9,7 @@
.opportunities-extension-auth-badge { transition: none; }
}
.opportunities-extension-crm-sr-badge {
flex: 0 0 auto;
padding: 0 6px;
border: 1px solid #408254;
border-radius: 8px;
font-size: 10px;
line-height: 18px;
font-weight: 600;
white-space: nowrap;
color: #185b30;
background: #e1f1e5;
}
@@ -31,14 +24,15 @@
.opportunities-extension-form {
display: grid;
grid-template-columns: max-content minmax(110px, 1fr) minmax(110px, 1fr) minmax(145px, 180px) max-content max-content;
grid-template-columns: repeat(4, minmax(0, 1fr)) max-content max-content;
align-items: end;
gap: 12px;
min-height: 0;
padding: 12px;
}
.opportunities-extension-form > * {
.opportunities-extension-form > *,
.opportunities-extension-badge-filters > section {
min-width: 0;
width: 100%;
max-width: none;
@@ -47,7 +41,8 @@
font-size: 12px;
}
.opportunities-extension-form > * > span:first-child {
.opportunities-extension-form > * > span:first-child,
.opportunities-extension-badge-filters > section > span:first-child {
font-size: 12px;
line-height: 16px;
}
@@ -89,30 +84,77 @@
}
.opportunities-extension-form .opportunities-extension-stage-filter-button,
.opportunities-extension-form .opportunities-extension-status-filter-button {
.opportunities-extension-form .opportunities-extension-status-filter-button,
.opportunities-extension-form .opportunities-extension-activity-filter-button {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 28px;
min-height: 28px;
padding: 4px 7px;
border: 1px solid currentColor;
border-radius: 18px;
font-family: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
line-height: 18px;
white-space: nowrap;
}
/* Native select intrinsic sizing includes its longest option, not just the selection. */
@media (min-width: 1151px) {
.opportunities-extension-form > .opportunities-extension-field,
.opportunities-extension-form .opportunities-extension-select-wrap,
.opportunities-extension-form .opportunities-extension-field select {
width: max-content;
max-width: none;
.opportunities-extension-cell-actions > .opportunities-extension-products-count,
.opportunities-extension-cell-actions > .opportunities-extension-products-ramp,
.opportunities-extension-cell-actions > .opportunities-extension-crm-sr-badge {
display: inline-flex;
align-items: center;
justify-content: flex-start;
box-sizing: border-box;
flex: 0 0 auto;
gap: 4px;
height: 20px;
min-height: 20px;
margin: 0;
padding: 0 6px;
border-width: 1px;
border-style: solid;
border-radius: 8px;
font-family: inherit;
font-size: 10px;
font-style: normal;
font-weight: 600;
line-height: 16px;
letter-spacing: 0;
text-align: center;
vertical-align: middle;
white-space: nowrap;
}
.opportunities-extension-cell-actions .opportunities-extension-badge-icon,
.opportunities-extension-activity-filter-button .opportunities-extension-badge-icon {
display: block;
order: -1;
flex: 0 0 16px;
width: 16px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
color: inherit;
fill: none;
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
@media (max-width: 1150px) {
.opportunities-extension-form {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.opportunities-extension-form > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter {
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
grid-column: span 2;
}
}
@@ -125,16 +167,253 @@
.opportunities-extension-form .opportunities-extension-status-filter-controls {
flex-wrap: wrap;
}
.opportunities-extension-form > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter {
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter {
grid-template-rows: auto auto;
}
}
@media (max-width: 420px) {
.opportunities-extension-form { grid-template-columns: minmax(0, 1fr); }
.opportunities-extension-form > .opportunities-extension-status-filter,
.opportunities-extension-form > .opportunities-extension-stage-filter { grid-column: auto; }
.opportunities-extension-badge-filters > .opportunities-extension-status-filter,
.opportunities-extension-badge-filters > .opportunities-extension-stage-filter { grid-column: auto; }
}
.opportunities-extension-badge-filters { display: contents; }
.opportunities-extension-activity-filter { display: grid; }
.opportunities-extension-activity-filter[hidden] { display: none; }
.opportunities-extension-activity-filter-controls { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; }
/* Flexible fields share the space left by the four compact badge groups. */
.opportunities-extension-form[data-activity-filters="true"] {
grid-template-columns: repeat(4, minmax(0, 1fr)) repeat(4, max-content);
column-gap: 8px;
}
.opportunities-extension-activity-filter-button {
min-width: 32px;
gap: 4px;
color: #185b30;
background: #fff;
cursor: pointer;
}
.opportunities-extension-activity-filter-button[data-value="missing"] { color: #9f201b; }
.opportunities-extension-activity-filter-button[data-value="under4"] { color: #005f7d; }
.opportunities-extension-activity-filter-button[aria-pressed="true"] { color: #fff; background: #25653a; border-color: #25653a; }
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="missing"] { background: #a12d27; border-color: #a12d27; }
.opportunities-extension-activity-filter-button[aria-pressed="true"][data-value="under4"] { background: #006b86; border-color: #006b86; }
.opportunities-extension-activity-filter-button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter { color: #eeeae6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button { color: #b8e4c6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button:not([aria-pressed="true"]) { background: #333130; border-color: #6a6560; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="missing"] { color: #ffd9d5; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[data-value="under4"] { color: #b5e9fc; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-activity-filter-button[aria-pressed="true"] { border-color: currentColor; }
@media (max-width: 1350px) {
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters {
display: flex;
grid-column: 1 / -1;
flex-wrap: wrap;
align-items: start;
gap: 12px 20px;
}
.opportunities-extension-form[data-activity-filters="true"] .opportunities-extension-badge-filters > section {
width: auto;
max-width: 100%;
grid-template-rows: auto auto;
}
}
@media (max-width: 700px) {
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.opportunities-extension-activity-filter-controls { flex-wrap: wrap; }
}
@media (max-width: 420px) {
.opportunities-extension-form[data-activity-filters="true"] { grid-template-columns: minmax(0, 1fr); }
}
.opportunities-extension-add-action {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex: 0 0 16px;
width: 16px;
height: 16px;
padding: 0;
margin: 0 0 0 2px;
border: 0;
border-radius: 3px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.opportunities-extension-add-action:hover { background: rgba(127, 127, 127, .2); }
.opportunities-extension-add-action:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-action-form-modal {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-items: center;
padding: 12px;
background: rgba(0, 0, 0, .42);
}
.opportunities-extension-action-form-dialog {
box-sizing: border-box;
width: min(560px, 100%);
max-height: calc(100vh - 24px);
overflow-y: auto;
background: #fff;
color: #312d2a;
border: 1px solid #b9b4ae;
border-radius: 6px;
box-shadow: 0 12px 36px rgba(0, 0, 0, .25);
}
.opportunities-extension-action-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 18px;
}
.opportunities-extension-action-form-heading { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-action-form-context {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 6px 12px;
margin: 12px 0 0;
font-size: 12px;
line-height: 18px;
}
.opportunities-extension-action-form-context dt { font-weight: 600; }
.opportunities-extension-action-form-context dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-action-form-field { display: grid; gap: 6px; min-width: 0; font-size: 13px; }
.opportunities-extension-action-form-field[hidden] { display: none; }
.opportunities-extension-action-form-status { margin: 0; font-size: 13px; overflow-wrap: anywhere; }
.opportunities-extension-action-form-status[role="alert"] { color: #a12d27; }
.opportunities-extension-action-form-wide,
.opportunities-extension-action-form-footer { grid-column: 1 / -1; }
.opportunities-extension-action-form-field input,
.opportunities-extension-action-form-field select,
.opportunities-extension-action-form-field textarea {
box-sizing: border-box;
min-width: 0;
width: 100%;
border: 1px solid #8f8983;
border-radius: 4px;
padding: 8px;
font: inherit;
line-height: 20px;
color: inherit;
background: #fff;
}
.opportunities-extension-action-form-field textarea { resize: vertical; min-height: 96px; }
.opportunities-extension-action-form-field select { height: 38px; }
.opportunities-extension-action-form-field select[hidden] { display: none; }
.opportunities-extension-sr-form-dialog { width: min(900px, 100%); }
.opportunities-extension-sr-confirmation { z-index: 6; }
.opportunities-extension-sr-confirm-dialog { width: min(800px, 100%); display: flex; flex-direction: column; overflow: hidden; }
.opportunities-extension-sr-confirm-dialog > header,
.opportunities-extension-sr-confirm-dialog > footer { flex-shrink: 0; }
.opportunities-extension-sr-confirm-dialog > footer { padding: 16px 20px; }
.opportunities-extension-sr-confirm-body { min-height: 0; overflow: auto; padding: 0 20px; }
.opportunities-extension-sr-confirm-body > p { font-size: 13px; font-weight: 600; }
.opportunities-extension-sr-confirm-payload { margin: 0; padding: 16px; border: 1px solid #b9b4ae; border-radius: 4px; background: #f6f5f4; color: #312d2a; font: 13px/1.6 Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.opportunities-extension-sr-confirmation :focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-confirm-payload { background: #211f1d; color: #eeeae6; border-color: #756f68; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-confirmation :focus-visible { outline-color: #80d6e8; }
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 20px 24px; }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { gap: 20px 24px; padding: 24px; align-items: start; }
.opportunities-extension-sr-form-dialog .opportunities-extension-action-form-field { gap: 8px; }
.opportunities-extension-sr-form-dialog textarea { min-height: 112px; }
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-option { padding: 12px 0; gap: 10px; }
.opportunities-extension-sr-form-dialog .opportunities-extension-sr-service-abstract { font-size: 13px; line-height: 20px; }
@media (min-width: 1000px) {
.opportunities-extension-sr-form-dialog { width: min(1120px, 100%); }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px 24px; }
.opportunities-extension-sr-services { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 28px; align-items: start; }
.opportunities-extension-sr-services .opportunities-extension-sr-service-option { margin: 0; border-top: 1px solid #aaa6a040; }
.opportunities-extension-sr-services .opportunities-extension-sr-service-option:nth-child(-n+2) { border-top: 0; }
}
@media (max-width: 600px) {
.opportunities-extension-sr-form-dialog > .opportunities-extension-account-plans-header { padding: 16px; }
.opportunities-extension-sr-form-dialog > .opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); gap: 18px; padding: 16px; }
}
.opportunities-extension-sr-lookup { min-width: 0; padding: 0; margin: 0; border: 0; font-size: 13px; }
.opportunities-extension-sr-lookup legend { padding: 0 0 8px; font-weight: 600; }
.opportunities-extension-sr-lookup[aria-busy="true"] > p { opacity: .65; }
.opportunities-extension-sr-service-option { display: flex; align-items: flex-start; gap: 8px; padding: 9px 0; cursor: pointer; }
.opportunities-extension-sr-service-option + .opportunities-extension-sr-service-option { border-top: 1px solid #aaa6a040; }
.opportunities-extension-sr-service-option input { flex: 0 0 16px; width: 16px; height: 16px; margin: 2px 0 0; accent-color: #00758f; }
.opportunities-extension-sr-service-option > span { min-width: 0; overflow-wrap: anywhere; }
.opportunities-extension-sr-service-name { display: block; font-weight: 600; line-height: 20px; }
.opportunities-extension-sr-service-abstract { display: block; margin-top: 3px; color: #625d58; font-size: 12px; line-height: 18px; white-space: pre-wrap; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-service-abstract { color: #c7c2bc; }
.opportunities-extension-sr-lookup-retry { color: inherit; background: transparent; border: 1px solid #8f8983; border-radius: 4px; padding: 5px 10px; cursor: pointer; }
.opportunities-extension-sr-lookup-retry[hidden] { display: none; }
.opportunities-extension-action-form [aria-invalid="true"] { border-color: #b3261e; outline: 1px solid #b3261e; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form [aria-invalid="true"] { border-color: #ffaaa4; outline-color: #ffaaa4; }
.opportunities-extension-modal .opportunities-extension-copy-toast { z-index: 20; }
.opportunities-extension-action-combobox { position: relative; min-width: 0; }
.opportunities-extension-action-combobox input { height: 38px; }
.opportunities-extension-action-options {
position: absolute;
z-index: 10;
top: calc(100% + 4px);
left: 0;
right: 0;
box-sizing: border-box;
max-height: 180px;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid #8f8983;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, .2);
background: #fff;
color: #292725;
}
.opportunities-extension-action-options[data-placement="above"] { top: auto; bottom: calc(100% + 4px); }
.opportunities-extension-action-options[hidden] { display: none; }
.opportunities-extension-action-option { padding: 8px; line-height: 20px; overflow-wrap: anywhere; cursor: pointer; }
.opportunities-extension-action-option:hover,
.opportunities-extension-action-option[aria-selected="true"] { background: #e3f2f5; color: #005b70; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-options { background: #211f1d; color: #eeeae6; border-color: #918a82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option:hover,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-option[aria-selected="true"] { background: #174955; color: #c9f4ff; }
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: opportunities-action-options-loading 1s ease-in-out infinite alternate; }
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled {
animation: opportunities-action-options-loading 1s ease-in-out infinite alternate;
}
@keyframes opportunities-action-options-loading { from { opacity: .45; } to { opacity: .85; } }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-form-field > :disabled,
.opportunities-extension-action-form[data-state="loading"] .opportunities-extension-action-combobox input:disabled { animation: none; }
}
.opportunities-extension-action-form-footer { display: flex; justify-content: flex-end; gap: 8px; }
.opportunities-extension-action-form-footer button {
border: 1px solid #8f8983;
border-radius: 4px;
padding: 7px 14px;
font: inherit;
font-size: 13px;
background: transparent;
color: inherit;
cursor: pointer;
}
.opportunities-extension-action-form-footer button:disabled { opacity: .55; cursor: not-allowed; }
.opportunities-extension-action-form :focus-visible { outline: 2px solid #00758f; outline-offset: 2px; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-dialog {
color-scheme: dark;
background: #292725;
color: #eeeae6;
border-color: #756f68;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field input,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field select,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-field textarea { background: #211f1d; border-color: #918a82; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form-status[role="alert"] { color: #ffd9d5; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-action-form :focus-visible { outline-color: #80d6e8; }
@media (max-width: 420px) {
.opportunities-extension-action-form { grid-template-columns: minmax(0, 1fr); }
}
/* Keep layout rules on the outer table, never on expanded product tables. */
@@ -232,18 +511,11 @@
}
.opportunities-extension-products-ramp {
display: inline-block;
min-width: 0;
max-width: 100%;
padding: 0 6px;
border: 1px solid #bba5cf;
border-radius: 8px;
background: #f0e8f7;
color: #63358a;
font-size: 10px;
font-weight: 600;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -302,6 +574,11 @@
.opportunities-extension-sr-number { display: flex; align-items: center; gap: 8px; padding-right: 30px; font-weight: 600; }
.opportunities-extension-sr-popover a { color: #00677d; text-decoration: underline; }
.opportunities-extension-sr-details { white-space: pre-wrap; margin-top: 6px; }
.opportunities-extension-sr-children { margin: 10px 0 0 8px; padding-left: 10px; border-left: 2px solid #8c969a; overflow-wrap: anywhere; }
.opportunities-extension-sr-children[aria-busy="true"] { min-height: 28px; animation: opportunities-action-options-loading 1s ease-in-out infinite alternate; }
.opportunities-extension-sr-children[role="alert"] { color: #a12d27; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-children[role="alert"] { color: #ffd9d5; }
@media (prefers-reduced-motion: reduce) { .opportunities-extension-sr-children[aria-busy="true"] { animation: none; } }
.opportunities-extension-sr-dismiss {
position: absolute; top: 6px; right: 6px; width: 28px; height: 28px;
display: grid; place-items: center; padding: 4px; border: 0;
@@ -311,3 +588,43 @@
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover { background: #292827; color: #f1efec; border-color: #77716b; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover section + section { border-color: #5f5953; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-sr-popover a { color: #7ddced; }
.opportunities-extension-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
min-width: 0;
flex: 1;
}
.opportunities-extension-title-row h1 {
display: block;
margin: 0;
padding: 0;
line-height: 1;
}
.opportunities-extension-title-row .opportunities-extension-subtitle-row {
display: flex;
align-items: center;
align-self: center;
flex-wrap: wrap;
gap: 8px;
margin: 0;
padding: 0;
min-width: 0;
}
.opportunities-extension-title-row .opportunities-extension-subtitle-row > span {
box-sizing: border-box;
margin: 0;
min-height: 22px;
line-height: 16px;
}
.opportunities-extension-title-row + .opportunities-extension-header-actions { flex-shrink: 0; }
.opportunities-extension-refresh-all:disabled { opacity: .65; cursor: wait; }
.opportunities-extension-refresh-all:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: opportunities-refresh-spin 1s linear infinite; }
@keyframes opportunities-refresh-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-refresh-all[aria-busy="true"] svg { animation: none; }
}
.opportunities-extension-option-description { display: block; font-size: 12px; line-height: 1.4; margin-top: 4px; white-space: normal; overflow-wrap: anywhere; opacity: .85; }
.opportunities-extension-action-form-dialog.opportunities-extension-internal-sr-dialog { width: min(760px, 100%); }

View File

@@ -23,7 +23,9 @@ function createOpportunitiesTable({
productsContentState,
renderCustomerFilter,
renderOwnerFilter,
renderActivityFilters,
renderStageDashboard,
renderCalendar,
resetCustomerFilter,
resetOwnerFilter,
environment
@@ -47,6 +49,7 @@ function createOpportunitiesTable({
}
function renderOpportunitiesTable() {
renderActivityFilters();
const table = document.getElementById(OPPORTUNITIES_TABLE_ID);
if (!table) {
@@ -99,7 +102,8 @@ function createOpportunitiesTable({
const tableBody = document.createElement("tbody");
const items = getSortedOpportunities();
renderStageDashboard(items);
renderStageDashboard(items, opportunitiesTableState.opportunitiesTableState.items.length);
renderCalendar?.(items, opportunitiesTableState.opportunitiesTableState);
if (opportunitiesTableState.opportunitiesTableState.status === "loading" || opportunitiesTableState.opportunitiesTableState.status === "error" || opportunitiesTableState.opportunitiesTableState.status === "empty" || (opportunitiesTableState.opportunitiesTableState.status === "ready" && items.length === 0)) {
const row = document.createElement("tr");

View File

@@ -0,0 +1,304 @@
function createWorkloadActionForm({ MODAL_ID, allowsCrmActivities, isMissingAction, getOpportunity, getProducts,
requestTeamMembers, requestLookup, getCurrentUserId, saveAction, onActionSaved, notify, formatDate, createCloseIcon, searchableSelect, environment }) {
const { document } = environment;
let active = null;
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function close(restoreFocus = true) {
if (!active) return false;
if (active.saving) return true;
const previous = active;
active = null;
previous.modal.remove();
if (restoreFocus && previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createButton(optyNumber) {
const button = element("button", "opportunities-extension-add-action", "+");
button.type = "button";
button.title = "Add Action";
button.setAttribute("aria-label", `Add Action for ${optyNumber}`);
button.setAttribute("aria-haspopup", "dialog");
button.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
open(optyNumber, button);
});
return button;
}
function open(optyNumber, trigger) {
if (!allowsCrmActivities() || !isMissingAction(optyNumber)) return;
const host = document.getElementById(MODAL_ID);
if (!host || host.hidden) return;
close(false);
const modal = element("section", "opportunities-extension-action-form-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-action-form-title");
const dialog = element("div", "opportunities-extension-action-form-dialog");
const header = element("header", "opportunities-extension-account-plans-header");
const heading = element("div", "opportunities-extension-action-form-heading");
const title = element("h2", "", "Add Action");
title.id = "opportunities-extension-action-form-title";
heading.append(title, element("p", "", String(optyNumber)));
const opportunity = getOpportunity(optyNumber);
const details = element("dl", "opportunities-extension-action-form-context");
for (const [label, value] of [
["Customer", opportunity?.CustomerAccount?.PartyUniqueName],
["Name", opportunity?.Name],
["Close Date", formatDate(opportunity?.EffectiveDate)]
]) {
details.append(element("dt", "", label), element("dd", "", value == null || value === "" ? "-" : String(value)));
}
heading.append(details);
const dismiss = element("button", "opportunities-extension-icon-button");
dismiss.type = "button";
dismiss.title = "Close";
dismiss.setAttribute("aria-label", "Close Action form");
dismiss.append(createCloseIcon());
dismiss.addEventListener("click", () => close());
header.append(heading, dismiss);
const form = element("form", "opportunities-extension-action-form");
form.noValidate = true;
const workloads = [...new Map(getProducts(optyNumber).filter(product => /^\d+$/.test(String(product?.RevnId ?? "")))
.map(product => [String(product.RevnId), product])).entries()];
const workloadField = element("label", "opportunities-extension-action-form-field opportunities-extension-action-form-wide");
const workload = element("select");
workload.id = "opportunities-extension-action-form-workload";
workloadField.htmlFor = workload.id;
function option(select, value, label) {
const item = element("option", "", label);
item.value = value;
select.append(item);
}
option(workload, "", "Select workload");
for (const [id, product] of workloads) option(workload, id, `${product.WorkloadName_c || product.ProdGroupName || "Workload"} (${id})`);
workload.value = workloads.length === 1 ? workloads[0][0] : "";
workloadField.hidden = workloads.length <= 1;
workloadField.append(element("span", "", "Workload"), workload);
const status = element("p", "opportunities-extension-action-form-status opportunities-extension-action-form-wide");
status.setAttribute("role", "status");
form.append(workloadField, status);
const searchable = new Map();
searchable.set(workload, searchableSelect(workload, workloadField, () => loadOptions()));
const fields = [];
const selects = [];
const lookupKeys = { role: "WWB_ACTION_ROLE", action: "WWB_ACTION_NAME", team: "WWB_ACTION_TEAM",
status: "WWB_ACTION_STATUS", complexity: "WWB_ACTION_COMPLEXITY" };
for (const [name, label, type] of [
["owner", "Owner", "select"], ["role", "Role", "select"], ["action", "Action", "select"],
["team", "Team", "select"], ["startDate", "Start date", "date"],
["endDate", "End Date", "date"], ["status", "Status", "select"],
["complexity", "Complexity", "select"], ["notes", "Notes", "textarea"]
]) {
const field = element("label", "opportunities-extension-action-form-field");
const input = element(type === "select" || type === "textarea" ? type : "input");
if (type === "select") selects.push({ input, label, name });
else if (type === "textarea") input.rows = 5;
else input.type = type;
input.disabled = true;
input.required = name !== "notes";
input.setAttribute("aria-required", String(input.required));
input.name = name;
input.id = `opportunities-extension-action-form-${name}`;
field.htmlFor = input.id;
if (["notes", "team", "action"].includes(name)) field.className += " opportunities-extension-action-form-wide";
field.append(element("span", "", `${label} (${input.required ? "required" : "optional"})`), input);
if (type === "select") searchable.set(input, searchableSelect(input, field));
form.append(field);
fields.push(input);
}
const footer = element("footer", "opportunities-extension-action-form-footer");
const save = element("button", "opportunities-extension-action-form-save", "Save");
save.type = "submit";
save.disabled = true;
save.title = "Save Action";
const retry = element("button", "", "Retry");
retry.type = "button";
retry.hidden = true;
const cancel = element("button", "", "Cancel");
cancel.type = "button";
cancel.addEventListener("click", () => close());
footer.append(retry, save, cancel);
form.append(footer);
dialog.append(header, form);
modal.append(dialog);
host.append(modal);
const view = { modal, trigger, generation: 0, lookup: null, loadingId: null };
active = view;
const isOpen = () => active === view && allowsCrmActivities();
let ready = false;
function validate() {
const invalid = [];
const value = name => String(fields.find(field => field.name === name)?.value || "").trim();
for (const field of fields) {
let valid = !field.required || !!value(field.name);
if (field.required && searchable.has(field)) valid = Array.from(field.children).some(option => option.value && option.value === field.value);
if (field.type === "date") {
const date = value(field.name);
valid = /^\d{4}-\d{2}-\d{2}$/.test(date) && Number.isFinite(Date.parse(`${date}T00:00:00.000Z`));
if (valid) valid = new Date(`${date}T00:00:00.000Z`).toISOString().slice(0, 10) === date;
if (field.name === "endDate" && value("startDate") > date) valid = false;
}
const control = searchable.get(field)?.input || field;
control.setAttribute("aria-invalid", String(!valid));
if (!valid) invalid.push(control);
}
return { invalid, value };
}
form.addEventListener("submit", async event => {
event.preventDefault();
if (!ready || !isOpen() || view.saving) return;
const { invalid, value } = validate();
if (invalid.length) {
status.hidden = false;
status.setAttribute("role", "alert");
status.textContent = "Complete the highlighted required fields. End date must be on or after start date.";
invalid[0].focus();
return;
}
const userId = getCurrentUserId();
if (!userId) { notify("Unable to save: current user is unavailable.", true); return; }
const payload = { parentType: "WORKLOAD", parentId: workload.value, action: {
ownerEmail: value("owner"), role: value("role"), name: value("action"), team: value("team"), tags: "",
startDate: `${value("startDate")}T00:00:00.000Z`, endDate: `${value("endDate")}T00:00:00.000Z`,
status: value("status"), complexity: value("complexity"), notes: value("notes") ? [value("notes")] : [],
objective: "", createdBy: userId, updatedBy: userId
} };
view.saving = true;
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = true;
fields.forEach(field => { field.disabled = true; });
searchable.forEach(control => control.sync());
save.textContent = "Saving...";
form.setAttribute("aria-busy", "true");
status.hidden = true;
try {
const result = await saveAction(payload, isOpen);
if (!result) return;
view.saving = false;
close();
try {
await onActionSaved(optyNumber, payload.parentId, result.result, payload.action);
} catch {
notify("Action saved, but the local badge could not be updated.", true);
return;
}
notify("Action saved successfully.");
} catch (error) {
status.hidden = false;
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to save Action.";
notify(status.textContent, true);
} finally {
view.saving = false;
if (active === view) {
save.disabled = cancel.disabled = dismiss.disabled = workload.disabled = false;
fields.forEach(field => { field.disabled = false; });
searchable.forEach(control => control.sync());
save.textContent = "Save";
form.setAttribute("aria-busy", "false");
}
}
});
const settled = promise => promise.then(result => ({ result }), error => ({ error }));
async function loadOptions(refresh = false) {
const id = workload.value;
if (!isOpen() || (view.loadingId === id && !refresh)) return;
const generation = ++view.generation;
const isCurrent = () => isOpen() && view.generation === generation;
ready = false;
save.disabled = true;
retry.hidden = true;
status.hidden = false;
status.setAttribute("role", "status");
fields.forEach(input => { input.disabled = true; });
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Loading..."); });
const syncSearch = () => selects.forEach(({ input }) => searchable.get(input).sync());
syncSearch();
if (!workloads.length) {
form.setAttribute("data-state", "error");
form.setAttribute("aria-busy", "false");
status.setAttribute("role", "alert");
status.textContent = "No product with a valid RevnId is available for this opportunity.";
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
syncSearch();
return;
}
if (!view.lookup || refresh) view.lookup = settled(requestLookup(isOpen));
if (!workloads.some(([value]) => value === id)) {
view.loadingId = null;
form.setAttribute("data-state", "waiting");
form.setAttribute("aria-busy", "false");
status.textContent = "Select a workload to load Action options.";
return;
}
view.loadingId = id;
form.setAttribute("data-state", "loading");
form.setAttribute("aria-busy", "true");
status.textContent = "Loading Action options...";
try {
const [lookup, team] = await Promise.all([view.lookup, settled(requestTeamMembers(id, isCurrent))]);
if (!isCurrent()) return;
if (lookup.error || team.error) throw lookup.error || team.error;
const values = selects.map(({ name }) => name === "owner"
? team.result.members.map(({ email }) => ({ code: email, name: email }))
: lookup.result.lookups[lookupKeys[name]]);
const defaults = { owner: getCurrentUserId(), role: "Cloud Architect",
action: "Success Plan & Consumption review", status: "Complete" };
const normalize = value => String(value ?? "").trim().toLowerCase();
for (const [index, { input, label, name }] of selects.entries()) {
input.textContent = "";
option(input, "", values[index].length ? `Select ${label.toLowerCase()}` : "No options available");
for (const item of values[index]) option(input, item.code, item.name);
const match = defaults[name] && values[index].find(item =>
normalize(name === "owner" ? item.code : item.name) === normalize(defaults[name]));
input.value = match ? match.code : "";
}
fields.forEach(input => { input.disabled = false; });
selects.forEach(({ input }, index) => { input.disabled = !values[index].length; });
form.setAttribute("data-state", "ready");
ready = true;
save.disabled = false;
status.hidden = true;
syncSearch();
if (document.activeElement === dismiss) {
const first = fields.find(field => !field.disabled);
(searchable.get(first)?.input || first)?.focus();
}
} catch (error) {
if (!isCurrent()) return;
form.setAttribute("data-state", "error");
status.setAttribute("role", "alert");
status.textContent = error.message || "Unable to load Action options.";
selects.forEach(({ input }) => { input.textContent = ""; option(input, "", "Unavailable"); });
syncSearch();
retry.hidden = false;
} finally {
if (isCurrent()) { view.loadingId = null; form.setAttribute("aria-busy", "false"); }
}
}
retry.addEventListener("click", () => { if (!view.loadingId) loadOptions(true); });
workload.addEventListener("change", () => loadOptions());
modal.addEventListener("click", event => { if (event.target === modal) close(); });
modal.addEventListener("keydown", event => {
if (event.key !== "Tab") return;
const controls = [dismiss, ...(!workloadField.hidden ? [workload] : []), ...fields, retry, save, cancel]
.map(control => searchable.get(control)?.input || control)
.filter(control => !control.disabled && !control.hidden);
const index = controls.indexOf(document.activeElement);
if (!controls.length) { event.preventDefault(); return; }
if (index === -1 || (!event.shiftKey && index === controls.length - 1) || (event.shiftKey && index === 0)) {
event.preventDefault();
controls[event.shiftKey ? controls.length - 1 : 0].focus();
}
});
if (!workloadField.hidden) searchable.get(workload).input.focus();
else dismiss.focus();
loadOptions();
}
return { createButton, open, close };
}

View File

@@ -0,0 +1,17 @@
function createWorkloadActionOptions({ requestConsumerTokenServiceOnce, sendRuntimeMessage, appendBackgroundRequestLog, messageTypes }) {
async function request(type, workloadId, isCurrent, payload) {
const authorizations = await requestConsumerTokenServiceOnce();
if (!isCurrent()) return null;
const authorization = authorizations?.workloadActions;
if (!authorization?.value) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const result = await sendRuntimeMessage({ type, ...(workloadId === undefined ? {} : { workloadId }), ...(payload ? { payload } : {}), authorization });
appendBackgroundRequestLog(result?.debugRequests || []);
if (!result?.ok) throw new Error(result?.error || "Unable to load Action options.");
return result;
}
return {
save: (payload, isCurrent) => request(messageTypes.requestMgmtAction, undefined, isCurrent, payload),
requestTeamMembers: (id, isCurrent) => request(messageTypes.requestActionTeamMembers, id, isCurrent),
requestLookup: isCurrent => request(messageTypes.requestActionFindLookup, undefined, isCurrent)
};
}

View File

@@ -1,31 +1,59 @@
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) {
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, createActionButton, closeActionForm, setBadgeContent, environment }) {
let session = newSession();
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0 }; }
function newSession() { return { enabled: true, records: new Map(), queue: [], running: 0, progress: new Set() }; }
function updateProgress(current) {
if (current !== session || !current.enabled || !current.progress.size) return;
const records = [...current.progress];
const completed = records.filter(record => record.status !== "loading").length;
const failed = records.filter(record => record.status === "error").length;
reportProgress(completed < records.length ? "running" : failed ? "complete-with-errors" : "complete", completed, records.length);
}
function reset(enabled = true) {
closeActionForm(false);
session.enabled = false;
for (const job of session.queue) job.record.resolveCompletion();
session.queue.length = 0;
session = newSession();
session.enabled = enabled;
reportProgress("idle");
}
function idsFor(products) {
return [...new Set((products?.items || []).map(item => String(item?.RevnId ?? "").trim()).filter(Boolean))];
}
function loadProducts(products) {
function loadProducts(products, refresh = false, cacheOptions = null) {
if (!session.enabled || !allowsCrmActivities()) return;
const pending = [];
if ([...session.progress].every(record => record.status !== "loading")) session.progress.clear();
for (const revnId of idsFor(products)) {
if (session.records.has(revnId)) continue;
const record = { status: "loading", actions: [], hasNext: false };
session.records.set(revnId, record);
session.queue.push({ revnId, record });
if (!refresh && session.records.has(revnId)) {
pending.push(session.records.get(revnId).completion);
continue;
}
session.progress.delete(session.records.get(revnId));
const record = { status: "loading", actions: [], hasNext: false };
record.completion = new Promise(resolve => { record.resolveCompletion = resolve; });
pending.push(record.completion);
session.records.set(revnId, record);
if (!cacheOptions) session.progress.add(record);
session.queue.push({ revnId, record, cacheOptions });
}
updateProgress(session);
pump(session);
return Promise.all(pending);
}
function reopen() {
reset();
for (const products of productsContentState.opptyProductsByNumber.values()) loadProducts(products);
queueProductsTableRender();
}
function blockProducts(products) {
if (!allowsCrmActivities()) return;
for (const id of idsFor(products)) {
session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." });
}
updateProgress(session);
}
function pump(current) {
while (current.enabled && current === session && current.running < 4 && current.queue.length) {
const job = current.queue.shift();
@@ -33,26 +61,48 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
run(current, job).finally(() => { current.running -= 1; pump(current); });
}
}
async function run(current, { revnId, record }) {
async function run(current, { revnId, record, cacheOptions }) {
try {
const cached = cacheOptions ? await activityCache.read("actions", revnId, cacheOptions.cachedAt) : null;
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
if (cached) {
record.actions = cached.items;
record.hasNext = cached.hasNext;
record.status = "ready";
return;
}
if (cacheOptions?.cacheOnly) { record.status = "error"; record.error = "No matching activity cache for this product update."; return; }
current.progress.add(record);
updateProgress(current);
let result;
for (let attempt = 1; attempt <= 5; attempt += 1) {
try {
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
const authorizations = await requestConsumerTokenServiceOnce();
if (!current.enabled || current !== session || !allowsCrmActivities()) return;
const result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
if (!current.enabled || current !== session || !allowsCrmActivities()) return;
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
result = await sendRuntimeMessage({ type: messageTypes.requestWlActions, revnId, authorization: authorizations?.workloadActions });
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !allowsCrmActivities()) return;
appendBackgroundRequestLog(result?.debugRequests);
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
break;
} catch (error) {
if (attempt === 5) throw error;
}
}
record.actions = result.actions;
record.hasNext = result.hasNext;
record.status = "ready";
if (cacheOptions) await activityCache.write("actions", revnId, cacheOptions.cachedAt, result.actions, result.hasNext);
} catch (error) {
if (!current.enabled || current !== session) return;
record.status = "error";
record.error = error.message || "Unable to load workload actions.";
} finally {
if (current.enabled && current === session) queueProductsTableRender();
record.resolveCompletion();
if (current.enabled && current === session) { updateProgress(current); queueProductsTableRender(); }
}
}
function createBadge(optyNumber) {
function getSummary(optyNumber) {
if (!session.enabled || !allowsCrmActivities() || !optyNumber) return null;
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
if (!products) return null;
@@ -63,12 +113,47 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
const uncertain = products.error || (products.items || []).some(item => !String(item?.RevnId ?? "").trim())
|| records.some(record => record?.status === "error" || record?.hasNext);
const principal = authSessionState.principal;
const owned = Boolean(principal) && actions.some(action => typeof action.owner === "string" && action.owner.trim() === principal);
const owned = Boolean(principal?.trim()) && actions.some(action => typeof action.owner === "string"
&& action.owner.trim().toLowerCase() === principal.trim().toLowerCase());
const state = owned ? "matched" : pending ? "loading" : uncertain || !principal ? "error" : "missing";
return { state, actions, records, principal, pending, uncertain };
}
async function applySavedAction(optyNumber, revnId, response, submittedAction = {}) {
const products = productsContentState.opptyProductsByNumber.get(String(optyNumber));
const id = String(revnId);
if (!session.enabled || !allowsCrmActivities() || !idsFor(products).includes(id)) return false;
const returned = Array.isArray(response) ? response : Array.isArray(response?.actions) ? response.actions : [response?.action || response];
const saved = returned.filter(action => action && typeof action === "object" && action.id != null
&& (action.revenueId == null || String(action.revenueId) === id));
if (!saved.length) return false;
const previous = session.records.get(id);
const actions = [...(previous?.actions || [])];
for (const action of saved) {
// The POST can return a display name in owner, unlike the workload GET.
const ownerEmail = typeof action.ownerEmail === "string" && action.ownerEmail.trim()
? action.ownerEmail.trim() : submittedAction.ownerEmail || action.owner || "";
const normalized = { ...action, ownerEmail, owner: ownerEmail };
const index = actions.findIndex(item => String(item.id) === String(action.id));
if (index === -1) actions.push(normalized);
else actions[index] = { ...actions[index], ...normalized };
}
const record = { status: "ready", actions, hasNext: previous?.hasNext || false,
completion: Promise.resolve(), resolveCompletion() {} };
session.records.set(id, record);
previous?.resolveCompletion?.();
session.progress.delete(previous);
queueProductsTableRender();
if (products.cachedAt) await activityCache.write("actions", id, products.cachedAt, actions, record.hasNext);
return true;
}
function createBadge(optyNumber) {
const summary = getSummary(optyNumber);
if (!summary) return null;
const { state, actions, records, principal, pending, uncertain } = summary;
const badge = environment.document.createElement("span");
badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge";
badge.setAttribute("data-state", state);
badge.textContent = state === "matched" ? "\u2705 Act" : state === "missing" ? "\u26d4 Act" : state === "loading" ? "Act ..." : "Act ?";
setBadgeContent(badge, "actions", state === "matched" || state === "missing" ? actions.length : state === "loading" ? "..." : "?");
badge.title = actions.length ? actions.map(action => [
`Action: ${action.name ?? "N/D"}`, `ID: ${action.id ?? "N/D"}`, `Status: ${action.status ?? "N/D"}`,
`Owner: ${action.owner || "N/D"}`, `Email: ${action.ownerEmail || "N/D"}`, `Workload: ${action.workloadName || "N/D"}`,
@@ -76,12 +161,15 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie
].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products."
: state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests.";
if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled.";
const errors = [...new Set(records.filter(record => record?.error).map(record => record.error))];
if (errors.length) badge.title += `\n${errors.join("\n")}`;
if (!principal) badge.title += "\nTokenrelay principal is unavailable; ownership cannot be verified.";
else if (actions.length && state === "missing") badge.title += "\nNone of the returned actions belong to the signed-in principal.";
if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked.";
badge.tabIndex = 0;
badge.setAttribute("aria-label", badge.title);
if (state === "missing") badge.append(createActionButton(String(optyNumber)));
return badge;
}
return { reset, reopen, loadProducts, createBadge };
return { reset, reopen, loadProducts, blockProducts, createBadge, getSummary, applySavedAction };
}

View File

@@ -5,8 +5,17 @@ function createProductsCache({
storageLocalGet,
storageLocalSet
}) {
let generation = 0;
async function invalidate() {
generation += 1;
productsCacheState.inFlightProducts.clear();
productsCacheState.productsCachePromise = Promise.resolve({});
await saveProductsCache({});
}
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
const currentGeneration = generation;
const cache = await getProductsCache();
if (generation !== currentGeneration) return getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug);
const cachedEntry = cache[optyNumber];
if (cachedEntry && Array.isArray(cachedEntry.items) && Date.now() - Number(cachedEntry.fetchedAt) < cacheMaxAgeMs) {
@@ -24,15 +33,14 @@ function createProductsCache({
})
.then(async (items) => {
const fetchedAt = Date.now();
cache[optyNumber] = {
fetchedAt,
items
};
if (generation === currentGeneration) {
cache[optyNumber] = { fetchedAt, items };
await saveProductsCache(cache).catch(() => {});
}
return { items, fetchedAt };
})
.finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber);
if (productsCacheState.inFlightProducts.get(optyNumber) === flight) productsCacheState.inFlightProducts.delete(optyNumber);
});
}
@@ -78,5 +86,5 @@ function createProductsCache({
return productsCacheState.productsCacheWritePromise;
}
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
}

View File

@@ -7,7 +7,8 @@ function createProductsContentJobs({
preferencesState,
productsContentState,
renderOpportunitiesTable,
loadWorkloadActions,
beginProductActivities,
completeProductActivities,
sendRuntimeMessage,
environment
}) {
@@ -52,6 +53,7 @@ function createProductsContentJobs({
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
productsContentState.currentProductsRequestId = requestId;
beginProductActivities(requestId, token, optyNumbers);
productsContentState.opptyProductsByNumber = new Map();
productsContentState.expandedProductOptyNumbers = new Set();
productsContentState.productsProgress = {
@@ -119,11 +121,6 @@ function createProductsContentJobs({
cachedAt: Number(message.cachedAt) || 0,
error: message.error || ""
});
loadWorkloadActions(productsContentState.opptyProductsByNumber.get(String(message.optyNumber)));
}
if (Number(message.cachedAt) > productsContentState.productsLastCachedAt) {
productsContentState.productsLastCachedAt = Number(message.cachedAt);
}
productsContentState.productsProgress = {
@@ -134,6 +131,7 @@ function createProductsContentJobs({
cacheHits: Number(message.cacheHits) || 0
};
renderProductsProgress();
if (message.state === "complete" || message.state === "complete-with-errors") completeProductActivities(message.requestId);
queueProductsTableRender();
return false;
});

View File

@@ -1,5 +1,6 @@
function createProductsDetails({
OPPORTUNITIES_COLUMNS,
setBadgeContent,
createAiSummaryAlert,
formatOracleResponseDate,
productsContentState,
@@ -17,7 +18,7 @@ function createProductsDetails({
const badge = document.createElement("span");
badge.className = "opportunities-extension-products-ramp";
badge.setAttribute("data-missing", String(values.includes("N/D")));
badge.textContent = `RM ${values.join(",")}`;
setBadgeContent(badge, "ramp", values.join(" | "));
badge.title = `Ramp Months: ${values.join(", ")}`;
badge.setAttribute("aria-label", badge.title);
return badge;
@@ -31,7 +32,7 @@ function createProductsDetails({
button.type = "button";
button.className = "opportunities-extension-products-count";
button.textContent = String(count);
setBadgeContent(button, "products", count);
button.setAttribute("aria-expanded", String(isExpanded));
button.setAttribute("aria-label", `${count} product${count === 1 ? "" : "s"} for opportunity ${normalizedOptyNumber}`);
button.title = productsResult.error ? productsResult.error : `${count} product${count === 1 ? "" : "s"}`;

View File

@@ -17,7 +17,10 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
try {
const planNumber = String(plan.subPlanNum ?? "").trim();
if (!planNumber) throw new Error("Plan number is unavailable for this account plan.");
if (!pending.has(planNumber)) pending.set(planNumber, request(planNumber).finally(() => pending.delete(planNumber)));
if (!pending.has(planNumber)) {
const promise = request(planNumber).finally(() => { if (pending.get(planNumber) === promise) pending.delete(planNumber); });
pending.set(planNumber, promise);
}
handle.showItems(await pending.get(planNumber));
} catch (error) {
handle.showError(error.message || "Unable to load rate card.");
@@ -34,5 +37,5 @@ function createRatecardController({ messageTypes, requestConsumerTokenServiceOnc
button.addEventListener("click", () => showRatecard(plan, button));
return button;
}
return { createRatecardButton, showRatecard };
return { createRatecardButton, showRatecard, resetRequests: () => pending.clear() };
}

View File

@@ -39,7 +39,8 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
try {
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
if (!pending.has(partyNumber)) {
pending.set(partyNumber, load(partyNumber).finally(() => pending.delete(partyNumber)));
const request = load(partyNumber).finally(() => { if (pending.get(partyNumber) === request) pending.delete(partyNumber); });
pending.set(partyNumber, request);
}
view.render(await pending.get(partyNumber));
} catch (error) {
@@ -48,5 +49,5 @@ function createServiceRequestsAction({ messageTypes, requestConsumerTokenService
});
return button;
}
return { createServiceRequestsButton };
return { createServiceRequestsButton, resetRequests: () => pending.clear() };
}

View File

@@ -11,16 +11,17 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
] },
keywords: null,
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "_actions", "Title",
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "CustomerDeadline_c", "PrimaryPillarNew_c", "_actions", "Title",
"Owner.PersonFirstName", "Owner.PersonLastName", "Owner.PrimaryEmail.EmailAddress", "DoNotCallFlag", "DoNotEmailFlag"],
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c",
"OpportunityName_Id_c.OptyNumber", "Owner"], skipInValidFields: true, limit: 15, offset
};
}
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version)) {
async function requestCrmServiceRequest(token, version, isActive = () => isCurrentPeriodRequest(version), onProgress = () => {}) {
const items = [];
let offset = 0;
let attempt = 1;
while (isActive()) {
const payload = createPayload(offset);
const headers = {
@@ -28,30 +29,33 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
Origin: "https://eeho.fa.us2.oraclecloud.com", Preference: "transient"
};
const entry = logRequestStart({ name: "resquestCrmServiceRequest", method: "POST", url,
headers: { ...headers, Authorization: `Bearer ${maskToken(token)}` }, body: JSON.stringify(payload, null, 2), metadata: { offset } });
headers: { ...headers }, body: JSON.stringify(payload, null, 2), metadata: { offset, attempt } });
try {
const response = await fetch(url, {
method: "POST", credentials: "include", headers, body: JSON.stringify(payload)
});
logRequestSuccess(entry, response, await readResponseBodyForDebug(response));
if (!response.ok) throw new Error(`CRM service request query failed with status ${response.status}.`);
if (response.status !== 200) throw new Error(`CRM service request query failed with status ${response.status}.`);
const data = await response.json();
if (!data || !Array.isArray(data.items) || typeof data.hasMore !== "boolean") {
throw new Error("CRM service request query returned an invalid response.");
}
if (!isActive()) return null;
items.push(...data.items);
if (!data.hasMore) return items;
const received = data.count === undefined ? data.items.length : Number(data.count);
const current = data.offset === undefined ? offset : Number(data.offset);
if (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|| current !== offset || current + received <= offset) {
if (data.hasMore && (!data.items.length || !Number.isInteger(received) || received <= 0 || !Number.isInteger(current)
|| current !== offset || current + received <= offset)) {
throw new Error("CRM service request pagination did not advance.");
}
items.push(...data.items);
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null);
if (!data.hasMore) return items;
offset = current + received;
attempt = 1;
} catch (error) {
logRequestFailure(entry, error.message || "CRM service request query failed.");
throw error;
if (attempt === 5) throw error;
attempt += 1;
}
}
return null;

View File

@@ -24,7 +24,7 @@ function createFusionOpportunities({
url: OPPORTUNITIES_QUERY_URL,
headers: {
Accept: "application/json",
Authorization: `Bearer ${maskToken(token)}`,
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Origin: "https://eeho.fa.us2.oraclecloud.com",
Preference: "transient"
@@ -150,7 +150,9 @@ function createFusionOpportunities({
"OptyId",
"CustomerAccount.PartyUniqueName",
"CustomerAccount.PartyId",
"CustomerAccount.PartyNumber"
"CustomerAccount.PartyNumber",
"CustomerAccount.Region_c",
"CustomerAccount.PrimaryAddress.CountryCode"
],
sort: [],
language: "en",

View File

@@ -0,0 +1,140 @@
function createFusionSrCreate({ fetchJson, tokenHeaders, previewTokenHeaders, internalServiceRequestHeaders }) {
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/";
const catalog = base + "crmRestApiResources/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/11.13.18.05/Catalogue_c";
const resourceUsers = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/resourceUsers";
const endpoint = base + "oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/mgmt/serviceRequests";
function numericId(value, name) {
if (!/^\d+$/.test(String(value ?? "")) || !Number.isSafeInteger(Number(value)) || Number(value) <= 0) throw new Error(`Missing or invalid ${name}.`);
return Number(value);
}
function text(value, name) {
if (typeof value !== "string" || !value.trim()) throw new Error(`Missing ${name}.`);
return value.trim();
}
function dateOnly(value, name) {
const date = text(value, name);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(`${date}T00:00:00Z`))
|| new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) !== date) throw new Error(`Invalid ${name}.`);
return date;
}
function payloadFor(input) {
const payload = {};
for (const name of ["AccountPartyId", "OpportunityName_Id_c", "ServiceNew_Id_c"]) payload[name] = numericId(input?.[name], name);
for (const name of ["Title", "RegionText_c", "ProblemDescription", "Language_c", "CustomerDeadline_c", "TechPlatformLookUp_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(input?.[name], name);
dateOnly(payload.CustomerDeadline_c, "Customer deadline");
return { ...payload, SvcQuestionsCollection_c: [], SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" };
}
async function requestResourceUser(email, debugRequests) {
const emailAddress = text(email, "current user email");
const resource = await fetchJson(`${resourceUsers}?emailAddress=${encodeURIComponent(emailAddress)}`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestResourceUser", debugRequests);
return numericId(resource?.resourcePartyId, "resourcePartyId");
}
async function requestPostInternalServiceRequest(message) {
const debugRequests = [];
try {
const source = message.payload;
const parentSrId = numericId(source?.ServiceRequest_Id_SRToInternalSR, "parent SrId");
const payload = {
Title: text(source?.Title, "Title"),
ServiceNew_Id_c: numericId(source?.ServiceNew_Id_c, "ServiceNew_Id_c"),
ProblemDescription: text(source?.ProblemDescription, "Description"),
CustomerDeadline_c: dateOnly(source?.CustomerDeadline_c, "Customer deadline"),
TechPlatformLookUp_c: text(source?.TechPlatformLookUp_c, "Platform"),
InternalDeadline_c: `${dateOnly(source?.InternalDeadline_c, "Internal Deadline")}T00:00:00.000Z`,
SvcQuestionsCollection_c: []
};
const parent = await requestSRInfo(parentSrId, debugRequests);
payload.ServiceRequest_Id_SRToInternalSR = String(parentSrId);
for (const name of ["AccountPartyId", "OpportunityName_Id_c"]) payload[name] = numericId(parent[name], name);
for (const name of ["OpportunityName_c", "RegionText_c", "SvcHealth_c", "Language_c", "CountryText_c", "PrimaryPillarNew_c"]) payload[name] = text(parent[name], name);
payload.SecondaryPillarNew_c = parent.SecondaryPillarNew_c == null ? null : text(parent.SecondaryPillarNew_c, "SecondaryPillarNew_c");
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPICloudAccount/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests";
const result = await fetchJson(url, { method: "POST", credentials: "include",
headers: await internalServiceRequestHeaders(url),
body: JSON.stringify(payload)
}, "requestPostInternalServiceRequest", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestSRInfo(parentSrId, debugRequests) {
const url = new URL("https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests");
url.searchParams.set("fields", "PrimaryPillarNew_c,SecondaryPillarNew_c,SvcHealth_c,SrNumber,AccountPartyId,CountryText_c,Language_c,OpportunityName_Id_c,OpportunityName_c,RegionText_c,ServiceNew_Id_c");
url.searchParams.set("onlyData", "true");
url.searchParams.set("orderBy", "LastUpdateDate:desc");
url.searchParams.set("q", `SrId=${parentSrId}`);
const result = await fetchJson(url.href, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestSRInfo", debugRequests);
if (!Array.isArray(result?.items) || result.items.length !== 1 || !result.items[0]
|| (result.items[0].SrId != null && String(result.items[0].SrId) !== String(parentSrId))) {
throw new Error("requestSRInfo did not return the selected parent Service Request.");
}
return result.items[0];
}
async function requestPostServiceRequest(message) {
const debugRequests = [];
try {
if (message.phase !== "prepare" && message.phase !== "confirm") throw new Error("Service Request confirmation is required.");
const payload = payloadFor(message.payload);
if (message.phase === "prepare") {
payload.ReportedByPartyId = await requestResourceUser(message.emailAddress, debugRequests);
const sharedHeaders = await previewTokenHeaders(catalog);
return { ok: true, payload, debugRequests, headers: {
requestSrTokenCatalog: sharedHeaders,
requestPostServiceRequest: { ...sharedHeaders, "vb-proxy-header-x-oracle-ectoken": "(generated after confirmation)" }
} };
}
payload.ReportedByPartyId = numericId(message.payload?.ReportedByPartyId, "ReportedByPartyId");
const headers = await requestCreationHeaders(message.catalogId, endpoint, debugRequests);
const result = await fetchJson(endpoint, {
method: "POST", credentials: "include", headers,
body: JSON.stringify(payload)
}, "requestPostServiceRequest", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
annotateSigningRequest(debugRequests);
}
}
async function requestCreationHeaders(id, target, debugRequests) {
if (id == null) {
const result = await fetchJson(`${catalog}?onlyData=true&fields=Id&limit=1`, {
method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" }
}, "requestSrCatalog", debugRequests);
id = result?.items?.[0]?.Id;
}
id = numericId(id, "Catalogue Id");
const tokenUrl = `${catalog}/${id}`;
let catalogHeaders = await tokenHeaders(tokenUrl, target);
const requestToken = () => fetchJson(tokenUrl, {
method: "POST", credentials: "include", headers: catalogHeaders, body: JSON.stringify({ name: "getJwtToken" })
}, "requestSrTokenCatalog", debugRequests);
let token;
try {
token = await requestToken();
} catch (error) {
if (error.httpStatus !== 403) throw error;
const refreshed = await previewTokenHeaders(tokenUrl);
if (!refreshed["x-appbuilder-client-id"] || refreshed["x-appbuilder-client-id"] === catalogHeaders["x-appbuilder-client-id"]) throw error;
catalogHeaders = refreshed;
token = await requestToken();
}
if (debugRequests.at(-1)?.httpStatus !== 200 || typeof token?.result !== "string" || !token.result.trim()) {
throw new Error("requestSrTokenCatalog did not return HTTP 200 with a token.");
}
return { ...catalogHeaders, "vb-proxy-header-x-oracle-ectoken": token.result.trim() };
}
function annotateSigningRequest(debugRequests) {
for (const entry of debugRequests) {
if (entry.name === "requestSrTokenCatalog") {
entry.metadata.appBuilderClientId = entry.requestHeaders?.["x-appbuilder-client-id"] ? "Observed in SeaaS requests" : "Not observed in SeaaS requests";
}
}
}
return { requestPostServiceRequest, requestPostInternalServiceRequest };
}

View File

@@ -0,0 +1,228 @@
function createFusionSrDetails({ fetchJson, tabsCreate, tabsRemove, supportsFirefoxCookieStoreTabs, cookiesGetAll, cookieChanges, wait }) {
const sessions = new Map();
function cookiePart(cookie) {
if (!cookie) return null;
const match = /^ORA_OCIS_CG_SESSION_idcs-([a-zA-Z0-9-]+)_comcipapic-oalprod\.integration\.ocp\.oraclecloud\.com_([12])$/.exec(cookie.name);
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const cookieDomain = cookie.domain?.replace(/^\./, "");
if (!match || (cookieDomain && cookieDomain !== domain && !domain.endsWith(`.${cookieDomain}`))) return null;
return { key: `${match[1]}|${cookie.path || "/"}|${cookie.storeId || ""}`, part: match[2],
valid: Boolean(cookie.value) && (cookie.expirationDate == null || cookie.expirationDate > Date.now() / 1000) };
}
function watchSession(session, sender) {
const pairs = new Map();
let available;
session.cookieReady = new Promise(resolve => { available = resolve; });
const listener = change => {
const cookie = change.cookie;
const part = cookiePart(cookie);
if (!part) return;
if (sender?.tab?.cookieStoreId && cookie.storeId !== sender.tab.cookieStoreId) return;
if (!pairs.has(part.key)) pairs.set(part.key, new Map());
const pair = pairs.get(part.key);
if (change.removed || !part.valid) pair.delete(part.part);
else pair.set(part.part, cookie);
if (pair.size === 2 && [...pair.values()].every(item => cookiePart(item)?.valid)) available(new Set([part.key]));
session.wake?.();
};
cookieChanges?.addListener(listener);
session.unwatch = () => cookieChanges?.removeListener(listener);
}
function waitForSessionCookies(session, sender) {
if (session.cookieWait) return session.cookieWait;
session.cookieWait = (async () => {
const deadline = Date.now() + 1000;
for (let attempt = 0; attempt < 5; attempt += 1) {
// Install the wake-up before reading so a renewal during getAll is not missed.
const changed = new Promise(resolve => { session.wake = resolve; });
const current = await sessionCookies(sender);
const renewed = [...current].some(pair => !session.baseline.has(pair));
if (renewed || Date.now() >= deadline) {
session.baseline = current;
return current;
}
await Promise.race([changed, wait(250)]);
}
return sessionCookies(sender);
})().finally(() => { session.cookieWait = null; session.wake = null; });
return session.cookieWait;
}
async function sessionCookies(sender) {
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const details = { domain };
if (sender?.tab?.cookieStoreId) details.storeId = sender.tab.cookieStoreId;
try {
const groups = new Map();
for (const cookie of await cookiesGetAll(details)) {
const part = cookiePart(cookie);
if (!part?.valid) continue;
if (!groups.has(part.key)) groups.set(part.key, {});
groups.get(part.key)[part.part] = [cookie.name, cookie.value, cookie.expirationDate ?? null];
}
// Fingerprints remain in memory only; the browser sends the actual Cookie header.
return new Set([...groups.values()].filter(pair => pair[1] && pair[2]).map(pair => JSON.stringify([pair[1], pair[2]])));
} catch { return new Set(); }
}
async function requestLookupWithSession(operation, message, sender, initialize = false) {
const existingCookies = initialize ? await sessionCookies(sender) : null;
if (existingCookies?.size) return { ok: true, debugRequests: [] };
const logs = [];
const query = async () => {
try {
const result = await operation(message);
logs.push(...(result.debugRequests || []));
return { ...result, debugRequests: logs };
} catch (error) {
logs.push(...(error.debugRequests || []));
error.debugRequests = logs;
throw error;
}
};
if (!initialize) {
try { return await query(); } catch (error) { if (error.httpStatus !== 401) throw error; }
}
const key = sender?.tab?.cookieStoreId || "default";
let session = sessions.get(key);
if (!session) {
const details = {
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/",
active: false
};
if (Number.isInteger(sender?.tab?.windowId)) details.windowId = sender.tab.windowId;
if (supportsFirefoxCookieStoreTabs() && sender?.tab?.cookieStoreId) details.cookieStoreId = sender.tab.cookieStoreId;
session = { users: 0, closed: null, baseline: null };
watchSession(session, sender);
session.tab = (async () => {
session.baseline = existingCookies || await sessionCookies(sender);
return tabsCreate(details);
})();
sessions.set(key, session);
}
session.users += 1;
let tab;
try {
tab = await session.tab;
const deadline = Date.now() + 60000;
for (let attempt = 0; attempt < 240 && Date.now() < deadline; attempt += 1) {
const pendingCookies = initialize && attempt === 0
? sessionCookies(sender) : waitForSessionCookies(session, sender);
const cookies = await (initialize ? Promise.race([session.cookieReady, pendingCookies]) : pendingCookies);
if (initialize) {
// Proactive preparation observes cookies only; API requests belong to the form.
if (!cookies.size) continue;
await closeSessionTab();
return { ok: true, debugRequests: [] };
}
try {
const result = await query();
// Any successful lookup confirms the shared session; stop the page immediately.
await closeSessionTab();
return result;
} catch (error) {
if (error.httpStatus !== 401) throw error;
}
}
throw new Error("Service Request session could not be established after 60 seconds. Sign in to Oracle and retry.");
} catch (error) {
error.debugRequests = logs;
throw error;
} finally {
session.users -= 1;
if (session.users === 0) {
sessions.delete(key);
session.unwatch();
await closeSessionTab();
}
}
function closeSessionTab() {
if (!session.closed && Number.isInteger(tab?.id)) {
session.closed = tabsRemove(tab.id).catch(() => {});
}
return session.closed;
}
}
async function requestInternalServiceRequest(message) {
const debugRequests = [];
try {
const id = String(message.srId ?? "");
if (!/^\d+$/.test(id)) throw new Error("A numeric SrId is required.");
const fields = "Title,SrNumber,SrId,OpportunityName_c,QueueManagers_c,StatusCd,resourceMembers;ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c,Title,OpportunityName_c,StatusCd,SrNumber,ServiceNew_c,AccountPartyName,LastUpdateDate,SvcHealth_c,AssigneePersonName,OpenDate,SrId";
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetCRMRestAPI/uri/https/eeho.fa.us2.oraclecloud.com/crmRestApi/resources/latest/serviceRequests"
+ `?fields=${encodeURIComponent(fields)}&onlyData=true&q=${encodeURIComponent(`SrId=${id}`)}`;
const query = async () => {
const result = await fetchJson(url, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, "requestInternalServiceRequest", debugRequests);
if (!Array.isArray(result?.items)) throw new Error("Invalid SR details response.");
return result;
};
const result = await query();
const items = result.items.flatMap(item => {
const children = item.ServiceRequest_SRToInternalSR_Tgt;
if (children == null) return [];
if (!Array.isArray(children) || children.some(child => !child || typeof child !== "object")) throw new Error("Invalid linked SR collection.");
return children;
});
return { ok: true, items, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
}
}
async function requestLookup(lookupType, requestName) {
const debugRequests = [];
try {
const url = `https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/lookups?lookupType=${lookupType}&type=STANDARD`;
const result = await fetchJson(url, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, requestName, debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => typeof item?.code !== "string" || typeof item?.name !== "string")) throw new Error(`Invalid ${requestName} response.`);
const items = result.items.filter(item => item.enabledFlag !== "N")
.sort((a, b) => (Number(a.displaySequence) || 0) - (Number(b.displaySequence) || 0))
.map(({ code, name }) => ({ code, name }));
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestSrLookupOpportunityBusiness(message) {
const debugRequests = [];
try {
if (typeof message.emailAddress !== "string" || !message.emailAddress.trim()) throw new Error("Current user email is unavailable.");
const url = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/oke_seaas/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/services"
+ `?emailAddress=${encodeURIComponent(message.emailAddress.trim())}&salesObject=OPPORTUNITY&serviceType=BUSINESS`;
const result = await fetchJson(url, { method: "GET", credentials: "include", headers: { Accept: "application/json", Authorization: "Session" } }, "requestSrLookupOpportunityBusiness", debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => !/^\d+$/.test(String(item?.Id ?? "")) || typeof item?.RecordName !== "string")) throw new Error("Invalid Services response.");
return { ok: true, items: result.items.map(({ Id, RecordName, Abstract_c }) => ({ Id: String(Id), RecordName, Abstract_c: Abstract_c == null ? "" : String(Abstract_c) })), debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
async function requestInternalSrOptions(message, services = false) {
const debugRequests = [];
const name = services ? "requestInternalSrServices" : "requestLookupTypePlatform";
try {
const base = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_SR_management/live;profile=PROD/services/auth/1.1/proxy/oalsetSeaaSOKECustomRestAPI/uri/https/gxpap.oracle.com/oalcrm/service/set/seaas/crm/";
let path = "lookups?type=STANDARD&lookupType=SENGG_TECH_PLATFORM";
if (services) {
const id = String(message.parentServiceId ?? "");
if (!/^\d+$/.test(id)) throw new Error("A numeric parent SrId is required.");
if (typeof message.emailAddress !== "string" || !message.emailAddress.trim()) throw new Error("Current user email is unavailable.");
path = `services?emailAddress=${encodeURIComponent(message.emailAddress.trim())}&parentServiceId=${encodeURIComponent(id)}&serviceType=INTERNAL`;
}
const result = await fetchJson(base + path, { method: "GET", credentials: "include",
headers: { Accept: "application/json", Authorization: "Session" }
}, name, debugRequests);
if (!Array.isArray(result?.items) || result.items.some(item => services
? !/^\d+$/.test(String(item?.Id ?? "")) || typeof item?.RecordName !== "string"
: typeof item?.code !== "string" || typeof item?.name !== "string")) throw new Error(`Invalid ${name} response.`);
const items = services
? result.items.map(item => ({ Id: String(item.Id), RecordName: item.RecordName, Description_c: String(item.Description_c ?? "") }))
: result.items.filter(item => item.enabledFlag !== "N").map(({ code, name }) => ({ code, name }));
return { ok: true, items, debugRequests };
} catch (error) { error.debugRequests = debugRequests; throw error; }
}
return { requestLookupWithSession, requestInternalServiceRequest, requestSrLookupOpportunityBusiness,
requestLookupTypePlatform: () => requestInternalSrOptions({}),
requestInternalSrServices: message => requestInternalSrOptions(message, true),
prepareSession: sender => requestLookupWithSession(null, {}, sender, true),
requestSrLookupPrimaryPillar: () => requestLookup("SENGG_PRIMARY_PILLAR", "requestSrLookupPrimaryPillar"),
requestSrLookupLanguage: () => requestLookup("SENGG_LANGUAGE", "requestSrLookupLanguage"),
requestSrLookupTechPlatform: () => requestLookup("SENGG_TECH_PLATFORM", "requestSrLookupTechPlatform") };
}

View File

@@ -1,10 +1,10 @@
function createHttpBackgroundJson({
previewDebugBody,
DEBUG_BODY_LIMIT,
observeResponseHeaders = () => {},
environment
}) {
const { fetch } = environment;
async function fetchJson(url, options, requestName, debugRequests = null) {
async function fetchJson(url, options, requestName, debugRequests = null, { allowEmpty = false } = {}) {
const startedAt = new Date();
const debugEntry = createBackgroundDebugEntry(requestName, url, options, startedAt);
let response;
@@ -17,6 +17,7 @@ function createHttpBackgroundJson({
throw error;
}
observeResponseHeaders(response.url || url, response.headers);
const responsePayload = await readResponsePayload(response);
const responseBody = createDebugBody(responsePayload.text);
completeBackgroundDebugEntry(debugEntry, response, responseBody, "");
@@ -25,7 +26,10 @@ function createHttpBackgroundJson({
appendBackgroundDebugEntry(debugRequests, debugEntry);
if (!response.ok) {
throw new Error(`${requestName} failed with status ${response.status}.`);
const error = new Error(`${requestName} failed with status ${response.status}.`);
error.httpStatus = response.status;
debugEntry.error = error.message;
throw error;
}
if (responsePayload.error) {
@@ -33,6 +37,7 @@ function createHttpBackgroundJson({
}
try {
if (allowEmpty && !responsePayload.text.trim()) return null;
return parseJsonResponse(responsePayload.text, requestName);
} catch (error) {
error.httpStatus = response.status;
@@ -121,7 +126,7 @@ function createHttpBackgroundJson({
}
function createDebugBody(text) {
return previewDebugBody(text, DEBUG_BODY_LIMIT);
return previewDebugBody(text);
}
function normalizeDebugHeaders(headers) {

View File

@@ -0,0 +1,83 @@
function createSpaActionOptions({ fetchJson }) {
const baseUrl = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
const lookupKeys = ["WWB_ACTION_ROLE", "WWB_ACTION_NAME", "WWB_ACTION_TEAM", "WWB_ACTION_STATUS", "WWB_ACTION_COMPLEXITY"];
async function request(message, name, path, normalize) {
const debugRequests = [];
try {
const token = message.authorization?.value;
if (typeof token !== "string" || !token.trim()) throw new Error("WWB-TS uiaasHeader.value is unavailable.");
const result = await fetchJson(`${baseUrl}/${path()}`, {
method: "GET", credentials: "include",
headers: { Accept: "application/json", "Wwb-Provider-Authorization": token.trim() }
}, name, debugRequests);
if (debugRequests.at(-1)?.httpStatus !== 200) throw new Error(`${name} requires HTTP 200.`);
return { ok: true, ...normalize(result), debugRequests };
} catch (error) {
const entry = debugRequests.at(-1);
if (entry) { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests;
throw error;
} finally {
for (const entry of debugRequests) {
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
}
}
}
function requestActionTeamMembers(message) {
return request(message, "requestActionTeamMembers", () => {
const id = String(message.workloadId ?? "").trim();
if (!/^\d+$/.test(id)) throw new Error("A numeric RevnId is required.");
return `${encodeURIComponent(id)}/teamMembers?includeLeads=true`;
}, result => {
if (!Array.isArray(result) || result.some(item => typeof item?.email !== "string" || !item.email.trim())) {
throw new Error("requestActionTeamMembers returned an invalid collection.");
}
return { members: result.map(item => ({ email: item.email })) };
});
}
function requestActionFindLookup(message) {
return request(message, "requestActionFindLookup", () => "fndLookup", result => {
const lookups = {};
for (const key of lookupKeys) {
if (!Array.isArray(result?.[key]) || result[key].some(item => typeof item?.code !== "string" || !item.code.trim()
|| typeof item?.name !== "string" || !item.name.trim())) {
throw new Error(`requestActionFindLookup returned an invalid ${key} collection.`);
}
lookups[key] = result[key].map(({ code, name }) => ({ code, name }));
}
return { lookups };
});
}
async function requestMgmtAction(message) {
const debugRequests = [];
try {
const token = message.authorization?.value;
const payload = message.payload;
const action = payload?.action;
if (!token || typeof token !== "string") throw new Error("WWB-TS uiaasHeader.value is unavailable.");
if (payload?.parentType !== "WORKLOAD" || !/^\d+$/.test(String(payload.parentId))) throw new Error("A numeric RevnId is required.");
for (const key of ["ownerEmail", "role", "name", "team", "status", "complexity", "createdBy", "updatedBy"]) {
if (typeof action?.[key] !== "string" || !action[key].trim()) throw new Error(`Missing ${key}.`);
}
for (const key of ["startDate", "endDate"]) {
if (typeof action[key] !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(action[key]) || !Number.isFinite(Date.parse(action[key]))) throw new Error(`Invalid ${key}.`);
}
if (action.endDate < action.startDate) throw new Error("End date must not precede start date.");
if (!Array.isArray(action.notes) || action.notes.some(note => typeof note !== "string")) throw new Error("Invalid notes.");
const result = await fetchJson("https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions", {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", "Wwb-Provider-Authorization": token.trim() },
body: JSON.stringify(payload)
}, "requestMgmtAction", debugRequests, { allowEmpty: true });
return { ok: true, result, debugRequests };
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
for (const entry of debugRequests) {
if (entry.requestHeaders?.["Wwb-Provider-Authorization"]) entry.requestHeaders["Wwb-Provider-Authorization"] = "(present)";
}
}
}
return { requestActionTeamMembers, requestActionFindLookup, requestMgmtAction };
}

View File

@@ -15,12 +15,6 @@ function createSpaCurrentUser({ fetchJson }) {
for (const entry of debugRequests) { entry.status = "failed"; entry.error = error.message; }
error.debugRequests = debugRequests;
throw error;
} finally {
// The identity token is not needed by content scripts or debug, including on failures.
for (const entry of debugRequests) {
entry.responseBody = JSON.stringify(user ? { user, token: "(redacted)" } : { error: "Identity response omitted." }, null, 2);
entry.responseBodyTruncated = false;
}
}
}
return { requestCurrentUser };

View File

@@ -1,40 +1,3 @@
function createDebugBodyPreview(text, limit) {
if (text.length <= limit) return { value: text || "(empty response body)", truncated: false };
try {
const normalized = text.replace(/^\uFEFF/, "").trimStart()
.replace(/^\)\]\}',?\s*/, "").replace(/^while\s*\(1\);\s*/, "");
let value = JSON.parse(normalized);
for (let depth = 0; depth < 2 && typeof value === "string" && /^\s*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; }
}
// Bound retained data without cutting JSON syntax or losing sensitive field names.
let remaining = Math.max(0, limit - 256);
const visit = (node, depth = 0) => {
if (remaining <= 64 || depth >= 30) return "[omitted from debug preview]";
if (node === null || typeof node !== "object") {
const serialized = JSON.stringify(node);
if (serialized.length > remaining) {
remaining = 0;
return "[omitted from debug preview]";
}
remaining -= serialized.length + 1;
return node;
}
const result = Array.isArray(node) ? [] : Object.create(null);
remaining -= 2;
for (const key of Object.keys(node)) {
const cost = Array.isArray(node) ? 1 : JSON.stringify(key).length + 2;
if (remaining < cost + 64) break;
remaining -= cost;
result[key] = visit(node[key], depth + 1);
}
return result;
};
return {
value: JSON.stringify({ debugNotice: "Partial response preview: remaining fields/items omitted due to the debug size limit.", preview: visit(value) }),
truncated: true
};
} catch {
return { value: `${text.slice(0, limit)}\n... truncated ${text.length - limit} characters`, truncated: true };
}
function createDebugBodyPreview(text) {
return { value: text || "(empty response body)", truncated: false };
}

View File

@@ -11,7 +11,21 @@ function createMessageTypes() {
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestActionTeamMembers: "opportunitiesExtension.requestActionTeamMembers",
requestActionFindLookup: "opportunitiesExtension.requestActionFindLookup",
requestMgmtAction: "opportunitiesExtension.requestMgmtAction",
requestInternalServiceRequest: "opportunitiesExtension.requestInternalServiceRequest",
requestSrLookupPrimaryPillar: "opportunitiesExtension.requestSrLookupPrimaryPillar",
requestPostServiceRequest: "opportunitiesExtension.requestPostServiceRequest",
requestPostInternalServiceRequest: "opportunitiesExtension.requestPostInternalServiceRequest",
prepareSrSession: "opportunitiesExtension.prepareSrSession",
requestSrLookupLanguage: "opportunitiesExtension.requestSrLookupLanguage",
requestSrLookupTechPlatform: "opportunitiesExtension.requestSrLookupTechPlatform",
requestLookupTypePlatform: "opportunitiesExtension.requestLookupTypePlatform",
requestInternalSrServices: "opportunitiesExtension.requestInternalSrServices",
requestSrLookupOpportunityBusiness: "opportunitiesExtension.requestSrLookupOpportunityBusiness",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"

22
src/vendor/fullcalendar/LICENSE.md vendored Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2021 Adam Shaw
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

7
src/vendor/fullcalendar/NOTICE.md vendored Normal file
View File

@@ -0,0 +1,7 @@
FullCalendar Standard Bundle 6.1.19 (MIT)
Vendored from https://cdn.jsdelivr.net/npm/fullcalendar@6.1.19/index.global.min.js
License: LICENSE.md in this directory.
The build embeds this pinned bundle in a lazy local factory. No CDN or runtime
code download is used by the extension. Only dayGridMonth and dayGridWeek are enabled.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,110 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const lookups = () => Object.fromEntries(["ROLE", "NAME", "TEAM", "STATUS", "COMPLEXITY"].map(key => [
`WWB_ACTION_${key}`, [{ code: `${key}_CODE`, name: `${key} label` }]
]));
const teamType = "opportunitiesExtension.requestActionTeamMembers";
const lookupType = "opportunitiesExtension.requestActionFindLookup";
test("Save sends the WWB POST, accepts empty success and never retries errors", async () => {
const payload = { parentType: "WORKLOAD", parentId: "123", action: {
ownerEmail: "owner@example.invalid", role: "CA", name: "REVIEW", team: "TEAM", tags: "",
startDate: "2026-09-14T00:00:00.000Z", endDate: "2026-09-15T00:00:00.000Z",
status: "COMPLETE", complexity: "LOW", notes: [], objective: "",
createdBy: "creator@example.invalid", updatedBy: "creator@example.invalid"
} };
for (const status of [200, 201, 204, 403, 500]) {
const calls = [];
const h = loadBackground({ family: "chrome", fetch: async (url, options) => {
calls.push({ url, options });
return status === 204 ? new Response(null, { status }) : response({ id: 123 }, status);
} });
const result = await h.dispatch({ type: "opportunitiesExtension.requestMgmtAction", payload, authorization: { value: "secret-wwb" } });
assert.equal(result.ok, status < 300, `${status}: ${result.error}`);
assert.equal(calls.length, 1);
assert.ok(calls[0].url.endsWith("/wwb/mgmt/actions"));
assert.equal(calls[0].options.method, "POST");
assert.equal(calls[0].options.headers["Wwb-Provider-Authorization"], "secret-wwb");
assert.deepEqual(JSON.parse(calls[0].options.body), payload);
assert.ok(!JSON.stringify(result).includes("secret-wwb"));
}
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: Action options use dynamic RevnId, WWB-TS and masked GET diagnostics`, async () => {
const calls = [];
const h = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
if (url.endsWith("consumerTokenService")) return response([
{ tokenService: "ACCP-TS", uiaasHeader: { value: "summary-token" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "wrong-token" } },
{ tokenService: "WWB-TS", uiaasHeader: { value: "synthetic-wwb" } }
]);
return response(url.includes("teamMembers") ? [{ email: "owner@example.invalid" }] : lookups());
} });
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
const authorization = auth.authorizations.workloadActions;
const team = await h.dispatch({ type: teamType, workloadId: "987654", authorization, url: "https://example.invalid" });
const lookup = await h.dispatch({ type: lookupType, authorization });
assert.equal(team.ok, true);
assert.deepEqual(team.members, [{ email: "owner@example.invalid" }]);
assert.deepEqual(lookup.lookups, lookups());
const base = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/workloads";
assert.equal(calls[1].url, `${base}/987654/teamMembers?includeLeads=true`);
assert.equal(calls[2].url, `${base}/fndLookup`);
for (const call of calls.slice(1)) {
assert.equal(call.options.method, "GET");
assert.equal(call.options.credentials, "include");
assert.deepEqual({ ...call.options.headers }, { Accept: "application/json", "Wwb-Provider-Authorization": "synthetic-wwb" });
}
for (const [result, name] of [[team, "requestActionTeamMembers"], [lookup, "requestActionFindLookup"]]) {
const log = result.debugRequests[0];
assert.equal(log.name, name);
assert.equal(log.status, "success");
assert.equal(log.httpStatus, 200);
assert.ok(log.durationMs >= 0);
assert.equal(log.requestHeaders["Wwb-Provider-Authorization"], "(present)");
assert.doesNotMatch(JSON.stringify(result), /synthetic-wwb|wrong-token/);
}
});
}
test("invalid workload IDs and missing authorization never fetch or accept an arbitrary target", async () => {
const h = loadBackground({ fetch: () => assert.fail("No network expected") });
for (const workloadId of [undefined, "", "abc", "12/../fndLookup", "12?other=1"]) {
assert.equal((await h.dispatch({ type: teamType, workloadId, authorization: { value: "synthetic" } })).ok, false);
}
for (const type of [teamType, lookupType]) {
assert.equal((await h.dispatch({ type, workloadId: "123" })).ok, false);
}
});
test("Action options require exactly HTTP 200 and valid collections and preserve failed debug entries", async () => {
for (const type of [teamType, lookupType]) {
const valid = type === teamType ? [{ email: "owner@example.invalid" }] : lookups();
for (const fetch of [
async () => response(valid, 201), async () => response({}, 401), async () => response({}, 403),
async () => { throw new Error("Synthetic network failure"); },
async () => new Response("<html>Unexpected session page</html>"),
async () => response(type === teamType ? [{ name: "Missing email" }] : { ...lookups(), WWB_ACTION_NAME: null })
]) {
const h = loadBackground({ fetch });
const result = await h.dispatch({ type, workloadId: "123", authorization: { value: "synthetic-secret" } });
assert.equal(result.ok, false);
assert.equal(result.debugRequests.length, 1);
assert.equal(result.debugRequests[0].status, "failed");
assert.ok(result.error);
assert.doesNotMatch(JSON.stringify(result), /synthetic-secret/);
}
}
});
test("empty option lists are valid responses, not invented defaults", async () => {
const empty = Object.fromEntries(Object.keys(lookups()).map(key => [key, []]));
const h = loadBackground({ fetch: async url => response(url.includes("teamMembers") ? [] : empty) });
const members = await h.dispatch({ type: teamType, workloadId: "123", authorization: { value: "synthetic" } });
const lookup = await h.dispatch({ type: lookupType, authorization: { value: "synthetic" } });
assert.deepEqual(members.members, []);
assert.deepEqual(lookup.lookups, empty);
});

View File

@@ -0,0 +1,179 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
function setup(family, saved = {}, principal = "user@example.invalid") {
const local = {
get: async key => key === null ? { ...saved } : ({ [key]: saved[key] }),
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values))),
remove: async keys => keys.forEach(key => delete saved[key])
};
const api = family === "firefox" ? { browser: { storage: { local } } } : {
chrome: { runtime: {}, storage: { local: {
get: (key, done) => local.get(key).then(done), set: (values, done) => local.set(values).then(done),
remove: (keys, done) => local.remove(keys).then(done)
} } }
};
const document = { createElement: () => ({ append() {}, setAttribute() {}, addEventListener() {} }) };
document.createElementNS = (_, tag) => document.createElement(tag);
const app = loadApplication("content", { ...api, document });
app.authSessionState.principal = principal;
app.currentUser.allowsCrmActivities = () => true;
app.preferencesState.opptyProductsCacheHours = 24;
app.opportunitiesQueryState.periodRequestVersion = 1;
app.activityProgress.update = () => {};
app.productsContentJobs.queueProductsTableRender = () => {};
app.debugContentLog.appendBackgroundRequestLog = () => {};
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-token" } });
const calls = [];
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => {
calls.push("sr");
return [{ SrNumber: "SR1", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "OP1" } }];
};
app.platformContentMessages.sendRuntimeMessage = async () => {
calls.push("actions");
return { ok: true, actions: [{ owner: principal, name: "Test action" }], hasNext: false };
};
async function load(cachedAt, cacheOnly) {
app.opportunityCrmServiceRequests.reset();
await app.opportunityCrmServiceRequests.load("synthetic-token", 1, { cachedAt, cacheOnly });
const products = { items: [{ RevnId: "123" }], cachedAt, fromCache: cacheOnly };
app.productsContentState.opptyProductsByNumber.set("OP1", products);
app.opportunityWorkloadActions.loadProducts(products, true, { cachedAt, cacheOnly });
await flush();
}
return { app, calls, load, saved, local };
}
test("all cached opportunities load missing Actions despite filters, then reuse their completed cache", async () => {
const h = setup("firefox");
const stamp = Date.now() - 1000;
const app = h.app;
app.opportunitiesFilterState.tableSearch = "hide every opportunity";
app.productsContentJobs.renderProductsProgress = () => {};
const products = Array.from({ length: 7 }, (_, index) => [String(index + 1), {
items: [{ RevnId: String(index + 1) }], cachedAt: stamp, fromCache: true
}]);
app.productsContentState.opptyProductsByNumber = new Map(products);
await app.activityCache.write("actions", "1", stamp, []);
await app.activityCache.write("actions", "2", stamp - 1, []);
const queried = [];
app.platformContentMessages.sendRuntimeMessage = async message => {
queried.push(message.revnId);
return { ok: true, actions: message.revnId === "3" ? [{ owner: app.authSessionState.principal }] : [], hasNext: false };
};
async function cycle(id) {
app.productsContentState.currentProductsRequestId = id;
app.productActivities.begin(id, "synthetic-token");
await app.productActivities.complete(id);
}
await cycle("first");
assert.deepEqual(queried.sort(), ["2", "3", "4", "5", "6", "7"]);
assert.deepEqual(h.calls, ["sr"], "a missing SR cache must not block the Actions queue");
for (const [number] of products) {
assert.equal(app.opportunityWorkloadActions.getSummary(number).state, number === "3" ? "matched" : "missing");
}
app.opportunityWorkloadActions.reset();
app.opportunityCrmServiceRequests.reset();
await cycle("second");
assert.equal(queried.length, 6, "valid matching action cache avoids all repeat requests");
assert.deepEqual(h.calls, ["sr"]);
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: Current Quarter -> Next Quarter -> Current Quarter restores SR hours and filters without requests`, async () => {
const h = setup(family);
const currentStamp = Date.now() - 2000;
const nextStamp = currentStamp + 1000;
let hours = 2;
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => {
h.calls.push("sr");
return [{ SrNumber: "SR1", ReportedHours_c: hours, OpportunityName_Id_c: { OptyNumber: "OP1" } }];
};
await h.load(currentStamp, false);
hours = 8;
await h.load(nextStamp, false);
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 8);
const count = h.calls.length;
await h.load(currentStamp, true);
assert.equal(h.calls.length, count);
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "2h");
h.app.opportunitiesTable.renderOpportunitiesTable = () => {};
h.app.activityFilters.toggle("sr", "under4");
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), true);
await h.load(nextStamp, true);
assert.equal(h.calls.length, count);
assert.equal(h.app.activityFilters.matches({ OptyNumber: "OP1" }), false);
const reopened = setup(family, h.saved);
await reopened.load(currentStamp, true);
assert.deepEqual(reopened.calls, []);
assert.equal(reopened.app.opportunityCrmServiceRequests.getSummary("OP1").reportedHours, 2);
});
test(`${family}: activities persist across instances with product timestamps and no credential storage`, async () => {
const h = setup(family);
const stamp = Date.now() - 1000;
await h.load(stamp, false);
assert.deepEqual(h.calls, ["sr", "actions"]);
assert.equal(Object.keys(h.saved).length, 2);
Object.values(h.saved).forEach(entry => assert.equal(entry.fetchedAt, stamp));
assert.doesNotMatch(JSON.stringify(h.saved), /synthetic-token|debugRequests|authorization/i);
const restored = setup(family, h.saved);
await restored.load(stamp, true);
assert.deepEqual(restored.calls, []);
assert.equal(restored.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "4h");
assert.equal(restored.app.opportunityWorkloadActions.createBadge("OP1").textContent, "1");
await restored.load(stamp, false);
assert.deepEqual(restored.calls, [], "valid matching cache also deduplicates repeated fresh-product notifications");
const other = setup(family, h.saved, "other@example.invalid");
await other.load(stamp, true);
assert.deepEqual(other.calls, []);
assert.equal(other.app.opportunityCrmServiceRequests.createBadge("OP1"), null);
assert.equal(other.app.opportunityWorkloadActions.createBadge("OP1").textContent, "?");
await restored.load(stamp + 1, false);
assert.deepEqual(restored.calls, ["sr", "actions"]);
});
}
test("TTL follows product preferences, rejects stale/mismatched cache and stores empty success", async () => {
const h = setup("firefox");
const stamp = Date.now() - 2 * 3600000;
await h.app.activityCache.write("sr", "all", stamp, []);
assert.ok(await h.app.activityCache.read("sr", "all", stamp));
h.app.preferencesState.opptyProductsCacheHours = 1;
assert.equal(await h.app.activityCache.read("sr", "all", stamp), null);
h.app.preferencesState.opptyProductsCacheHours = 24;
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
assert.equal(await h.app.activityCache.read("sr", "all", Date.now() + 100000), null);
await h.load(stamp + 1, true);
assert.deepEqual(h.calls, []);
});
test("versioned SR cache reads matching legacy entries and prunes expired snapshots only", async () => {
const h = setup("firefox");
const legacyKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all"])}`;
const stamp = Date.now() - 1000;
h.saved[legacyKey] = { fetchedAt: stamp, items: [], hasNext: false };
assert.ok(await h.app.activityCache.read("sr", "all", stamp));
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
const expiredStamp = Date.now() - 25 * 3600000;
const expiredKey = `opportunitiesExtension.activities.v1:${JSON.stringify(["user@example.invalid", "sr", "all", expiredStamp])}`;
h.saved[expiredKey] = { fetchedAt: expiredStamp, items: [], hasNext: false };
h.saved.preferences = { keep: true };
await h.app.activityCache.write("sr", "all", stamp + 1, []);
assert.equal(h.saved[expiredKey], undefined);
assert.ok(h.saved[legacyKey]);
assert.equal(h.saved.preferences.keep, true);
await h.app.activityCache.invalidate();
assert.equal(await h.app.activityCache.read("sr", "all", stamp + 1), null);
assert.equal(h.saved.preferences.keep, true);
});
test("storage failure does not lose live activity results and failures are never cached", async () => {
const h = setup("firefox");
h.local.get = async () => { throw new Error("Synthetic storage read failure"); };
h.local.set = async () => { throw new Error("Synthetic storage write failure"); };
await h.load(Date.now() - 1000, false);
assert.equal(h.app.opportunityCrmServiceRequests.createBadge("OP1").textContent, "4h");
const failed = setup("firefox");
failed.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => { throw new Error("Synthetic failure"); };
failed.app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: false });
await failed.load(Date.now() - 1000, false);
assert.deepEqual(failed.saved, {});
});

View File

@@ -0,0 +1,254 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { loadApplication } = require("./support/application.cjs");
class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; }
append(...nodes) { this.children.push(...nodes); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, handler) { this.listeners[key] = handler; }
}
function setup() {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
app.currentUser.allowsCrmActivities = () => true;
let renders = 0;
app.opportunitiesTable.renderOpportunitiesTable = () => { renders += 1; };
const sr = new Map(), actions = new Map();
app.opportunityCrmServiceRequests.getSummary = number => sr.get(number);
app.opportunityWorkloadActions.getSummary = number => actions.get(number);
const form = new Element("form"), status = new Element("status"), stage = new Element("stage");
const controls = app.activityFilters.createControls(form, status, stage);
const buttons = kind => controls.children[kind === "sr" ? 0 : 1].children[1].children;
const button = (kind, value) => buttons(kind).find(node => node.attributes["data-value"] === value);
return { app, sr, actions, form, controls, status, stage, button, renders: () => renders,
matches: number => app.activityFilters.matches({ OptyNumber: number }),
click: (kind, value) => button(kind, value).listeners.click() };
}
test("activity badge filters precede Status, are accessible and combine without network", () => {
const h = setup();
assert.equal(h.controls.children[2], h.status);
assert.equal(h.controls.children[0].children[0].textContent, "Service Request");
assert.equal(h.controls.children[0].children[1].attributes["aria-label"], "Filter by Service Request");
assert.equal(h.controls.children[3], h.stage);
assert.equal(h.form.attributes["data-activity-filters"], "true");
assert.equal(h.button("sr", "matched").textContent, "");
assert.equal(h.button("actions", "missing").textContent, "");
assert.equal(h.button("actions", "matched").type, "button");
assert.equal(h.controls.children[1].children[0].textContent, "WL Action");
for (const value of ["matched", "missing"]) {
assert.equal(h.button("actions", value).textContent, "");
assert.ok(h.button("actions", value).title.startsWith("Whether the Action is registered in Workload Workbench.\n"));
assert.ok(h.button("sr", value).title.startsWith("Whether a Service Request has been created for the opportunities.\n"));
for (const kind of ["sr", "actions"]) {
const button = h.button(kind, value);
assert.ok(button.title.endsWith(button.attributes["aria-label"]));
assert.equal(button.children.length, 1);
assert.equal(button.children[0].tag, "svg");
assert.equal(button.children[0].attributes["data-badge-icon"], kind);
assert.equal(button.children[0].attributes.stroke, "currentColor");
assert.equal(button.children[0].attributes["aria-hidden"], "true");
}
}
assert.equal(h.button("sr", "under4").textContent, "< 4h");
assert.equal(h.button("sr", "under4").children[0].attributes["data-badge-icon"], "sr");
assert.notEqual(h.button("actions", "matched").attributes["aria-label"], h.button("actions", "missing").attributes["aria-label"]);
assert.notEqual(h.button("sr", "matched").attributes["aria-label"], h.button("sr", "missing").attributes["aria-label"]);
h.sr.set("A", { state: "matched" });
h.actions.set("A", { state: "missing" });
h.sr.set("B", { state: "missing" });
h.actions.set("B", { state: "matched" });
assert.equal(h.matches("unknown"), true);
h.click("sr", "missing");
assert.equal(h.matches("A"), true);
assert.equal(h.matches("B"), false);
assert.equal(h.matches("unknown"), false);
h.click("actions", "missing");
assert.equal(h.matches("A"), false, "SR and Action use AND");
h.actions.set("A", { state: "matched" });
assert.equal(h.matches("A"), true);
assert.equal(h.renders(), 2);
});
test("SR below four hours is exclusive and regular SR options can replace it", () => {
const h = setup();
h.sr.set("A", { state: "matched", hasUnderFourHours: true });
h.sr.set("B", { state: "matched", hasUnderFourHours: false });
h.click("sr", "under4");
assert.equal(h.button("sr", "matched").attributes["aria-pressed"], "false");
assert.equal(h.button("sr", "missing").attributes["aria-pressed"], "false");
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "true");
assert.equal(h.matches("A"), true);
assert.equal(h.matches("B"), false);
h.click("sr", "matched");
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
assert.equal(h.matches("B"), true);
h.click("sr", "matched");
assert.equal(h.matches("A"), false, "empty selection matches no records, like Status");
h.click("sr", "under4");
h.click("sr", "missing");
assert.equal(h.button("sr", "under4").attributes["aria-pressed"], "false");
assert.equal(h.matches("A"), false);
});
test("filter vectors retain their fixed slot without duplication when selection changes", () => {
const h = setup();
const options = [["sr", "matched"], ["sr", "under4"], ["sr", "missing"], ["actions", "matched"], ["actions", "missing"]];
const icons = options.map(([kind, value]) => h.button(kind, value).children[0]);
for (const [kind, value] of options) h.click(kind, value);
h.app.activityFilters.render();
options.forEach(([kind, value], index) => {
const button = h.button(kind, value);
assert.deepEqual(button.children, [icons[index]]);
assert.equal(icons[index].attributes.viewBox, "0 0 24 24");
assert.equal(icons[index].attributes.width, "16");
assert.equal(icons[index].attributes.height, "16");
assert.equal(icons[index].attributes["stroke-width"], "2.25");
assert.equal(icons[index].attributes.focusable, "false");
});
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
const shared = css.match(/\.opportunities-extension-cell-actions \.opportunities-extension-badge-icon,\s*\.opportunities-extension-activity-filter-button \.opportunities-extension-badge-icon \{([^}]+)\}/);
assert.ok(shared, "filters and opportunity badges share the same vector positioning");
for (const rule of ["order: -1;", "flex: 0 0 16px;", "width: 16px;", "height: 16px;", "stroke: currentColor;"]) {
assert.ok(shared[1].includes(rule), rule);
}
assert.match(css, /activity-filter-button \{\s*min-width: 32px;\s*gap: 4px;/);
});
test("missing filters exclude unknown, loading and error states; SALES_REP bypasses and hides them", () => {
const h = setup();
h.click("sr", "matched");
h.click("actions", "matched");
for (const state of ["idle", "loading", "error", "matched"]) {
h.sr.set("A", { state });
h.actions.set("A", { state: "missing" });
assert.equal(h.matches("A"), false);
h.sr.set("A", { state: "missing" });
h.actions.set("A", { state });
assert.equal(h.matches("A"), false);
}
h.actions.set("A", { state: "missing" });
assert.equal(h.matches("A"), true);
h.app.currentUser.allowsCrmActivities = () => false;
h.app.activityFilters.render();
assert.equal(h.matches("unknown"), true);
assert.equal(h.controls.children[0].hidden, true);
assert.equal(h.controls.children[1].hidden, true);
assert.equal(h.form.attributes["data-activity-filters"], "false");
});
for (const [roles, allowed] of [[ ["SALES_REP"], false ], [ ["SIMPLE_USER", "SALES_REP"], false ], [ ["SOLUTION_ENG_NOTERR"], true ]]) {
test(`requestCurrentUser roles ${JSON.stringify(roles)} control actual filter visibility`, async () => {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
const calls = [];
let finish;
app.platformContentMessages.sendRuntimeMessage = message => {
calls.push(message.type);
return new Promise(resolve => { finish = resolve; });
};
app.opportunitiesTable.renderOpportunitiesTable = () => {};
const form = new Element("form");
const controls = app.activityFilters.createControls(form, new Element("status"), new Element("stage"));
assert.equal(controls.children[0].hidden, true, "hidden before identity resolves");
assert.equal(controls.children[1].hidden, true);
const identity = app.currentUser.ensure();
finish({ ok: true, user: { roles } });
await identity;
app.activityFilters.render();
assert.equal(controls.children[0].hidden, !allowed);
assert.equal(controls.children[1].hidden, !allowed);
if (!allowed) {
app.opportunityCrmServiceRequests.getSummary = () => assert.fail("must not inspect SR data");
app.opportunityWorkloadActions.getSummary = () => assert.fail("must not inspect Actions data");
app.activityFilters.toggle("sr", "under4");
assert.equal(app.activityFilters.matches({ OptyNumber: "A" }), true);
}
assert.deepEqual(calls, ["opportunitiesExtension.requestCurrentUser"]);
});
}
test("SR filter summary uses actual records: zero, decimals, exact four, missing and multiple hours", async () => {
const app = loadApplication("content");
app.currentUser.allowsCrmActivities = () => true;
app.opportunitiesQueryState.periodRequestVersion = 1;
app.activityProgress.update = () => {};
const examples = [[0, true], [3.99, true], [4, false], [8, false], [null, false], ["", false], [" ", false], ["3.5", true], ["bad", false]];
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
...examples.map(([value], index) => ({ OpportunityName_Id_c: { OptyNumber: String(index) }, ReportedHours_c: value })),
...[2, 8].map(value => ({ OpportunityName_Id_c: { OptyNumber: "multiple" }, ReportedHours_c: value }))
];
await app.opportunityCrmServiceRequests.load("synthetic", 1);
for (const [index, [, expected]] of examples.entries()) {
assert.equal(app.opportunityCrmServiceRequests.getSummary(String(index)).hasUnderFourHours, expected);
}
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").reportedHours, 10);
assert.equal(app.opportunityCrmServiceRequests.getSummary("multiple").hasUnderFourHours, true);
assert.equal(app.opportunityCrmServiceRequests.getSummary("absent").state, "missing");
});
test("Action filter summary shares badge ownership and uncertainty rules", async () => {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag) } });
app.currentUser.allowsCrmActivities = () => true;
app.productsContentJobs.queueProductsTableRender = () => {};
app.authSessionState.principal = "me@example.invalid";
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true, actions: [{ owner: "other@example.invalid" }], hasNext: false });
const products = { items: [{ RevnId: "123" }] };
app.productsContentState.opptyProductsByNumber.set("A", products);
await app.opportunityWorkloadActions.loadProducts(products);
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "missing");
app.authSessionState.principal = "other@example.invalid";
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
assert.equal(app.opportunityWorkloadActions.createBadge("A").attributes["data-state"], "matched");
app.authSessionState.principal = "";
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "error");
});
test("activity filters compose with existing search, status, owner and customer filters", () => {
const h = setup();
const items = ["A", "B"].map(OptyNumber => ({ OptyNumber, Name: `Test ${OptyNumber}`, StatusCode: "OPEN", ForecastGroup_c: "FORECAST",
CustomerAccount: { PartyUniqueName: "Synthetic customer", PartyId: "123" }, Owner: { PersonFirstName: "Test", PersonLastName: "Owner" } }));
h.app.opportunitiesTableState.opportunitiesTableState.items = items;
h.app.opportunitiesFilters.resetCustomerFilter(items);
h.app.opportunitiesFilters.resetOwnerFilter(items);
h.sr.set("A", { state: "matched" });
h.sr.set("B", { state: "missing" });
h.click("sr", "missing");
const results = () => Array.from(h.app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber);
assert.deepEqual(results(), ["A"]);
h.app.opportunitiesFilterState.tableSearch = "Test B";
assert.deepEqual(results(), []);
h.app.opportunitiesFilterState.tableSearch = "";
h.app.opportunitiesFilterState.selectedStatuses.clear();
assert.deepEqual(results(), []);
});
test("activity filters have theme contrast, visible keyboard focus and responsive wrapping", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
assert.match(css, /activity-filter-button:focus-visible/);
assert.match(css, /data-theme="dark"\] \.opportunities-extension-activity-filter-button/);
assert.match(css, /activity-filter\[hidden\] \{ display: none;/);
assert.match(css, /badge-filters \{\s*display: flex;[\s\S]*?flex-wrap: wrap;/);
});
test("SR and Act filters share sizing and typography with Status and Stage", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
const shared = css.match(/\.opportunities-extension-form \.opportunities-extension-stage-filter-button,\s*\.opportunities-extension-form \.opportunities-extension-status-filter-button,\s*\.opportunities-extension-form \.opportunities-extension-activity-filter-button\s*\{([^}]+)\}/);
assert.ok(shared, "all filter badges use the same presentation rule");
for (const declaration of ["display: inline-flex;", "align-items: center;", "box-sizing: border-box;", "height: 28px;",
"padding: 4px 7px;", "border-radius: 18px;", "font-size: 11px;", "font-weight: 700;", "line-height: 18px;"]) {
assert.ok(shared[1].includes(declaration), declaration);
}
});
test("desktop keeps fields and badge groups on one line, wrapping only below the compact breakpoint", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
assert.match(css, /\.opportunities-extension-badge-filters \{ display: contents; \}/);
assert.match(css, /\[data-activity-filters="true"\] \{\s*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) repeat\(4, max-content\);/);
const breakpoint = css.indexOf("@media (max-width: 1350px)");
const fullRow = css.indexOf("grid-column: 1 / -1;", css.indexOf(".opportunities-extension-badge-filters { display: contents; }"));
assert.ok(breakpoint >= 0 && fullRow > breakpoint, "full-row badge toolbar is limited to smaller viewports");
assert.match(css, /activity-filter-controls \{ display: flex; align-items: center; flex-wrap: nowrap;/);
});

View File

@@ -0,0 +1,71 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
test("activity indicators share Products styles, fade after 5s, retain failures and cancel stale timers", () => {
const nodes = [];
const timers = new Map();
let id = 0;
const app = loadApplication("content", {
document: { querySelector: () => ({ append: node => nodes.push(node) }), createElement: () => ({ attributes: {}, setAttribute(key, value) { this.attributes[key] = value; } }) },
setTimeout: (fn, delay) => { timers.set(++id, { fn, delay }); return id; }, clearTimeout: key => timers.delete(key)
});
const update = app.activityProgress.update;
update("sr", "running", 15, 58);
assert.equal(nodes[0].textContent, "Service Requests 15/58");
assert.equal(nodes[0].attributes["data-state"], "running");
assert.equal(nodes[0].className, "opportunities-extension-products-progress");
update("sr", "complete", 58, 58);
assert.equal([...timers.values()][0].delay, 5000);
[...timers.values()][0].fn();
assert.equal(nodes[0].attributes["data-fading"], "true");
const hide = [...timers.values()].find(timer => timer.delay === 300);
hide.fn();
assert.equal(nodes[0].hidden, true);
update("sr", "running", 0);
assert.equal(nodes[0].hidden, false);
assert.equal(timers.size, 0);
update("actions", "complete-with-errors", 2, 3);
assert.equal(nodes[1].hidden, false);
assert.equal(timers.size, 0);
update("sr", "error");
assert.equal(nodes[0].textContent, "Service Requests unavailable");
update("session", "running");
assert.equal(nodes[2].textContent, "SR session loading");
assert.equal(nodes[2].attributes["aria-busy"], "true");
update("session", "complete");
assert.equal(nodes[2].textContent, "SR session ready");
assert.equal(nodes[2].attributes["aria-busy"], "false");
assert.equal([...timers.values()].at(-1).delay, 5000);
});
test("CRM reports real page counts and Actions reports queued and completed requests", async () => {
const app = loadApplication("content");
const progress = [];
app.activityProgress.update = (...args) => progress.push(args);
app.currentUser.allowsCrmActivities = () => true;
app.opportunitiesQueryState.periodRequestVersion = 1;
app.fusionCrmServiceRequests.requestCrmServiceRequest = async (_token, _version, _active, onProgress) => {
onProgress(15, 20);
onProgress(20, 20);
return Array.from({ length: 20 }, () => ({}));
};
await app.opportunityCrmServiceRequests.load("synthetic", 1);
assert.deepEqual(progress, [["sr", "running", 0], ["sr", "running", 15, 20], ["sr", "running", 20, 20], ["sr", "complete", 20, 20]]);
app.productsContentJobs.queueProductsTableRender = () => {};
app.debugContentLog.appendBackgroundRequestLog = () => {};
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({});
const pending = [];
app.platformContentMessages.sendRuntimeMessage = () => new Promise(resolve => pending.push(resolve));
app.opportunityWorkloadActions.loadProducts({ items: [{ RevnId: "1" }, { RevnId: "2" }] });
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(progress.at(-1), ["actions", "running", 0, 2]);
pending[0]({ ok: true, actions: [], hasNext: false });
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(progress.at(-1), ["actions", "running", 1, 2]);
for (let attempt = 1; attempt <= 5; attempt += 1) {
pending[attempt]({ ok: false, error: "Synthetic failure" });
await new Promise(resolve => setImmediate(resolve));
}
assert.deepEqual(progress.at(-1), ["actions", "complete-with-errors", 2, 2]);
});

View File

@@ -0,0 +1,108 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const { response } = require("./support/characterization.cjs");
function setup(fetch) {
const app = loadApplication("content", { fetch });
app.currentUser.allowsCrmActivities = () => true;
app.opportunitiesQueryState.periodRequestVersion = 1;
app.debugContentView.renderDebugPanel = () => {};
app.activityProgress.update = () => {};
app.productsContentJobs.renderProductsProgress = () => {};
app.productsContentJobs.queueProductsTableRender = () => {};
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
const actions = [];
app.platformContentMessages.sendRuntimeMessage = async message => {
actions.push(message);
return { ok: true, actions: [], hasNext: false };
};
async function cycle(id) {
app.productsContentState.currentProductsRequestId = id;
app.productsContentState.opptyProductsByNumber.set("A", { items: [{ RevnId: "123" }], cachedAt: Date.now(), fromCache: false });
app.productActivities.begin(id, "synthetic");
await app.productActivities.complete(id);
}
return { app, actions, cycle };
}
test("SR succeeds on fifth attempt, is shared in flight and reused across Reference/product reloads", async () => {
let calls = 0;
const h = setup(async () => {
calls += 1;
if (calls < 5) throw new Error("Synthetic offline");
return response({ items: [], hasMore: false });
});
const first = h.app.opportunityCrmServiceRequests.load("synthetic", 1);
assert.equal(h.app.opportunityCrmServiceRequests.load("synthetic", 1), first);
assert.equal(await first, true);
assert.equal(calls, 5);
await h.cycle("current-quarter");
h.app.opportunitiesQueryState.periodRequestVersion += 1;
await h.cycle("next-quarter");
assert.equal(calls, 5);
assert.equal(h.actions.length, 2);
assert.equal(h.app.debugState.requestLog.filter(entry => entry.name === "resquestCrmServiceRequest").length, 5);
h.app.opportunityCrmServiceRequests.reset();
await h.cycle("explicit-refresh");
assert.equal(calls, 6);
});
for (const failure of [() => new Response("Denied", { status: 403 }), () => new Response("<html>Login</html>")]) {
test("SR exhausting five attempts blocks Actions and preserves the last completed timestamp", async () => {
let calls = 0;
const h = setup(async () => { calls += 1; return failure(); });
h.app.productsContentState.productsLastCachedAt = 123;
await h.cycle("failed");
assert.equal(calls, 5);
assert.equal(h.actions.length, 0);
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("A").state, "error");
assert.equal(h.app.productsContentState.productsLastCachedAt, 123);
h.app.authSessionState.principal = "synthetic@example.invalid";
assert.equal(h.app.opportunityWorkloadActions.getSummary("A").state, "error");
});
}
test("pagination retries only the failed SR page, with no duplicate records", async () => {
const offsets = [];
let failures = 0;
const h = setup(async (_url, options) => {
const { offset } = JSON.parse(options.body);
offsets.push(offset);
if (offset === 1 && failures++ < 2) return response({ invalid: true });
return response({ items: [{ SrNumber: `SR${offset}`, ReportedHours_c: 2, OpportunityName_Id_c: { OptyNumber: "A" } }],
offset, count: 1, hasMore: offset === 0 });
});
await h.cycle("paged");
assert.deepEqual(offsets, [0, 1, 1, 1]);
assert.equal(h.app.opportunityCrmServiceRequests.getSummary("A").reportedHours, 4);
assert.equal(h.actions.length, 1);
});
test("Actions retries each workload up to five times and stops as soon as it succeeds", async () => {
const h = setup(async () => response({ items: [], hasMore: false }));
let attempts = 0;
h.app.platformContentMessages.sendRuntimeMessage = async () => {
attempts += 1;
return attempts < 3 ? { ok: false, error: "Synthetic" } : { ok: true, actions: [], hasNext: false };
};
await h.cycle("success");
assert.equal(attempts, 3);
attempts = 0;
h.app.platformContentMessages.sendRuntimeMessage = async () => { attempts += 1; throw new Error("Synthetic"); };
await h.cycle("failed-actions");
assert.equal(attempts, 5);
});
test("an invalidated SR request does not continue retrying or launch Actions", async () => {
let reject, calls = 0;
const h = setup(() => { calls += 1; return new Promise((_resolve, fail) => { reject = fail; }); });
const pending = h.cycle("old");
await new Promise(resolve => setImmediate(resolve));
h.app.opportunityCrmServiceRequests.reset();
h.app.productsContentState.currentProductsRequestId = "new";
reject(new Error("Synthetic"));
await pending;
assert.equal(calls, 1);
assert.equal(h.actions.length, 0);
});

View File

@@ -29,6 +29,7 @@ function setup(mockSalesToken = true) {
const context = {
document: {
createElement: (tag) => new Element(tag),
createElementNS: (_, tag) => new Element(tag),
querySelectorAll: () => visibleAlerts
},
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),

View File

@@ -0,0 +1,68 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
function setup() {
const app = loadApplication("content");
const normal = { OptyNumber: "normal", Name: "Synthetic", ForecastGroup_c: "FORECAST", StatusCode: "OPEN",
CustomerAccount: { PartyId: "1", PartyUniqueName: "Customer" }, Owner: { PersonFirstName: "Owner" } };
const items = [normal,
{ ...normal, OptyNumber: "stage", ForecastGroup_c: null },
{ ...normal, OptyNumber: "status", StatusCode: "UNLISTED" },
{ ...normal, OptyNumber: "customer", CustomerAccount: null },
{ ...normal, OptyNumber: "owner", Owner: null }];
app.opportunitiesTableState.opportunitiesTableState.items = items;
app.opportunitiesFilters.resetCustomerFilter(items);
app.opportunitiesFilters.resetOwnerFilter(items);
return { app, items, result: () => Array.from(app.opportunitiesFilters.getSortedOpportunities(), item => item.OptyNumber) };
}
test("all selected includes records with missing or unlisted dimensions", () => {
const h = setup();
assert.equal(h.result().length, h.items.length);
h.app.opportunitiesFilterState.tableSearch = "normal";
assert.deepEqual(h.result(), ["normal"], "search still restricts results");
});
test("partial and empty selections remain restrictive, including WON_PENDING mapping", () => {
const h = setup();
const state = h.app.opportunitiesFilterState;
state.selectedStages.delete("SQL");
assert.equal(h.result().includes("stage"), false);
state.selectedStages.add("SQL");
state.selectedStatuses.delete("CLOSED");
assert.equal(h.result().includes("status"), false);
assert.equal(h.app.opportunitiesFilters.matchesSelectedStatus("WON_PENDING"), true);
state.selectedStatuses.clear();
assert.deepEqual(h.result(), []);
});
test("customer and owner exclusions apply only when a narrower selection is made", () => {
const h = setup();
const state = h.app.opportunitiesFilterState;
state.selectedCustomerKeys.clear();
assert.deepEqual(h.result(), []);
state.selectedCustomerKeys.add("party:1");
assert.equal(h.result().includes("customer"), true);
state.selectedOwnerKeys.clear();
assert.deepEqual(h.result(), []);
});
test("Owner offers one dash option for unassigned records and supports selecting it alone", () => {
const h = setup();
for (const Owner of [undefined, {}, "", " ", { PersonFirstName: " " }]) {
h.items.push({ ...h.items[0], OptyNumber: `missing-${h.items.length}`, Owner });
}
const options = Array.from(h.app.opportunitiesFormatters.getOwnerOptions());
const missing = options.filter(option => option.label === "-");
assert.equal(missing.length, 1);
assert.equal(missing[0].key, "owner:unassigned");
h.app.opportunitiesFilterState.selectedOwnerKeys = new Set([missing[0].key]);
assert.equal(h.result().length, 6);
assert.equal(h.result().includes("normal"), false);
h.app.opportunitiesFilterState.selectedOwnerKeys = new Set(options.map(option => option.key));
assert.equal(h.result().length, h.items.length);
h.app.opportunitiesFilterState.selectedOwnerKeys.delete(missing[0].key);
assert.equal(h.result().includes("owner"), false);
assert.equal(h.result().length, 4);
});

View File

@@ -4,7 +4,7 @@ const path = require("node:path");
const vm = require("node:vm");
const { createHash } = require("node:crypto");
const { test } = require("node:test");
const { sources, composeScript } = require("../scripts/compose.cjs");
const { sources, vendorSources, composeScript } = require("../scripts/compose.cjs");
const { loadApplication } = require("./support/application.cjs");
const root = path.resolve(__dirname, "..");
@@ -14,7 +14,13 @@ const json = value => JSON.parse(JSON.stringify(value));
test("every source belongs to an explicit runtime composition, with no duplicate factories", () => {
const actual = fs.readdirSync(path.join(root, "src"), { recursive: true })
.filter(file => file.endsWith(".js")).map(file => `src/${file.replaceAll(path.sep, "/")}`).sort();
assert.deepEqual([...new Set(Object.values(sources).flat())].sort(), actual);
assert.deepEqual([...new Set([...Object.values(sources).flat(), ...Object.values(vendorSources).flat()])].sort(), actual);
for (const files of Object.values(vendorSources)) {
for (const file of files) {
assert.doesNotThrow(() => new vm.Script(read(file)));
assert.doesNotMatch(read(file), /\beval\s*\(|\bnew\s+Function\s*\(/, file);
}
}
for (const [target, files] of Object.entries(sources)) {
assert.equal(new Set(files).size, files.length);
const factories = files.flatMap(file => [...read(file).matchAll(/^function (\w+)\(/gm)].map(match => match[1]));

113
tests/badgeIcons.test.cjs Normal file
View File

@@ -0,0 +1,113 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { loadApplication, composeScript } = require("./support/application.cjs");
const root = path.join(__dirname, "..");
class Element {
constructor(tag) {
this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = "";
this.classList = { toggle() {} };
}
set textContent(value) { this.text = String(value); this.children = []; }
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
append(...children) { this.children.push(...children); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, callback) { this.listeners[key] = callback; }
}
function setup() {
const app = loadApplication("content", { document: { createElement: tag => new Element(tag), getElementById: () => null,
createElementNS: (namespace, tag) => Object.assign(new Element(tag), { namespace }) } });
app.currentUser.allowsCrmActivities = () => true;
app.productsContentJobs.queueProductsTableRender = () => {};
app.activityProgress.update = () => {};
app.authSessionState.principal = "me@example.invalid";
return app;
}
function checkIcon(badge, kind, value) {
assert.equal(badge.textContent, value);
const images = badge.children.filter(child => child.tag === "svg");
assert.equal(images.length, 1);
const icon = images[0];
assert.equal(icon.namespace, "http://www.w3.org/2000/svg");
assert.equal(icon.attributes.viewBox, "0 0 24 24");
assert.equal(icon.attributes.fill, "none");
assert.equal(icon.attributes.stroke, "currentColor");
assert.equal(icon.attributes["stroke-width"], "2.25");
assert.equal(icon.attributes["stroke-linecap"], "round");
assert.equal(icon.attributes["stroke-linejoin"], "round");
assert.equal(icon.attributes["data-badge-icon"], kind);
assert.equal(icon.attributes["aria-hidden"], "true");
assert.equal(icon.attributes.focusable, "false");
assert.equal(icon.attributes.width, "16");
assert.equal(icon.attributes.height, "16");
assert.ok(icon.children.length >= 3);
assert.ok(icon.children.every(child => ["path", "rect", "polyline"].includes(child.tag)));
assert.ok(icon.children.every(child => child.namespace === icon.namespace));
assert.equal(badge.children.some(child => child.tag === "img"), false);
}
test("consistent inline vectors replace raster icons without parentheses or lost tooltips", async () => {
const app = setup();
const products = { items: [1, 2, 3].map(RampMonths_c => ({ RampMonths_c, RevnId: "123" })) };
const count = app.productsDetails.createProductsCountButton("TEST", products);
checkIcon(count, "products", "3");
assert.equal(count.title, "3 products");
assert.equal(typeof count.listeners.click, "function");
const ramp = app.productsDetails.createProductsRampBadge(products);
checkIcon(ramp, "ramp", "1 | 2 | 3");
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
checkIcon(missingRamp, "ramp", "N/D");
assert.equal(missingRamp.attributes["data-missing"], "true");
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
{ SrNumber: "SR-TEST", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "TEST" } }
];
await app.opportunityCrmServiceRequests.load("synthetic", app.opportunitiesQueryState.periodRequestVersion);
const sr = app.opportunityCrmServiceRequests.createBadge("TEST");
checkIcon(sr, "sr", "4h");
assert.equal(sr.title, "Whether a Service Request has been created for the opportunities.");
assert.equal(typeof sr.listeners.click, "function");
checkIcon(app.opportunityCrmServiceRequests.createBadge("OTHER"), "sr", "0h+");
app.productsContentState.opptyProductsByNumber.set("TEST", products);
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true, hasNext: false, actions: [
{ id: 1, owner: "me@example.invalid", name: "My action" },
{ id: 2, owner: "other@example.invalid", name: "Other action" }
] });
await app.opportunityWorkloadActions.loadProducts(products);
const actions = app.opportunityWorkloadActions.createBadge("TEST");
checkIcon(actions, "actions", "2");
assert.equal(actions.attributes["data-state"], "matched");
assert.match(actions.title, /My action/);
assert.match(actions.title, /Other action/);
app.currentUser.allowsCrmActivities = () => false;
assert.equal(app.opportunityWorkloadActions.createBadge("TEST"), null);
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST"), null);
});
test("vector shapes and their license ship in both distributions without raster images or requests", () => {
const source = composeScript("content");
const module = fs.readFileSync(path.join(root, "src/features/opportunities/badge-content.js"), "utf8");
assert.ok(source.includes(module));
assert.doesNotMatch(source, /opportunityBadgeImages|data:image\/png;base64/);
for (const target of ["chromium", "firefox"]) {
const bundle = fs.readFileSync(path.join(root, "dist", target, "content.js"), "utf8");
assert.ok(bundle.includes(module));
assert.ok(bundle.includes("Copyright (c) 2026 Lucide Icons and Contributors"));
}
});
test("icon styles preserve uniform dimensions and provide light, dark and active contrast", () => {
const css = fs.readFileSync(path.join(root, "src/features/opportunities/styles.css"), "utf8");
const icon = css.match(/\.opportunities-extension-badge-icon \{([^}]+)\}/)[1];
for (const rule of ["order: -1;", "flex: 0 0 16px;", "width: 16px;", "height: 16px;", "color: inherit;", "stroke: currentColor;", "stroke-width: 2.25;"]) {
assert.ok(icon.includes(rule), rule);
}
assert.doesNotMatch(icon, /filter:|mix-blend-mode:/);
assert.match(css, /gap: 4px;\s*height: 20px;/);
assert.match(css, /cell-actions > \.opportunities-extension-crm-sr-badge \{\s*display: inline-flex;\s*align-items: center;\s*justify-content: flex-start;/);
});

View File

@@ -40,8 +40,8 @@ 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.2", permissions: ["cookies", "storage"],
host_permissions: ["https://eeho.fa.us2.oraclecloud.com/*", "https://spa.oracle.com/*"],
version: "1.0.3", 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*"],
js: ["content.js"], all_frames: true, run_at: "document_idle"
@@ -93,7 +93,7 @@ test("opportunity query preserves fields, record-set OR, date AND, and paginatio
{ op: "$or", criteria: types.map(value => ({ op: "$eq", attribute: "RecordSet", value })) },
{ op: "$wi", attribute: "EffectiveDate", value1: range.startCloseDate, value2: range.endCloseDate, dynamicDate: false }
] }, keywords: null, keywordsFields: fields,
fields: [...fields, "CustomerAccount.PartyUniqueName", "CustomerAccount.PartyId", "CustomerAccount.PartyNumber"],
fields: [...fields, "CustomerAccount.PartyUniqueName", "CustomerAccount.PartyId", "CustomerAccount.PartyNumber", "CustomerAccount.Region_c", "CustomerAccount.PrimaryAddress.CountryCode"],
sort: [], language: "en", skipInValidFields: true, skipHiddenFromUIFields: true,
copiedFrom: "queries/d5dbcd01-9ff3-40be-9b5b-64744cbf7162", limit: 15, offset: 15
});
@@ -183,6 +183,8 @@ test("tokenrelay missing-cookie recovery and HTTP failure do not change the requ
assert.equal(h.refreshes.length, 1);
assert.equal(calls.length, 1);
assert.equal(calls[0].options.headers["x-xsrf-token"], "synthetic-xsrf");
assert.equal(h.requestLog.at(-1).requestHeaders["x-xsrf-token"], "synthetic-xsrf");
assert.equal(JSON.parse(h.requestLog.at(-1).responseBody).access_token, "synthetic-access");
assert.equal(calls[0].options.method, "GET");
for (const status of [400, 401, 403]) {
let attempts = 0;

View File

@@ -0,0 +1,73 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const tokens = [
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-account" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } },
{ tokenService: "WWB-TS", uiaasHeader: { value: "synthetic-actions" } }
];
const request = { type: "opportunitiesExtension.requestConsumerTokenService" };
for (const family of ["chrome", "firefox"]) {
for (const status of [401, 403]) {
test(`${family}: HTTP ${status} opens an inactive SPA session and closes it after valid JSON`, async () => {
let attempts = 0;
const h = loadBackground({ family, fetch: async (_url, options) => {
attempts += 1;
assert.equal(options.credentials, "include");
if (attempts === 1) return response({ error: "Session unavailable" }, status);
assert.equal(h.tabs.length, 1, "session tab must open before retrying");
return response(tokens);
} });
const result = await h.dispatch(request);
assert.equal(result.ok, true);
assert.equal(attempts, 2);
assert.equal(h.tabs.length, 1);
assert.equal(h.tabs[0].active, false);
assert.equal(h.tabs[0].windowId, 3);
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
assert.equal(h.tabs[0].url, "https://spa.oracle.com/oalcrm/web/api/g2m-consumer-application/ui/index.html");
assert.deepEqual(h.removedTabs, [91]);
assert.equal(result.debugRequests[0].httpStatus, status);
assert.match(result.debugRequests[0].metadata.retryReason, /SPA session recovery/);
assert.equal(result.debugRequests[1].metadata.sessionBootstrapState, "closed-after-valid-json");
assert.equal(result.authorizations.workloadActions.value, "synthetic-actions");
});
}
test(`${family}: persistent 403 stops after five attempts and removes the auxiliary tab`, async () => {
let attempts = 0;
const h = loadBackground({ family, fetch: async () => { attempts += 1; return response({}, 403); } });
const result = await h.dispatch(request);
assert.equal(result.ok, false);
assert.equal(attempts, 5);
assert.equal(h.tabs.length, 1);
assert.deepEqual(h.removedTabs, [91]);
assert.match(result.error, /403 after 5 attempts/);
assert.equal(result.debugRequests.at(-1).metadata.retryLimitReached, "true");
assert.equal(result.debugRequests.at(-1).metadata.sessionBootstrapState, "closed-after-retry-finished");
});
}
test("mixed 403/HTML recovery shares one tab and the existing retry budget", async () => {
let attempts = 0;
const h = loadBackground({ fetch: async () => {
attempts += 1;
if (attempts === 1) return response({}, 403);
if (attempts === 2) return new Response("<html>Login</html>");
return response(tokens);
} });
const result = await h.dispatch(request);
assert.equal(result.ok, true);
assert.equal(attempts, 3);
assert.equal(h.tabs.length, 1);
assert.deepEqual(h.removedTabs, [91]);
assert.equal(result.debugRequests[1].metadata.sessionBootstrapState, "waiting-for-valid-session");
});
test("unrelated HTTP failures do not launch the SPA session page", async () => {
const h = loadBackground({ fetch: async () => response({}, 500) });
const result = await h.dispatch(request);
assert.equal(result.ok, false);
assert.equal(result.debugRequests.length, 1);
assert.equal(h.tabs.length, 0);
});

View File

@@ -3,7 +3,8 @@ const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const response = data => new Response(JSON.stringify(data), { status: 200 });
function setup(fetch) {
const document = { createElement: () => ({ attributes: {}, 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);
const app = loadApplication("content", { fetch, document, Intl, Date });
app.currentUser.allowsCrmActivities = () => true;
app.debugContentView.renderDebugPanel = () => {};
@@ -14,6 +15,27 @@ 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 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/D"], [[4, null], "N/D"], [[""], "N/D"], [["invalid"], "N/D"]]) {
const app = setup(async () => response({ items: values.map((value, index) => ({ ...sr("A", `SR-${index}`), ReportedHours_c: value })), hasMore: false }));
await app.opportunityCrmServiceRequests.load("synthetic", 1);
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, expected);
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").title, "Whether a Service Request has been created for the opportunities.");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").title, "Whether a Service Request has been created for the opportunities.");
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").attributes["data-state"], "matched");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").attributes["data-state"], "missing");
}
});
test("SR tooltip formats customer deadlines without timezone day shifts", async () => {
for (const [value, expected] of [["2026-05-14T00:00:00.000+0000", "14/05/2026"], [null, "N/D"], [undefined, "N/D"], ["", "N/D"]]) {
const app = setup(async () => response({ items: [{ ...sr("A"), CustomerDeadline_c: value }], hasMore: false }));
await app.opportunityCrmServiceRequests.load("synthetic", 1);
assert.ok(app.opportunityCrmServiceRequests.createBadge("A").attributes["aria-label"].includes(`Customer Deadline: ${expected}`));
}
});
test("CRM fetch is called without the environment object as its receiver", async () => {
let calls = 0;
const app = setup(async function (url, options) {
@@ -28,7 +50,7 @@ test("CRM fetch is called without the environment object as its receiver", async
assert.equal(items.length, 2);
});
test("CRM query preserves payload, paginates before opportunities, uses tokenrelay Bearer and logs each page", async () => {
test("CRM query preserves payload and pagination after opportunities, uses tokenrelay Bearer and logs each page", async () => {
const calls = [];
const app = setup(async (url, options) => {
const payload = JSON.parse(options.body);
@@ -40,11 +62,12 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
app.productsContentJobs.renderProductsProgress = () => {};
app.productsContentJobs.requestOpptyProductsInBackground = () => {};
app.fusionOpportunities.requestOpportunitiesPage = async () => {
assert.equal(calls.length, 2, "all CRM pages must finish first");
assert.equal(calls.length, 0, "CRM must not run before product loading");
return [{ OptyNumber: "TEST-1" }];
};
await app.opportunitiesController.requestOpportunities("synthetic-token", "Current Quarter", {}, 1);
assert.equal(states.at(-1).status, "ready");
await app.opportunityCrmServiceRequests.load("synthetic-token", 1);
assert.deepEqual(calls.map(call => call.payload.offset), [0, 1]);
const { payload, options, url } = calls[0];
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/crmRestApi/searchResources/latest/custom-actions/queries");
@@ -61,7 +84,7 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
{ op: "$in", attribute: "StatusCd", values: ["SVC_PENDING_DELIVERY"] },
{ op: "$eq", attribute: "RecordSet", value: "ORA_SVC_MY_SR_TEAM_SRS" }
] }, keywords: null,
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "_actions", "Title", "Owner.PersonFirstName",
fields: ["SrNumber", "OpportunityName_Id_c.OptyNumber", "ReportedHours_c", "CustomerDeadline_c", "PrimaryPillarNew_c", "_actions", "Title", "Owner.PersonFirstName",
"Owner.PersonLastName", "Owner.PrimaryEmail.EmailAddress", "DoNotCallFlag", "DoNotEmailFlag"],
sort: [{ attribute: "_score", direction: "descending" }], language: "en",
keywordsFields: ["SrNumber", "ServiceNew_Id_c.RecordName", "CustomerAccount", "OpportunityName_Id_c", "OpportunityName_Id_c.OptyNumber", "Owner"],
@@ -75,15 +98,19 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
assert.equal(log.status, "success");
assert.ok(log.responseBody);
assert.ok(log.durationMs >= 0);
assert.doesNotMatch(log.requestHeaders.Authorization, /synthetic-token/);
assert.equal(log.requestHeaders.Authorization, "Bearer synthetic-token");
assert.doesNotMatch(app.debugContentLog.formatHeaderBlock(log.requestHeaders), /synthetic-token/);
app.debugState.debugSecretsVisible = true;
assert.match(app.debugContentLog.formatHeaderBlock(log.requestHeaders), /Bearer synthetic-token/);
app.debugState.debugSecretsVisible = false;
assert.equal(app.debugContentView.getRequestCategory(log), "serviceRequests");
}
const found = app.opportunityCrmServiceRequests.createBadge("TEST-1");
assert.equal(found.textContent, "\u2705 SR");
assert.equal(found.textContent, "0h");
assert.equal(found.attributes["data-state"], "matched");
for (const detail of ["SR-0", "SR-1", "Reported Hours: 0", "Test Owner", "test@example.invalid", "<script>synthetic</script>"])
assert.ok(found.attributes["aria-label"].includes(detail));
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null);
app.opportunityCrmServiceRequests.reset();
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST-1"), null);
@@ -107,7 +134,8 @@ test("CRM failures do not mislabel missing SRs or block the opportunity list", a
app.fusionOpportunities.requestOpportunitiesPage = async () => { loaded = true; return []; };
await app.opportunitiesController.requestOpportunities("token", "Current Quarter", {}, 1);
assert.equal(loaded, true);
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST").textContent, "SR ?");
await app.opportunityCrmServiceRequests.load("token", 1);
assert.equal(app.opportunityCrmServiceRequests.createBadge("TEST").textContent, "?");
assert.equal(app.debugState.requestLog.at(-1).status, "failed");
}
});
@@ -122,8 +150,8 @@ test("stale CRM results cannot overwrite a reopened modal or continue pagination
await app.opportunityCrmServiceRequests.load("token", 2);
resolveOld(response({ items: [sr("OLD")], hasMore: true, count: 1, offset: 0 }));
await old;
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "\u2705 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "\u26d4 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("NEW").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge("OLD").textContent, "0h");
assert.equal(app.debugState.requestLog.length, 1);
});
@@ -144,8 +172,8 @@ test("Reference changes share pending pagination and reuse completed CRM results
app.opportunitiesQueryState.periodRequestVersion = 3;
await app.opportunityCrmServiceRequests.load("token", 3);
assert.equal(calls, 2);
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "\u2705 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "\u2705 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge("A").textContent, "0h");
assert.equal(app.opportunityCrmServiceRequests.createBadge("B").textContent, "0h");
app.opportunityCrmServiceRequests.reset();
await app.opportunityCrmServiceRequests.load("token", 3);
assert.equal(calls, 3);

327
tests/crmSrDetails.test.cjs Normal file
View File

@@ -0,0 +1,327 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const type = "opportunitiesExtension.requestInternalServiceRequest";
test("internal SR form lookups use management proxy, Session and parent SrId", async () => {
const calls = [];
const h = loadBackground({ fetch: async (url, options) => {
calls.push({ url: new URL(url), options });
return response({ items: String(url).includes("/lookups?")
? [{ code: "OCI", name: "Oracle Cloud" }]
: [{ Id: 123, RecordName: "Review", Description_c: "Review the architecture" }] });
} });
const platform = await h.dispatch({ type: "opportunitiesExtension.requestLookupTypePlatform" });
assert.equal(platform.ok, true);
assert.equal(platform.items[0].code, "OCI");
const service = await h.dispatch({ type: "opportunitiesExtension.requestInternalSrServices", parentServiceId: "456", emailAddress: "user+test@example.invalid" });
assert.equal(service.ok, true);
assert.equal(service.items[0].Id, "123");
assert.equal(service.items[0].Description_c, "Review the architecture");
assert.equal(calls[1].url.searchParams.get("parentServiceId"), "456");
assert.equal(calls[1].url.searchParams.get("emailAddress"), "user+test@example.invalid");
assert.equal(calls[1].url.searchParams.get("serviceType"), "INTERNAL");
for (const call of calls) {
assert.match(call.url.pathname, /SeaaS_SR_management.*oalsetSeaaSOKECustomRestAPI/);
assert.equal(call.options.headers.Authorization, "Session");
assert.equal(call.options.credentials, "include");
}
assert.equal((await h.dispatch({ type: "opportunitiesExtension.requestInternalSrServices", parentServiceId: "bad", emailAddress: "user@example.invalid" })).ok, false);
assert.equal(calls.length, 2);
});
test("preparation reuses an existing unexpired cookie pair without navigation or API calls", async () => {
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const h = loadBackground({
fetch: async () => assert.fail("No API calls during preparation"),
cookies: () => [1, 2].map(part => ({ name: `ORA_OCIS_CG_SESSION_idcs-existing_${domain}_${part}`, value: `secret-${part}`, domain }))
});
const result = await h.dispatch({ type: "opportunitiesExtension.prepareSrSession" });
assert.equal(result.ok, true);
assert.equal(h.tabs.length, 0);
assert.equal(h.removedTabs.length, 0);
});
test("preparation closes immediately on the second cookie event even while getAll is pending", { timeout: 2000 }, async () => {
const fs = require("node:fs"), vm = require("node:vm"), path = require("node:path");
const factory = vm.runInNewContext(`${fs.readFileSync(path.join(__dirname, "../src/services/fusion/sr-details.js"), "utf8")}\ncreateFusionSrDetails`);
const listeners = new Set();
let reads = 0, closed = 0;
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const cookie = part => ({ name: `ORA_OCIS_CG_SESSION_idcs-new_${domain}_${part}`, value: `secret-${part}`, domain, storeId: "store" });
const emit = change => { for (const listener of listeners) listener(change); };
const api = factory({
fetchJson: async () => assert.fail("No API calls during preparation"),
cookieChanges: { addListener: fn => listeners.add(fn), removeListener: fn => listeners.delete(fn) },
cookiesGetAll: () => ++reads === 1 ? Promise.resolve([]) : new Promise(() => {}),
tabsCreate: async () => {
setImmediate(() => {
emit({ cookie: cookie(1) });
emit({ cookie: { ...cookie(2), storeId: "other" } });
assert.equal(closed, 0);
emit({ cookie: cookie(2), removed: true });
assert.equal(closed, 0);
emit({ cookie: cookie(2) });
});
return { id: 7 };
},
tabsRemove: async id => { assert.equal(id, 7); closed += 1; },
supportsFirefoxCookieStoreTabs: () => false,
wait: () => new Promise(() => {})
});
assert.equal((await api.prepareSession({ tab: { cookieStoreId: "store" } })).ok, true);
assert.equal(closed, 1);
assert.equal(listeners.size, 0);
});
test("explicit preparation opens SeaaS initiation and observes cookies without any API requests", async () => {
let calls = 0;
let reads = 0;
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const pair = [1, 2].map(part => ({ name: `ORA_OCIS_CG_SESSION_idcs-new_${domain}_${part}`, value: `secret-${part}`, domain }));
const h = loadBackground({ fetch: async () => {
calls += 1;
assert.fail("Opening the main modal must not trigger any session-dependent API request");
}, cookies: () => {
reads += 1;
if (reads === 1) return [];
assert.equal(h.tabs.length, 1);
if (reads === 2) return pair.slice(0, 1);
if (reads === 3) return pair.map(cookie => ({ ...cookie, expirationDate: 1 }));
return pair;
} });
const result = await h.dispatch({ type: "opportunitiesExtension.prepareSrSession" });
assert.equal(result.ok, true);
assert.equal(calls, 0);
assert.equal(reads, 4);
assert.deepEqual(result.debugRequests, []);
assert.ok(!JSON.stringify(result).includes("secret"));
assert.equal(h.tabs[0].active, false);
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/");
assert.deepEqual(h.removedTabs, [91]);
});
test("proactive preparation times out without issuing lookup probes", async () => {
let calls = 0;
const h = loadBackground({ messageTimeout: 10000, fetch: async () => { calls += 1; assert.fail("No probes allowed"); } });
const result = await h.dispatch({ type: "opportunitiesExtension.prepareSrSession" });
assert.equal(result.ok, false);
assert.equal(calls, 0);
assert.match(result.error, /session could not be established/);
assert.deepEqual(h.removedTabs, [91]);
});
test("cookie change wakes recovery without waiting for a polling timer and removes its listener", { timeout: 2000 }, async () => {
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/services/fusion/sr-details.js"), "utf8")}\ncreateFusionSrDetails`);
const listeners = new Set();
let ready = false;
let calls = 0;
let closed = 0;
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const cookies = [1, 2].map(part => ({ name: `ORA_OCIS_CG_SESSION_idcs-new_${domain}_${part}`, value: `secret-${part}`, domain, storeId: "store" }));
const api = factory({
cookieChanges: { addListener: fn => listeners.add(fn), removeListener: fn => listeners.delete(fn) },
cookiesGetAll: async () => ready ? cookies : [],
tabsCreate: async () => {
setImmediate(() => {
ready = true;
for (const listener of listeners) listener({ cookie: cookies[1] });
});
return { id: 7 };
},
tabsRemove: async () => { closed += 1; },
supportsFirefoxCookieStoreTabs: () => false,
wait: () => new Promise(() => {})
});
const result = await api.requestLookupWithSession(async () => {
calls += 1;
if (!ready) { const error = new Error("Unauthorized"); error.httpStatus = 401; throw error; }
return { ok: true, items: [] };
}, {}, { tab: { cookieStoreId: "store" } });
assert.equal(result.ok, true);
assert.equal(calls, 2);
assert.equal(closed, 1);
assert.equal(listeners.size, 0);
});
test("renewed matching cookie fragments trigger an early retry without leaking session values", async () => {
let reads = 0;
let calls = 0;
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
const cookie = (id, part, value) => ({ name: `ORA_OCIS_CG_SESSION_idcs-${id}_${domain}_${part}`, value, domain, path: "/", session: true });
const h = loadBackground({
cookies: () => {
reads += 1;
if (reads < 3) return [cookie("old", 1, "old-secret-1"), cookie("old", 2, "old-secret-2")];
if (reads === 3) return [cookie("new", 1, "new-secret-1"), cookie("other", 2, "wrong-pair")];
return [cookie("new", 1, "new-secret-1"), cookie("new", 2, "new-secret-2")];
},
fetch: async (_, options) => {
calls += 1;
assert.equal(options.credentials, "include");
assert.equal(options.headers.Cookie, undefined);
if (calls === 1) return response({}, 401);
assert.equal(reads, 4, "retry immediately when both renewed parts match, not on a partial pair");
return response({ items: [] });
}
});
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupLanguage" });
assert.equal(result.ok, true);
assert.equal(calls, 2);
assert.ok(!JSON.stringify(result).includes("secret"));
assert.ok(h.cookieQueries.every(query => query.domain === domain && query.storeId === "test-store"));
assert.deepEqual(h.removedTabs, [91]);
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: simultaneous lookup 401s share an inactive session tab and close it after successful retries`, async () => {
const counts = new Map();
const h = loadBackground({ family, fetch: async url => {
const count = (counts.get(url) || 0) + 1;
counts.set(url, count);
return count < 3 ? response({}, 401) : response({ items: [] });
} });
const results = await Promise.all(["PrimaryPillar", "Language", "TechPlatform", "OpportunityBusiness"].map(name =>
h.dispatch({ type: `opportunitiesExtension.requestSrLookup${name}`, emailAddress: "test@example.invalid" })));
assert.ok(results.every(result => result.ok));
assert.ok(results.every(result => result.debugRequests.length === 3));
assert.equal(h.tabs.length, 1);
assert.equal(h.tabs[0].active, false);
assert.equal(h.tabs[0].windowId, 3);
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/");
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
assert.deepEqual(h.removedTabs, [91]);
});
}
test("lookup authentication timeout closes the temporary tab and returns a clear error", async () => {
const h = loadBackground({ messageTimeout: 10000, fetch: async () => response({}, 401) });
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupLanguage" });
assert.equal(result.ok, false);
assert.match(result.error, /session could not be established/);
assert.equal(h.tabs.length, 1);
assert.deepEqual(h.removedTabs, [91]);
});
test("SR language, platform and services use fixed Session endpoints and dynamic email", async () => {
const calls = [];
const h = loadBackground({ fetch: async (url, options) => {
calls.push({ url, options });
return response({ items: url.includes("/services?")
? [{ Id: 123, RecordName: "Consumption", Abstract_c: "Service summary" }]
: [{ code: "CODE", name: "Label", enabledFlag: "Y", displaySequence: 1 }] });
} });
for (const [name, lookup] of [["requestSrLookupLanguage", "SENGG_LANGUAGE"], ["requestSrLookupTechPlatform", "SENGG_TECH_PLATFORM"]]) {
const result = await h.dispatch({ type: `opportunitiesExtension.${name}` });
assert.deepEqual(result.items, [{ code: "CODE", name: "Label" }]);
assert.equal(new URL(calls.at(-1).url).searchParams.get("lookupType"), lookup);
assert.equal(result.debugRequests[0].name, name);
}
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupOpportunityBusiness", emailAddress: "user+test@example.invalid" });
assert.deepEqual(result.items, [{ Id: "123", RecordName: "Consumption", Abstract_c: "Service summary" }]);
const url = new URL(calls.at(-1).url);
assert.equal(url.searchParams.get("emailAddress"), "user+test@example.invalid");
assert.equal(url.searchParams.get("salesObject"), "OPPORTUNITY");
assert.equal(url.searchParams.get("serviceType"), "BUSINESS");
assert.ok(calls.every(call => call.options.headers.Authorization === "Session" && call.options.method === "GET"));
assert.equal((await h.dispatch({ type: "opportunitiesExtension.requestSrLookupOpportunityBusiness" })).ok, false);
assert.equal(calls.length, 3);
});
test("Primary pillar lookup uses Session, orders enabled name/code options and rejects invalid data", async () => {
const calls = [];
const h = loadBackground({ fetch: async (url, options) => {
calls.push({ url, options });
return response({ items: [
{ code: "B", name: "Second", displaySequence: 20, enabledFlag: "Y" },
{ code: "N", name: "Disabled", displaySequence: 1, enabledFlag: "N" },
{ code: "A", name: "First", displaySequence: 10, enabledFlag: "Y" }
] });
} });
const result = await h.dispatch({ type: "opportunitiesExtension.requestSrLookupPrimaryPillar" });
assert.equal(result.ok, true);
assert.deepEqual(result.items, [{ code: "A", name: "First" }, { code: "B", name: "Second" }]);
assert.ok(calls[0].url.includes("SeaaS_initiation/live;profile=PROD"));
assert.ok(calls[0].url.endsWith("lookupType=SENGG_PRIMARY_PILLAR&type=STANDARD"));
assert.equal(calls[0].options.headers.Authorization, "Session");
assert.equal(calls[0].options.method, "GET");
for (const fetch of [async () => response({ items: [{}] }), async () => response({}, 401)]) {
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 = [];
const children = [{ SrId: 123, SrNumber: "SR123", Title: "Synthetic" }];
const h = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
return response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: children }] });
} });
const result = await h.dispatch({ type, srId: "987", token: "secret-test" });
assert.equal(result.ok, true);
assert.deepEqual(result.items, children);
const url = new URL(calls[0].url);
assert.equal(url.hostname, "comcipapic-oalprod.integration.ocp.oraclecloud.com");
assert.equal(url.searchParams.get("q"), "SrId=987");
assert.equal(url.searchParams.get("onlyData"), "true");
assert.ok(url.searchParams.get("fields").includes("ServiceRequest_SRToInternalSR_Tgt:ReportedHours_c"));
assert.equal(calls[0].options.method, "GET");
assert.equal(calls[0].options.headers.Authorization, "Session");
assert.equal(calls[0].options.credentials, "include");
assert.equal(result.debugRequests[0].requestHeaders.Authorization, "Session");
assert.ok(!JSON.stringify(result).includes("secret-test"));
assert.equal(result.debugRequests[0].name, "requestInternalServiceRequest");
});
}
test("SR details distinguish empty collections from errors and validate inputs", async () => {
for (const body of [{ items: [] }, { items: [{}] }, { items: [{ ServiceRequest_SRToInternalSR_Tgt: [] }] }]) {
const h = loadBackground({ fetch: async () => response(body) });
assert.deepEqual((await h.dispatch({ type, srId: "1" })).items, []);
}
for (const fetch of [async () => response({}, 403), async () => response({}), async () => response({ items: [{ ServiceRequest_SRToInternalSR_Tgt: {} }] })]) {
const h = loadBackground({ fetch });
assert.equal((await h.dispatch({ type, srId: "1", token: "test" })).ok, false);
}
const h = loadBackground({ fetch: () => assert.fail("Invalid input must not fetch") });
assert.equal((await h.dispatch({ type, srId: "1 OR 1=1", token: "test" })).ok, false);
assert.equal((await h.dispatch({ type })).ok, false);
});

View File

@@ -1,6 +1,112 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
test("internal SR form validates Save, maps parent data, prevents duplicate posts and preserves failures", async () => {
const h = setup();
h.app.currentUser.getUserId = () => "current@example.invalid";
h.app.opportunitiesTableState.opportunitiesTableState.items = [
{ OptyNumber: "OTHER", Name: "Other opportunity", CustomerAccount: { PrimaryAddress: { CountryCode: "US" } } },
{ OptyNumber: "OP1", Name: "<Opportunity name>", OptyId: 456, CountryCode: "CA",
CustomerAccount: { PartyId: 789, Region_c_localizedValue: "LAD", CountryCode: "AR", PrimaryAddress: { CountryCode: "BR" } } }
];
h.app.debugContentView.renderDebugPanel = () => {};
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{ SrId: "123", SrNumber: "SR123",
CustomerDeadline_c: "2026-05-14T00:00:00.000+0000", PrimaryPillarNew_c: "Tech_Cloud_Infrastructure", Title: "<Parent SR title>", OpportunityName_Id_c: { OptyNumber: "OP1" } }];
const calls = [];
h.app.platformContentMessages.sendRuntimeMessage = async message => {
calls.push(message);
return { ok: true, items: message.type.endsWith("requestLookupTypePlatform") ? [{ code: "OCI", name: "Oracle Cloud" }]
: message.type.endsWith("requestInternalSrServices") ? [{ Id: "45", RecordName: "Review", Description_c: "Architecture review" }] : [] };
};
await h.app.opportunityCrmServiceRequests.load("synthetic", 1);
const badge = h.app.opportunityCrmServiceRequests.createBadge("OP1");
h.modal.append(badge);
badge.listeners.pointerenter();
await flush();
const add = h.walk(h.panel()).find(el => el.title === "Create Internal Service Request");
assert.ok(add);
assert.equal(add.parent.children[2].title, "Copy SR number");
add.listeners.click({ preventDefault() {}, stopPropagation() {} });
await flush();
const nodes = h.walk(h.modal);
const context = nodes.find(el => el.className === "opportunities-extension-action-form-context");
assert.deepEqual(context.children.map(el => el.textContent), ["SR Number", "SR123", "Opportunity", "<Opportunity name>", "Title", "<Parent SR title>"]);
assert.equal(nodes.find(el => el.name === "customerDeadline").value, "14/05/2026");
assert.equal(nodes.find(el => el.name === "customerDeadline").readOnly, true);
assert.equal(nodes.find(el => el.name === "customerDeadline").required, false);
for (const name of ["title", "internalDeadline", "platform", "description", "services"]) {
const input = nodes.find(el => el.name === name);
assert.equal(input.required, true, name);
assert.equal(input.attributes["aria-required"], "true", name);
assert.match(input.parent.children[0].textContent, /\(required\)$/);
}
for (const name of ["platform", "services"]) {
const input = nodes.find(el => el.id === `internal-sr-${name}-search`);
assert.equal(input.required, true);
assert.equal(input.attributes["aria-required"], "true");
}
assert.equal(nodes.find(el => el.name === "internalDeadline").type, "date");
assert.equal(nodes.find(el => el.name === "platform").children[1].value, "OCI");
assert.equal(nodes.find(el => el.name === "services").children[1].attributes["data-description"], "Architecture review");
assert.equal(calls.at(-1).parentServiceId, "123");
assert.equal(calls.at(-1).emailAddress, "current@example.invalid");
const form = nodes.find(el => el.tag === "form");
assert.deepEqual(h.walk(form).filter(el => el.tag === "button").map(el => el.textContent), ["Save", "Cancel"]);
const submit = () => form.listeners.submit({ preventDefault() {} });
const before = calls.length;
await submit();
assert.equal(calls.length, before);
assert.equal(nodes.find(el => el.name === "title").attributes["aria-invalid"], "true");
for (const [name, value] of Object.entries({ title: "New internal SR", internalDeadline: "2026-05-01", platform: "OCI", description: "Detailed request", services: "45" })) nodes.find(el => el.name === name).value = value;
const posts = [], toasts = [];
h.app.opportunitiesLinks.showCopyConfirmation = (message, isError = false) => toasts.push({ message, isError });
h.app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => posts.push({ message, resolve }));
const saving = submit();
await submit();
assert.equal(posts.length, 1);
assert.equal(posts[0].message.type, "opportunitiesExtension.requestPostInternalServiceRequest");
assert.equal(posts[0].message.emailAddress, "current@example.invalid");
assert.deepEqual(JSON.parse(JSON.stringify(posts[0].message.payload)), {
Title: "New internal SR", ServiceNew_Id_c: "45",
ProblemDescription: "Detailed request", CustomerDeadline_c: "2026-05-14", TechPlatformLookUp_c: "OCI",
ServiceRequest_Id_SRToInternalSR: "123", SvcQuestionsCollection_c: [], InternalDeadline_c: "2026-05-01"
});
posts[0].resolve({ ok: false, httpStatus: 403, error: "Synthetic save failure" });
await saving;
assert.equal(nodes.find(el => el.name === "title").value, "New internal SR");
assert.deepEqual(toasts, [{ message: "Synthetic save failure", isError: true }]);
const retry = submit();
posts[1].resolve({ ok: true, result: { SrNumber: "ISR1" } });
await retry;
assert.deepEqual(toasts.at(-1), { message: "Internal Service Request created successfully.", isError: false });
assert.ok(!h.walk(h.modal).includes(form));
});
test("internal SR uses Session without TokenRelay and reports unavailable iframe host", async () => {
const h = setup();
let renewals = 0;
const sent = [];
h.app.authSessionState.accessToken = "stale-token";
h.app.authTokenRelay.requestTokenRelay = async () => `fresh-${++renewals}`;
h.app.debugContentView.renderDebugPanel = () => {};
h.app.platformContentMessages.sendRuntimeMessage = async message => {
sent.push(message);
return { ok: false, httpStatus: 401, error: "Unauthorized" };
};
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{ SrId: "123", SrNumber: "SR123", OpportunityName_Id_c: { OptyNumber: "OP1" } }];
await h.app.opportunityCrmServiceRequests.load("synthetic", 1);
const badge = h.app.opportunityCrmServiceRequests.createBadge("OP1");
h.modal.append(badge);
badge.listeners.pointerenter();
await flush();
assert.equal(renewals, 0);
assert.equal(sent.length, 1);
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"));
});
function setup() {
const document = { activeElement: null, listeners: new Map() };
@@ -104,3 +210,45 @@ test("SR popover pins on click, closes on Escape/outside/scroll/reset and remove
assert.equal(panel(), undefined);
assert.equal(document.listeners.size, 0);
});
test("SR tooltip loads nested details with copy/link and empty text, discarding stale responses", async () => {
const h = setup();
const calls = [];
h.app.authSessionState.accessToken = "crm-token";
h.app.authTokenRelay.requestTokenRelay = async () => "fresh-crm-token";
h.app.debugContentView.renderDebugPanel = () => {};
h.app.platformContentMessages.sendRuntimeMessage = message => new Promise(resolve => calls.push({ message, resolve }));
await h.app.opportunityCrmServiceRequests.load("synthetic", 1);
const badge = h.app.opportunityCrmServiceRequests.createBadge("OP1");
h.modal.append(badge);
badge.listeners.pointerenter();
await flush();
assert.equal(calls.length, 2);
assert.equal(calls[0].message.token, undefined);
assert.equal(calls[0].message.srId, "NOT-THE-URL-ID");
assert.ok(h.walk(h.panel()).some(node => node.textContent === "Loading SR details..."));
calls[0].resolve({ ok: true, items: [{ SrId: 99, SrNumber: "SR &child", Title: "<child>", ServiceNew_c: "Queue",
LastUpdateDate: "2026-05-14T00:00:00.000+0000", OpenDate: "2026-04-01T23:30:00.000+0000" }] });
calls[1].resolve({ ok: true, items: [] });
await flush();
const nodes = h.walk(h.panel());
const link = nodes.find(node => node.tag === "a" && node.textContent === "SR &child");
assert.equal(link.parent.children[0].textContent, "Internal SR Number:");
assert.ok(link.href.endsWith("srNumber=SR%20%26child&srId=99"));
assert.equal(link.parent.children[2].title, "Copy SR number");
assert.ok(nodes.some(node => node.textContent.includes("Service Queue: Queue")));
assert.ok(nodes.some(node => node.textContent.includes("LastUpdateDate: 14/05/2026")));
assert.ok(nodes.some(node => node.textContent.includes("OpenDate: 01/04/2026")));
assert.ok(nodes.some(node => node.textContent === "No further details."));
h.app.crmSrPopover?.close();
h.app.opportunityCrmServiceRequests.reset();
badge.listeners.pointerenter();
const oldPanel = h.panel();
await flush();
h.app.opportunityCrmServiceRequests.reset();
calls[2].resolve({ ok: true, items: [{ SrNumber: "STALE" }] });
calls[3].resolve({ ok: false, error: "Forbidden" });
await flush();
assert.equal(h.panel(), undefined);
assert.ok(!h.walk(oldPanel).some(node => node.textContent === "STALE"));
});

View File

@@ -5,7 +5,7 @@ const { loadApplication } = require("./support/application.cjs");
const type = "opportunitiesExtension.requestCurrentUser";
for (const family of ["chrome", "firefox"]) {
test(`${family}: current user GET uses SPA cookies and discards identity token before messaging/debug`, async () => {
test(`${family}: current user GET preserves identity token only in debug data for explicit reveal`, async () => {
const calls = [];
const app = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
@@ -20,12 +20,16 @@ for (const family of ["chrome", "firefox"]) {
assert.equal(calls[0].options.cache, "no-store");
assert.equal(result.debugRequests[0].name, "requestCurrentUser");
assert.equal(result.debugRequests[0].httpStatus, 200);
assert.doesNotMatch(JSON.stringify(result), /synthetic-secret/);
assert.equal(JSON.parse(result.debugRequests[0].responseBody).token, "synthetic-secret");
const content = loadApplication("content");
assert.doesNotMatch(content.debugContentLog.formatDebugBody(result.debugRequests[0].responseBody, "requestCurrentUser"), /synthetic-secret/);
content.debugState.debugSecretsVisible = true;
assert.match(content.debugContentLog.formatDebugBody(result.debugRequests[0].responseBody, "requestCurrentUser"), /synthetic-secret/);
assert.equal(result.token, undefined);
});
}
test("identity failures and malformed roles remain explicit and never expose response tokens", async () => {
test("identity failures retain diagnostics but hide response tokens by default", async () => {
for (const fetch of [
async () => new Response("synthetic-secret<html>fallback</html>"),
async () => response({ token: "synthetic-secret", user: {} }),
@@ -37,7 +41,8 @@ test("identity failures and malformed roles remain explicit and never expose res
const result = await app.dispatch({ type });
assert.equal(result.ok, false);
assert.equal(result.debugRequests[0].status, "failed");
assert.doesNotMatch(JSON.stringify(result), /synthetic-secret/);
const content = loadApplication("content");
assert.doesNotMatch(content.debugContentLog.formatDebugBody(result.debugRequests[0].responseBody, "requestCurrentUser"), /synthetic-secret/);
}
});
@@ -73,10 +78,12 @@ for (const [roles, allowed] of [[ ["SALES_REP", "SIMPLE_USER"], false ], [ ["SOL
assert.equal(same, app.currentUser.ensure());
finish({ ok: true, user: { roles } });
await first;
assert.deepEqual(calls, allowed ? [type, "tokenrelay", "crm", "opportunities"] : [type, "tokenrelay", "opportunities"]);
assert.deepEqual(calls, [type, "tokenrelay", "opportunities"]);
app.productsContentState.currentProductsRequestId = "products-cycle";
app.productActivities.begin("products-cycle", "synthetic");
const products = { items: [{ RevnId: "123" }] };
app.productsContentState.opptyProductsByNumber.set("OP", products);
app.opportunityWorkloadActions.loadProducts(products);
app.productActivities.complete("products-cycle");
await new Promise(resolve => setImmediate(resolve));
assert.equal(calls.includes("opportunitiesExtension.requestWlActions"), allowed);
if (!allowed) {

View File

@@ -0,0 +1,48 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
class Element {
constructor() { this.children = []; this.attributes = {}; }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text; }
append(...nodes) { this.children.push(...nodes); }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener() {}
}
test("Total Opportunities displays filtered/overall and updates with filters and Reference data", () => {
const dashboard = new Element(), table = new Element();
const app = loadApplication("content", {
document: {
createElement: () => new Element(),
getElementById: id => id === "opportunities-extension-stage-dashboard" ? dashboard
: id === "opportunities-extension-table" ? table : null
},
performance: { now: () => 0 }, requestAnimationFrame: tick => tick(480)
});
// Use real table/dashboard coordination without constructing unrelated row controls.
app.opportunitiesTableState.opportunitiesTableState.status = "loading";
const items = Array.from({ length: 26 }, () => ({ ForecastGroup_c: "FORECAST" }));
app.opportunitiesTableState.opportunitiesTableState.items = items;
let filtered = items.slice(0, 11);
app.opportunitiesFilters.getSortedOpportunities = () => filtered;
const value = () => dashboard.children[0].children[1];
app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(value().textContent, "11/26");
assert.equal(value().attributes["aria-label"], "11 filtered opportunities out of 26 total");
filtered = [];
app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(value().textContent, "0/26");
filtered = items;
app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(value().textContent, "26/26");
app.opportunitiesTableState.opportunitiesTableState.items = items.slice(0, 3);
filtered = items.slice(0, 2);
app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(value().textContent, "2/3");
app.opportunitiesTableState.opportunitiesTableState.items = [];
filtered = [];
app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(value().textContent, "0/0");
});

View File

@@ -20,23 +20,56 @@ function fixture() {
return { ...dom, app, add, nodes, tab, ids, select };
}
test("large response previews stay valid, formatted, masked and colored in both runtimes", () => {
test("catalog and identity secrets are revealed exactly by the eye button and hidden again", () => {
const h = fixture();
const body = JSON.stringify({ result: "catalog-exact-secret", detail: "Preserved" });
h.app.debugContentLog.appendBackgroundRequestLog([{
name: "requestSrTokenCatalog", method: "POST", startedAt: new Date().toISOString(), status: "success",
responseBody: body, requestHeaders: { "vb-proxy-header-x-oracle-ectoken": "header-exact-secret", "x-appbuilder-client-id": "client-exact-secret" }
}, {
name: "requestCurrentUser", method: "GET", startedAt: new Date().toISOString(), status: "success",
responseBody: JSON.stringify({ user: { roles: ["SIMPLE_USER"] }, token: "identity-exact-secret" })
}]);
const text = node => node.text + node.children.map(text).join("");
const toggle = () => h.nodes().find(node => node.getAttribute("data-debug-focus") === "visibility").listeners.click();
assert.doesNotMatch(text(h.panel), /exact-secret/);
toggle();
for (const secret of ["catalog-exact-secret", "header-exact-secret", "client-exact-secret", "identity-exact-secret"]) assert.ok(text(h.panel).includes(secret));
toggle();
assert.doesNotMatch(text(h.panel), /exact-secret/);
assert.equal(h.app.debugState.requestLog[0].responseBody, body, "display masking never overwrites originals");
});
test("request summaries show duration after status, including zero, failure and pending", () => {
const h = fixture();
h.add("duration", "requestOpportunities", 1);
const entry = h.app.debugState.requestLog.at(-1);
for (const [durationMs, status, expected] of [[0, "success", "0 ms"], [325, "success", "325 ms"], [1540, "failed", "1.54 s"], [null, "pending", "In progress"], [null, "failed", "N/D"]]) {
Object.assign(entry, { durationMs, status });
h.app.debugContentView.renderDebugPanel();
const meta = h.nodes().find(node => node.className === "opportunities-extension-debug-summary-meta");
assert.equal(meta.children.at(-1).textContent, expected);
assert.equal(meta.children.at(-2).className, "opportunities-extension-debug-status");
}
});
test("complete large responses stay formatted, masked and colored in both runtimes", () => {
const h = fixture();
const background = loadApplication("background");
const body = JSON.stringify({ items: Array.from({ length: 2000 }, (_, index) => ({
id: index, name: "Synthetic opportunity", access_token: "synthetic-secret",
uiaasHeader: { value: "synthetic-header-secret" }, owner: { name: "Synthetic owner" }
})) });
for (const [logger, limit] of [[h.app.debugContentLog, 12000], [background.httpBackgroundJson, 100000]]) {
for (const logger of [h.app.debugContentLog, background.httpBackgroundJson]) {
const preview = logger.createDebugBody(body);
assert.equal(preview.truncated, true);
assert.ok(preview.value.length <= limit);
assert.equal(preview.truncated, false);
assert.equal(preview.value, body);
const parsed = JSON.parse(preview.value);
assert.match(parsed.debugNotice, /Partial response/);
assert.ok(parsed.preview.items.length > 0);
assert.ok(parsed.preview.items.length < 2000);
assert.equal(parsed.items.length, 2000);
assert.equal(parsed.items.at(-1).id, 1999);
const formatted = h.app.debugContentLog.formatDebugBody(preview.value);
assert.match(formatted, /\n "debugNotice"/);
assert.match(formatted, /\n "items"/);
assert.match(formatted, /"id": 1999/);
assert.doesNotMatch(formatted, /synthetic-secret|synthetic-header-secret/);
const container = h.document.createElement("div");
h.app.debugContentView.appendDebugBlock(container, "Response Body", formatted);
@@ -45,7 +78,9 @@ test("large response previews stay valid, formatted, masked and colored in both
assert.match(h.app.debugContentLog.formatDebugBody(preview.value), /synthetic-secret/);
h.app.debugState.debugSecretsVisible = false;
assert.equal(logger.createDebugBody('{"ok":true}').value, '{"ok":true}');
assert.match(logger.createDebugBody("<html>" + "x".repeat(limit + 1000)).value, /truncated/);
const html = "<html>" + "x".repeat(150000) + "</html>";
assert.equal(logger.createDebugBody(html).value, html);
assert.equal(logger.createDebugBody("").value, "(empty response body)");
}
});
@@ -56,7 +91,7 @@ test("request categories cover every current family and unknown requests stay vi
requestConsumerTokenService: "authentication", requestCustomerTokenService: "authentication",
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests", requestSRInfo: "serviceRequests",
requestConsumptionPlanDetail: "consumption",
requestRatecardByPlan: "consumption",
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "consumption",
@@ -256,7 +291,7 @@ test("full-page debug CSS defines responsive bounds and readable semantic colors
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
const root = path.join(__dirname, "..");
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"]
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));

View File

@@ -1,13 +1,15 @@
(() => {
const listeners = [];
const storage = new Map();
const internalSrPreview = new URLSearchParams(location.search).has("internal-sr");
const opportunities = Array.from({ length: 25 }, (_, index) => ({
OptyNumber: `TEST${index + 1}`,
OptyId: 1000 + index,
Name: `Synthetic opportunity ${index + 1} - Database migration`,
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: "test-party", PartyNumber: "test-account" },
CustomerAccount: { PartyUniqueName: "Example public sector customer (Example City, BR)", PartyId: internalSrPreview ? 789 : "test-party", PartyNumber: "test-account", Region_c_localizedValue: "LAD", PrimaryAddress: { CountryCode: "BR" } },
Owner: { PersonFirstName: "Example", PersonLastName: "Owner" },
PrimaryRevenue: { RevnAmountCurcyCode: "USD", RevnAmount: "25000", WinProb: 60 },
EffectiveDate: "2026-11-15T00:00:00Z",
EffectiveDate: index === 24 ? null : `2026-11-${String(index < 8 ? 15 : 1 + index).padStart(2, "0")}T00:00:00Z`,
LastUpdateDate: "2026-09-15T17:00:00Z",
ForecastGroup_c: ["SQL", "PIPELINE", "UPSIDE", "FORECAST", "WON"][index % 5],
StatusCode: ["OPEN", "CLOSED", "LOST", "WON", "WON_PENDING"][index % 5]
@@ -28,6 +30,11 @@
onMessage: { addListener: listener => listeners.push(listener) },
async sendMessage(message) {
const operation = message.type.split(".").at(-1);
if (operation === "requestPostInternalServiceRequest") return { ok: true, result: { SrNumber: "ISR123" }, debugRequests: [] };
if (operation === "requestCurrentUser") return { ok: true, user: { id: "example@example.invalid", name: "Example Owner", roles: internalSrPreview ? [] : ["SALES_REP"] }, debugRequests: [] };
if (["prepareSrSession", "requestInternalServiceRequest", "requestWlActions"].includes(operation)) return { ok: true, items: [], actions: [], debugRequests: [] };
if (operation === "requestLookupTypePlatform") return { ok: true, items: [{ code: "OCI", name: "Oracle Cloud Infrastructure" }, { code: "MULTI", name: "Multicloud" }], debugRequests: [] };
if (operation === "requestInternalSrServices") return { ok: true, items: [{ Id: "123", RecordName: "Architecture review", Description_c: "Review the customer architecture, workloads and technical requirements." }, { Id: "124", RecordName: "Technical workshop", Description_c: "Prepare and deliver a technical session for the customer team." }], debugRequests: [] };
if (operation === "getXsrfToken") return { ok: true, token: "synthetic-xsrf", cookieName: "XSRF-TOKEN-TEST" };
if (operation === "requestConsumerTokenService") return { ok: true, authorizations, debugRequests: [] };
if (operation === "requestOpptyProducts") {
@@ -64,7 +71,8 @@
let body;
if (url.pathname === "/fscmRestApi/tokenrelay") body = { access_token: "synthetic-fusion-token", expires_in: 3600 };
else if (url.pathname.endsWith("custom-actions/queries")) {
const { offset, limit } = JSON.parse(options.body);
const { offset, limit, entity } = JSON.parse(options.body);
if (entity === "CRMServiceRequest") return new Response(JSON.stringify({ items: [{ SrId: "123456", SrNumber: "SR123456", Title: "Example parent SR", PrimaryPillarNew_c: "Tech_Cloud_Infrastructure", CustomerDeadline_c: "2026-11-30T00:00:00.000+0000", ReportedHours_c: 4, OpportunityName_Id_c: { OptyNumber: "TEST1" } }], hasMore: false }), { status: 200 });
body = { items: opportunities.slice(offset, offset + limit), totalResults: opportunities.length, count: Math.min(limit, opportunities.length - offset), hasMore: offset + limit < opportunities.length, offset, limit };
} else if (url.pathname.endsWith("summarizations/generate")) body = { summaryText: "Synthetic AI summary.\nText is displayed safely: <script>never executed</script>." };
else throw new Error(`Unexpected synthetic fetch: ${url.pathname}`);

View File

@@ -0,0 +1,145 @@
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 };
}
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);
});
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("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("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);
});

View File

@@ -0,0 +1,227 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { test } = require("node:test");
const factory = new vm.Script(`${fs.readFileSync(path.join(__dirname, "../src/features/opportunities/calendar.js"), "utf8")}\ncreateOpportunityCalendar;`).runInNewContext();
const plain = value => JSON.parse(JSON.stringify(value));
const opportunity = (number, date) => ({ OptyNumber: number, Name: `<Opportunity ${number}>`, EffectiveDate: date,
ForecastGroup_c: "FORECAST", StatusCode: "OPEN", CustomerAccount: { PartyUniqueName: "Customer" } });
function setup(items = [opportunity("A", "2026-11-15")]) {
let document;
class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.text = ""; this.isConnected = true; }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
append(...nodes) { for (const child of nodes) { child.parent = this; this.children.push(child); } }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { document.activeElement = this; }
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
click() { this.listeners.click?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
}
const host = new Element("host");
document = { createElement: tag => new Element(tag), getElementById: () => host };
const state = { status: "ready", items };
const h = { state, filtered: items, version: 1, instances: [], loads: 0, host, document };
class Calendar {
constructor(element, options) { this.element = element; this.options = options; this.events = options.events; this.dates = []; h.instances.push(this); }
render() { this.rendered = true; }
destroy() { this.destroyed = true; }
updateSize() { this.resized = true; }
batchRendering(fn) { fn(); }
gotoDate(date) { this.dates.push(date); }
removeAllEvents() { this.events = []; }
addEventSource(events) { this.events = events; }
}
h.api = factory({ environment: { document }, loadLibrary: () => { h.loads++; return { Calendar }; },
getState: () => state, getItems: () => h.filtered, getVersion: () => h.version,
formatRevenue: () => "$100", formatDate: value => value.slice(0, 10), getOwnerName: () => "Owner",
createDetailLink: (text, href) => { const a = new Element("a"); a.textContent = text; a.href = href; return a; },
createCopyButton: () => new Element("button"), createCloseIcon: () => new Element("svg"),
createViewIcon: view => { const icon = new Element("svg"); icon.view = view; return icon; },
createStageBadge: () => new Element("span"), createStatusBadge: () => new Element("span"),
opportunityUrl: "https://example.invalid/?puid=", modalId: "modal", closeSrPopover() {} });
h.body = new Element("body"); h.table = new Element("table-surface"); host.append(h.body);
const walk = element => [element, ...element.children.flatMap(walk)];
h.nodes = () => walk(host);
h.byClass = name => h.nodes().find(element => element.className === name);
h.mount = () => h.api.mount(h.body, h.table);
h.activate = () => { h.mount(); h.api.selectView("calendar"); return h.instances[0]; };
return h;
}
test("close dates retain the API day across offsets and reject invalid dates", () => {
const { api } = setup();
for (const date of ["2026-11-15", "2026-11-15T00:00:00Z", "2026-11-15T23:00:00-05:00"]) assert.equal(api.closeDate(date), "2026-11-15");
assert.equal(api.closeDate("2028-02-29"), "2028-02-29");
for (const value of [null, undefined, "", "2026-02-29", "2026-13-01", "2026-04-31", "15/11/2026", 123]) assert.equal(api.closeDate(value), null);
});
test("fiscal week labels use June-May years and Sunday weeks containing June 1", () => {
const { api } = setup();
for (const [day, expected] of [
["2026-05-24", "FY26-Q4-W52"], ["2026-05-31", "FY27-Q1-W1"],
["2026-06-01", "FY27-Q1-W1"], ["2026-06-07", "FY27-Q1-W2"],
["2026-09-06", "FY27-Q2-W15"], ["2026-10-04", "FY27-Q2-W19"],
["2026-11-15", "FY27-Q2-W25"], ["2026-12-06", "FY27-Q3-W28"],
["2027-03-07", "FY27-Q4-W41"], ["2027-05-30", "FY28-Q1-W1"],
["2028-02-27", "FY28-Q3-W40"], ["2028-06-04", "FY29-Q1-W2"]
]) assert.equal(api.fiscalWeekLabel(new Date(`${day}T00:00:00Z`)), expected, day);
});
test("daily count badges follow filters and are cleaned up when cells unmount", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-11-15"), opportunity("C", "2026-11-15")]);
const calendar = h.activate();
const frame = h.document.createElement("div");
const el = { querySelector: selector => { assert.equal(selector, ".fc-daygrid-day-frame"); return frame; } };
calendar.options.dayCellDidMount({ el, date: new Date(2026, 10, 15) });
const badge = frame.children[0];
assert.equal(badge.textContent, "3");
assert.equal(badge.hidden, false);
assert.equal(badge.attributes["aria-label"], "3 oportunidades");
h.api.render([h.state.items[0]], h.state);
assert.equal(badge.textContent, "1");
assert.equal(badge.attributes["aria-label"], "1 oportunidade");
h.api.render([], h.state);
assert.equal(badge.textContent, "0");
assert.equal(badge.attributes["data-empty"], "true");
assert.equal(badge.hidden, true);
h.api.render(h.state.items, h.state);
assert.equal(badge.hidden, false);
assert.equal(badge.textContent, "3");
calendar.options.dayCellWillUnmount({ el });
assert.equal(frame.children.length, 0);
});
test("calendar events use EffectiveDate, all-day semantics and literal untrusted names", () => {
const { api } = setup();
const events = plain(api.eventsFor([opportunity("A", "2026-11-15"), opportunity("B", null)]));
assert.equal(events.length, 1);
assert.equal(events[0].start, "2026-11-15");
assert.equal(events[0].allDay, true);
assert.equal(events[0].title, "<Opportunity A>");
assert.deepEqual(events[0].classNames, ["opportunities-calendar-stage-FORECAST"]);
});
test("initial calendar date selects the next close date, last past date or today", () => {
const { api } = setup();
const now = new Date(2026, 9, 5);
assert.equal(api.initialDate([opportunity("A", "2026-11-15"), opportunity("B", "2026-10-12")], now), "2026-10-12");
assert.equal(api.initialDate([opportunity("A", "2026-09-01"), opportunity("B", "2026-10-01")], now), "2026-10-01");
assert.equal(api.initialDate([opportunity("A", null)], now), "2026-10-05");
});
test("overview is the default; tabs lazily initialize and reuse a monthly/weekly calendar", () => {
const h = setup(); h.mount();
const surface = h.byClass("opportunities-extension-calendar");
assert.equal(h.table.hidden, false); assert.equal(surface.hidden, true); assert.equal(h.loads, 0);
const tabs = h.nodes().filter(element => element.attributes.role === "tab");
assert.deepEqual(tabs.map(tab => tab.children[0].view), ["overview", "calendar"]);
assert.deepEqual(tabs.map(tab => tab.children[1].textContent), ["Vis\u00e3o geral", "Calend\u00e1rio"]);
assert.equal(tabs[0].attributes["aria-selected"], "true");
tabs[1].click();
const instance = h.instances[0];
assert.equal(h.table.hidden, true); assert.equal(surface.hidden, false);
assert.equal(instance.options.initialView, "dayGridMonth");
assert.equal(instance.options.height, "100%");
assert.equal(instance.options.expandRows, true);
assert.equal(instance.options.headerToolbar.right, "dayGridMonth,dayGridWeek");
assert.equal(instance.options.editable, false);
assert.equal(instance.options.dayMaxEvents, 2);
assert.equal(instance.options.moreLinkText(7), "... +7");
assert.equal(instance.options.weekNumbers, true);
tabs[0].click(); tabs[1].click();
assert.equal(h.loads, 1); assert.equal(h.instances.length, 1); assert.equal(instance.resized, true);
tabs[1].listeners.keydown({ key: "Home", preventDefault() {} });
assert.equal(h.table.hidden, false); assert.equal(h.document.activeElement, tabs[0]);
});
test("responsive event limit reserves room for every week and the overflow control", () => {
const { api } = setup();
assert.equal(api.visibleEventLimit(240, 6, 21), 0);
assert.equal(api.visibleEventLimit(500, 5, 21), 1);
assert.equal(api.visibleEventLimit(800, 5, 21), 2);
assert.equal(api.visibleEventLimit(500, 1, 21), 2);
assert.equal(api.visibleEventLimit(0, 6, 21), 0);
});
test("calendar follows filtered data; only a new Reference request changes its date", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-12-12")]);
const calendar = h.activate();
h.filtered = [h.state.items[1]]; h.api.render(h.filtered, h.state);
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["B"]);
assert.equal(calendar.dates.length, 0);
h.version++; h.state.items = [opportunity("C", "2027-02-20")]; h.filtered = h.state.items;
h.api.render(h.filtered, h.state);
assert.deepEqual(calendar.dates, ["2027-02-20"]);
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["C"]);
h.filtered = []; h.api.render(h.filtered, h.state);
assert.equal(calendar.events.length, 0);
assert.equal(h.byClass("opportunities-calendar-message").textContent, "No opportunities found.");
});
test("undated opportunities remain visible and open read-only details", () => {
const h = setup([opportunity("A", null)]); const calendar = h.activate();
assert.equal(calendar.events.length, 0);
const undated = h.byClass("opportunities-calendar-undated");
assert.equal(undated.hidden, false); undated.children[1].click();
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Owner", "Close Date", "Amount"]);
assert.equal(h.nodes().find(node => node.tag === "a").href, "https://example.invalid/?puid=A");
assert.ok(h.nodes().some(node => node.tag === "dd" && node.textContent === "N/D"));
assert.equal(h.api.closeDetails(), true);
assert.equal(h.document.activeElement, undated.children[1]);
});
test("event details close on changed data, loading/error hide stale calendar content", () => {
const h = setup(); const calendar = h.activate();
calendar.options.eventClick({ event: { extendedProps: { item: h.state.items[0] } }, el: h.table, jsEvent: { preventDefault() {} } });
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
h.api.render([], h.state);
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
for (const status of ["loading", "error", "empty"]) {
h.api.render([], { status, items: [], message: "State message" });
assert.equal(h.byClass("opportunities-calendar-grid").hidden, true);
assert.equal(h.byClass("opportunities-calendar-undated").hidden, true);
assert.equal(h.byClass("opportunities-calendar-message").textContent, "State message");
}
});
test("overflow uses our dialog instead of the FullCalendar offsetParent-dependent popover", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-11-15"), opportunity("C", "2026-11-15")]);
const calendar = h.activate();
const trigger = h.document.createElement("a");
const allSegs = calendar.events.map(event => ({ event }));
const open = () => calendar.options.moreLinkClick({ date: new Date("2026-11-15T00:00:00Z"), allSegs,
jsEvent: { currentTarget: trigger, preventDefault() {} } });
assert.equal(open(), true, "truthy non-string result suppresses vendor popover");
const list = h.byClass("opportunities-calendar-day-list");
assert.equal(list.children.length, 3);
assert.ok(list.children.every(button => button.children.length === 2 && button.children[1].textContent.includes(" | FORECAST | $100")));
list.children[2].click();
assert.ok(h.nodes().some(node => node.tag === "h2" && node.textContent === "<Opportunity C>"));
h.api.closeDetails();
assert.equal(h.document.activeElement, trigger);
open(); h.api.selectView("overview");
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
h.api.selectView("calendar"); open();
const dialog = h.nodes().find(node => node.attributes.role === "dialog");
dialog.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
});
test("calendar events show their stage and handle missing stage values", () => {
const h = setup();
const calendar = h.activate();
for (const [value, label] of [["FORECAST", "FORECAST"], ["UPSIDE", "UPSIDE"], [null, "N/D"]]) {
const item = { ...h.state.items[0], ForecastGroup_c: value };
const { domNodes } = calendar.options.eventContent({ event: { title: item.Name, extendedProps: { item } } });
assert.equal(domNodes[0].children.length, 2);
assert.equal(domNodes[0].children[0].textContent, item.Name);
assert.equal(domNodes[0].children[1].textContent, `A | ${label} | $100`);
assert.equal(domNodes[0].children[1].title, `A | ${label} | $100`);
}
});

View File

@@ -59,6 +59,7 @@ test("ten columns retain their links and put product/customer controls below the
assert.ok(name.children[0].href.endsWith("TEST-1"));
assert.equal(name.children[1].children[0].className, "opportunities-extension-products-count");
assert.equal(name.children[1].children[0].textContent, "1");
assert.equal(name.children[1].children[0].title, "1 product");
assert.equal(customer.children[0].textContent, "Synthetic customer");
assert.ok(customer.children[0].href.endsWith("party%2Fid"));
assert.equal(customer.children[1].children[0].title, "View customer consumption");
@@ -69,10 +70,11 @@ test("ten columns retain their links and put product/customer controls below the
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
});
test("desktop filter widths fit Reference content and allocate free space to Customer and Owner", () => {
test("all four filter fields share available width dynamically with responsive wrapping", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
assert.match(css, /grid-template-columns: max-content minmax\(110px, 1fr\) minmax\(110px, 1fr\) minmax\(145px, 180px\) max-content max-content/);
assert.match(css, /@media \(min-width: 1151px\)[\s\S]*?\.opportunities-extension-field select\s*\{\s*width: max-content;/);
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\) max-content max-content;/);
assert.match(css, /grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\);/);
assert.match(css, /@media \(max-width: 1150px\)/);
});
@@ -81,8 +83,8 @@ test("ramp months preserve zero and flag missing values beside the product count
const badge = () => findClass(h.cell("name"), "opportunities-extension-products-ramp");
assert.equal(badge(), undefined);
for (const [values, expected] of [
[[], null], [[null], "RM N/D"], [[null, undefined, "", " "], "RM N/D"], [[6], "RM 6"],
[[0], "RM 0"], [[0, "3", null, " 6 ", 3], "RM 0,3,N/D,6,3"]
[[], null], [[null], "N/D"], [[null, undefined, "", " "], "N/D"], [[6], "6"],
[[0], "0"], [[0, "3", null, " 6 ", 3], "0 | 3 | N/D | 6 | 3"]
]) {
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
@@ -92,10 +94,13 @@ test("ramp months preserve zero and flag missing values beside the product count
else {
assert.equal(badge().textContent, expected);
assert.equal(badge().attributes["data-missing"], String(expected.includes("N/D")));
assert.match(badge().title, /^Ramp Months:/);
assert.equal(badge().title, `Ramp Months: ${expected.split(" | ").join(", ")}`);
assert.equal(badge().attributes["aria-label"], badge().title);
assert.equal(h.cell("name").children[0].children[1].children[1], badge());
}
assert.equal(findClass(h.cell("name"), "opportunities-extension-products-count").textContent, String(values.length));
const count = findClass(h.cell("name"), "opportunities-extension-products-count");
assert.equal(count.textContent, String(values.length));
assert.equal(count.title, `${values.length} product${values.length === 1 ? "" : "s"}`);
}
});
@@ -117,6 +122,33 @@ test("products remain expandable with AI summary and a ten-column detail row aft
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
});
test("product labels support multiple digits without changing their tooltip", () => {
const h = setup();
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: Array.from({ length: 12 }, () => ({})) });
h.app.opportunitiesTable.renderOpportunitiesTable();
const count = findClass(h.cell("name"), "opportunities-extension-products-count");
assert.equal(count.textContent, "12");
assert.equal(count.title, "12 products");
assert.equal(count.attributes["aria-label"], "12 products for opportunity TEST-1");
});
test("opportunity badges share height, typography and alignment including the Action plus button", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/styles.css"), "utf8");
const shared = css.match(/\.opportunities-extension-cell-actions > \.opportunities-extension-products-count,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-products-ramp,\s*\.opportunities-extension-cell-actions > \.opportunities-extension-crm-sr-badge\s*\{([^}]+)\}/);
assert.ok(shared, "P, R, SR and Act share one scoped badge rule");
for (const declaration of ["display: inline-flex;", "align-items: center;", "justify-content: flex-start;",
"box-sizing: border-box;", "height: 20px;", "min-height: 20px;", "padding: 0 6px;", "margin: 0;",
"border-radius: 8px;", "font-family: inherit;", "font-size: 10px;", "font-weight: 600;", "line-height: 16px;"]) {
assert.ok(shared[1].includes(declaration), declaration);
}
const plus = css.match(/\.opportunities-extension-add-action\s*\{([^}]+)\}/)[1];
assert.match(plus, /height: 16px;/);
assert.match(plus, /align-items: center;/);
assert.match(plus, /font: inherit;/);
const actions = fs.readFileSync(path.join(__dirname, "../src/features/opportunities/workload-actions.js"), "utf8");
assert.match(actions, /badge.className = "opportunities-extension-crm-sr-badge opportunities-extension-wl-actions-badge"/);
});
test("CRM SR badge follows Ramp Months and remains visible while product data is pending", async () => {
const h = setup();
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{
@@ -124,11 +156,11 @@ test("CRM SR badge follows Ramp Months and remains visible while product data is
}];
await h.app.opportunityCrmServiceRequests.load("synthetic", h.app.opportunitiesQueryState.periodRequestVersion);
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "\u2705 SR");
assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "0h");
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
h.app.opportunitiesTable.renderOpportunitiesTable();
const actions = h.cell("name").children[0].children[1];
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "RM 3", "\u2705 SR", "Act ?"]);
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "3", "0h", "?"]);
findClass(h.cell("name"), "opportunities-extension-products-count").click();
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
});
@@ -210,7 +242,9 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.match(css, /flex: 0 0 24px/);
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "");
const tableRules = css.replace(/[^{}]*\.opportunities-extension-products-ramp\s*\{[^}]*\}/g, "")
.replace(/[^{}]*\.opportunities-extension-title-row[^{}]*\{[^}]*\}/g, "")
.replace(/[^{}]*\.opportunities-extension-sr-confirm-dialog\s*\{[^}]*\}/g, "");
assert.doesNotMatch(tableRules, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
assert.match(css, /\.opportunities-extension-products-ramp\s*\{[^}]*background: #f0e8f7/);
assert.match(css, /\[data-theme="dark"\] \.opportunities-extension-products-ramp\s*\{[^}]*color: #ead5fc/);
@@ -220,6 +254,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.ok(remaining > 0);
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
}
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"].map(read).join("\n");
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});

View File

@@ -0,0 +1,201 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
for (const first of ["SR", "Actions"]) {
test(`Last update waits for every background call when ${first} finishes first`, async () => {
const h = setup();
const sr = deferred(), actions = deferred();
let renders = 0;
h.app.productsContentJobs.renderProductsProgress = () => { renders += 1; };
h.app.productsContentState.productsLastCachedAt = 100;
h.app.opportunityCrmServiceRequests.load = () => sr.promise;
h.app.opportunityWorkloadActions.loadProducts = () => actions.promise;
const id = h.start();
h.progress(id, { state: "running", optyNumber: "A", items: [{ RevnId: "1" }], cachedAt: 200 });
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
h.progress(id, { state: "complete" });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
(first === "SR" ? sr : actions).resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
(first === "SR" ? actions : sr).resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
assert.equal(renders, 1, "publish the completed cycle once");
});
}
test("Last update ignores superseded cycles and settles failures without changing the cache timestamp", async () => {
const h = setup();
const old = deferred(), current = deferred();
h.app.productsContentState.productsLastCachedAt = 100;
h.app.opportunityCrmServiceRequests.load = () => old.promise;
const first = h.start();
h.progress(first, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
h.app.opportunityCrmServiceRequests.load = () => current.promise;
const second = h.start();
h.progress(second, { state: "complete", optyNumber: "A", items: [], cachedAt: 300 });
old.resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
current.reject(new Error("Synthetic failure"));
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
assert.equal(h.calls.includes("empty products"), false, "SR failure blocks Actions");
});
test("Last update waits for cache restoration and ignores a superseded Reference", async () => {
const h = setup();
const restore = deferred();
h.app.productsContentState.productsLastCachedAt = 100;
h.app.opportunityCrmServiceRequests.load = () => restore.promise;
const id = h.start();
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
h.app.opportunitiesQueryState.periodRequestVersion += 1;
restore.resolve();
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 100);
const next = h.start();
h.progress(next, { state: "complete", optyNumber: "A", items: [], cachedAt: 200, fromCache: true });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
});
test("Last update completes for SALES_REP without activity requests", async () => {
const h = setup();
h.app.opportunityCrmServiceRequests.load = () => undefined;
h.app.opportunityWorkloadActions.loadProducts = () => undefined;
const id = h.start();
h.progress(id, { state: "complete", optyNumber: "A", items: [], cachedAt: 200 });
await flush();
assert.equal(h.app.productsContentState.productsLastCachedAt, 200);
});
function setup() {
let listener;
const app = loadApplication("content", {
document: { getElementById: () => null }, window: { requestAnimationFrame() {} },
chrome: { runtime: { onMessage: { addListener(fn) { listener = fn; } } } }
});
const calls = [];
app.opportunityCrmServiceRequests.reset = () => calls.push("reset SR");
app.opportunityWorkloadActions.reset = () => calls.push("reset Act");
app.opportunityCrmServiceRequests.load = async (token, version, options) => { if (!options?.cacheOnly) calls.push(`SR ${token}`); };
app.opportunityWorkloadActions.loadProducts = (products, refresh, options) => { if (!options?.cacheOnly) calls.push(products.items[0]?.RevnId || "empty products"); };
app.platformContentMessages.sendRuntimeMessage = async () => ({ ok: true });
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
function start(token = "token", numbers = ["A"]) {
app.productsContentJobs.requestOpptyProductsInBackground(numbers.map(OptyNumber => ({ OptyNumber })), token);
return app.productsContentState.currentProductsRequestId;
}
function progress(requestId, data) {
listener({ type: "opportunitiesExtension.opptyProductsProgress", requestId, ...data });
}
return { app, calls, start, progress };
}
test("follow-up calls wait for completion, deduplicate terminal messages and refresh on every new cycle", async () => {
const h = setup();
const first = h.start();
h.progress(first, { state: "running", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: false });
assert.deepEqual(h.calls, []);
h.progress(first, { state: "complete" });
h.progress(first, { state: "complete" });
await flush();
assert.deepEqual(h.calls, ["SR token", "123"]);
const second = h.start("new-token");
h.progress(first, { state: "complete", optyNumber: "B", items: [{ RevnId: "old" }] });
h.progress(second, { state: "running", optyNumber: "A", items: [{ RevnId: "123" }] });
assert.equal(h.calls.filter(value => value === "123").length, 1);
h.progress(second, { state: "complete" });
await flush();
assert.equal(h.calls.filter(value => value === "123").length, 2);
assert.ok(h.calls.includes("SR new-token"));
assert.equal(h.calls.includes("old"), false);
});
test("partial failures use successful products only; closed, failed and empty cycles have no follow-up", async () => {
const h = setup();
const first = h.start();
h.progress(first, { optyNumber: "A", items: [{ RevnId: "123" }] });
h.progress(first, { optyNumber: "B", items: [{ RevnId: "failed" }], error: "Synthetic failure" });
h.progress(first, { state: "complete-with-errors" });
await flush();
assert.equal(h.calls.includes("failed"), false);
assert.equal(h.calls.filter(value => value === "SR token").length, 1);
const second = h.start();
h.progress(second, { optyNumber: "A", error: "Synthetic failure" });
h.progress(second, { state: "complete-with-errors" });
const third = h.start();
h.progress(third, { state: "error" });
const fourth = h.start();
h.app.productsContentState.currentProductsRequestId = "";
h.progress(fourth, { state: "complete", optyNumber: "A", items: [{ RevnId: "closed" }] });
h.app.productsContentJobs.requestOpptyProductsInBackground([], "empty");
await flush();
assert.equal(h.calls.filter(value => value.startsWith("SR ")).length, 1);
assert.equal(h.calls.includes("closed"), false);
});
test("cached and mixed product cycles both allow activity cache misses to be fetched", async () => {
const h = setup();
const cached = h.start();
h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
h.progress(cached, { state: "complete" });
await flush();
assert.deepEqual(h.calls, ["SR token", "cached"]);
h.calls.length = 0;
const mixed = h.start("token", ["A", "B"]);
h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true });
h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false });
h.progress(mixed, { state: "complete" });
await flush();
assert.deepEqual(h.calls, ["SR token", "cached", "fresh"]);
});
test("activity scope includes hidden reference opportunities and excludes entries from other references", async () => {
const h = setup();
h.app.opportunitiesFilterState.tableSearch = "hide everything";
const id = h.start("token", ["A", "B"]);
h.progress(id, { optyNumber: "A", items: [{ RevnId: "1" }, { RevnId: "2" }] });
h.progress(id, { optyNumber: "B", items: [{ RevnId: "3" }] });
h.progress(id, { optyNumber: "OTHER_REFERENCE", items: [{ RevnId: "outside" }] });
h.progress(id, { state: "complete" });
await flush();
assert.deepEqual(h.calls, ["SR token", "1", "3"]);
assert.equal(h.calls.includes("outside"), false);
});
test("reopening an existing modal does not reload products or reset/query activities", async () => {
const modal = { hidden: true, querySelector: () => ({ focus() {} }) };
const app = loadApplication("content", {
window: {}, document: {
getElementById: id => id === "opportunities-extension-modal" ? modal : null,
documentElement: { classList: { add() {} } }, addEventListener() {}
}
});
app.contentStyles.ensureExtensionStyles = () => {};
app.currentUser.ensure = async () => true;
app.authContentSession.requestTokenRelayOnce = async () => "synthetic";
app.opportunitiesTable.renderOpportunitiesTable = () => {};
const calls = [];
app.productsContentJobs.requestOpptyProductsInBackground = () => calls.push("products");
app.opportunityCrmServiceRequests.load = () => calls.push("SR");
app.opportunityCrmServiceRequests.reset = () => calls.push("reset SR");
app.opportunityWorkloadActions.loadProducts = () => calls.push("Actions");
app.opportunityWorkloadActions.reset = () => calls.push("reset Actions");
app.contentModal.openOpportunitiesModal();
await flush();
assert.equal(modal.hidden, false);
assert.deepEqual(calls, []);
});

106
tests/refreshAll.test.cjs Normal file
View File

@@ -0,0 +1,106 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const { loadBackground, response } = require("./support/characterization.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
for (const family of ["chrome", "firefox"]) {
test(`${family}: invalidation detaches old product flights and prevents stale cache restoration`, async () => {
const pending = [];
const storage = { preferences: { keep: true } };
const app = loadBackground({ family, storage, fetch: () => new Promise(resolve => pending.push(resolve)) });
const message = { type: "opportunitiesExtension.requestOpptyProducts", accessToken: "synthetic", optyNumbers: ["A"], cacheMaxAgeMs: 86400000 };
const old = app.dispatch({ ...message, requestId: "old" });
await flush();
assert.equal(pending.length, 1);
const clear = await app.dispatch({ type: "opportunitiesExtension.invalidateProductsCache" });
assert.equal(clear.ok, true);
const fresh = app.dispatch({ ...message, requestId: "fresh" });
await flush();
assert.equal(pending.length, 2);
pending[1](response({ items: [{ RevnId: "new" }], count: 1, hasMore: false }));
await fresh;
pending[0](response({ items: [{ RevnId: "old" }], count: 1, hasMore: false }));
await old;
await app.dispatch({ ...message, requestId: "cached" });
assert.equal(pending.length, 2);
assert.equal(storage["opportunitiesExtension.opptyProductsCache.v1"].A.items[0].RevnId, "new");
assert.deepEqual(storage.preferences, { keep: true });
});
}
test("activity invalidation preserves unrelated storage and rejects late entries from old product timestamps", async () => {
const values = { preferences: "keep" };
const app = loadApplication("content", { browser: { storage: { local: {
get: async key => key === null ? { ...values } : { [key]: values[key] },
set: async data => Object.assign(values, data),
remove: async keys => keys.forEach(key => delete values[key])
} } } });
app.authSessionState.principal = "user@example.invalid";
const stamp = Date.now() - 1000;
await app.activityCache.write("sr", "all", stamp, []);
const oldKey = Object.keys(values).find(key => key.includes("activities.v1:"));
const oldEntry = values[oldKey];
await app.activityCache.invalidate();
assert.equal(values[oldKey], undefined);
assert.equal(values.preferences, "keep");
values[oldKey] = oldEntry;
assert.equal(await app.activityCache.read("sr", "all", stamp), null);
delete values[oldKey];
await app.activityCache.write("sr", "all", stamp, []);
assert.equal(values[oldKey], undefined);
});
function content() {
const app = loadApplication("content", { document: { createElement: () => ({ children: [], append(node) { this.children.push(node); }, listeners: {}, attributes: {}, addEventListener(key, fn) { this.listeners[key] = fn; }, setAttribute(key, value) { this.attributes[key] = value; } }) } });
app.contentIcons.createRefreshIcon = () => ({ tag: "svg" });
for (const [object, method] of [
["opportunityCrmServiceRequests", "reset"], ["opportunityWorkloadActions", "reset"],
["consumptionPlansView", "closeAccountPlansModal"], ["installBaseView", "closeInstallBaseModal"],
["serviceRequestsView", "closeServiceRequestsModal"], ["productsContentJobs", "renderProductsProgress"],
["opportunitiesTable", "setOpportunitiesTableState"]
]) app[object][method] = () => {};
return app;
}
test("Refresh all deduplicates clicks, clears memory before reload and preserves identity/preferences", async () => {
const app = content();
const calls = [];
let finish;
app.activityCache.invalidate = () => { calls.push("activities"); return new Promise(resolve => { finish = resolve; }); };
app.platformContentMessages.sendRuntimeMessage = async message => { calls.push(message.type); return { ok: true }; };
app.opportunitiesController.requestOpportunitiesForPeriod = async period => calls.push(period);
app.authSessionState.accessToken = "synthetic";
app.preferencesState.opptyProductsCacheHours = 48;
app.productsContentState.opptyProductsByNumber.set("A", {});
app.productsContentState.productsLastCachedAt = 100;
app.consumptionTrendState.consumptionTrendCache.set("A", {});
const first = app.refreshAll.refresh("Next Quarter");
assert.equal(app.refreshAll.refresh("Next Quarter"), first);
assert.equal(app.productsContentState.opptyProductsByNumber.size, 0);
assert.equal(app.productsContentState.productsLastCachedAt, 100, "keep previous update visible while refreshing");
assert.equal(app.consumptionTrendState.consumptionTrendCache.size, 0);
assert.deepEqual(calls, ["activities"]);
finish();
await first;
assert.deepEqual(calls, ["activities", "opportunitiesExtension.invalidateProductsCache", "Next Quarter"]);
assert.equal(app.authSessionState.accessToken, "synthetic");
assert.equal(app.preferencesState.opptyProductsCacheHours, 48);
});
test("Refresh all reports failures, does not reload on failed invalidation, and re-enables the button", async () => {
const app = content();
app.activityCache.invalidate = async () => { throw new Error("Synthetic storage failure"); };
let notified = "";
app.opportunitiesLinks.showCopyConfirmation = message => { notified = message; };
app.opportunitiesController.requestOpportunitiesForPeriod = () => assert.fail("must not reload");
const button = app.refreshAll.createButton(() => "Current Quarter");
const request = button.listeners.click();
assert.equal(button.disabled, true);
await request;
assert.equal(button.disabled, false);
assert.equal(button.title, "Refresh all");
assert.equal(button.attributes["aria-label"], "Refresh all");
assert.equal(button.children.length, 1);
assert.equal(button.children[0].tag, "svg");
assert.equal(button.attributes["aria-busy"], "false");
assert.equal(notified, "Synthetic storage failure");
});

391
tests/srCreate.test.cjs Normal file
View File

@@ -0,0 +1,391 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const { loadApplication } = require("./support/application.cjs");
function assertDebugVisibility(result, secret) {
const app = loadApplication("content");
const entries = result.debugRequests || [];
const render = () => entries.map(entry => app.debugContentLog.formatHeaderBlock(entry.requestHeaders)
+ app.debugContentLog.formatDebugBody(entry.responseBody, entry.name)).join("\n");
assert.ok(!render().includes(secret));
app.debugState.debugSecretsVisible = true;
assert.equal(render().includes(secret), JSON.stringify(entries).includes(secret));
}
const type = "opportunitiesExtension.requestPostServiceRequest";
const emailAddress = "signed.in+sr@example.invalid";
const internalType = "opportunitiesExtension.requestPostInternalServiceRequest";
const internalInput = () => ({ Title: "Internal request title", ServiceNew_Id_c: "404", ProblemDescription: "Form description",
CustomerDeadline_c: "2026-11-29", TechPlatformLookUp_c: "DATABASE", InternalDeadline_c: "2027-01-31",
ServiceRequest_Id_SRToInternalSR: "505" });
const parentInfo = () => ({ SrId: 505, SrNumber: "SR505", AccountPartyId: 100000693906507, OpportunityName_c: "A8YPN8",
OpportunityName_Id_c: 302959906052470, RegionText_c: "LAD", SvcHealth_c: "SVC_HEALTH_YELLOW", Language_c: "PORTUGUESE",
CountryText_c: "BR", PrimaryPillarNew_c: "Tech_Cloud_Platform", SecondaryPillarNew_c: "Tech_Cloud_Infrastructure", ServiceNew_Id_c: 300010753841648 });
for (const family of ["chrome", "firefox"]) {
test(`${family}: internal SR reads parent SRInfo, resolves user and posts form values with Session and observed client ID`, async () => {
const calls = [];
const h = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
if (options.method === "GET" && url.includes("/serviceRequests?")) return withClientId({ items: [parentInfo()] }, 200, "internal-client");
if (url.includes("/resourceUsers?")) return response({ resourcePartyId: 303 });
assert.ok(!url.includes("Catalogue_c"), "Internal SR must not use catalog signing");
return response({ SrNumber: "ISR1" }, 201);
} });
const input = { ...internalInput(), AccountPartyId: 1, OpportunityName_c: "STALE", OpportunityName_Id_c: 2, RegionText_c: "APAC",
SvcHealth_c: "SVC_HEALTH_GREEN", Language_c: "ENGLISH", CountryText_c: "US", PrimaryPillarNew_c: "STALE", SecondaryPillarNew_c: null, ReportedByPartyId: 999 };
const result = await h.dispatch({ type: internalType, emailAddress, payload: input });
assert.equal(result.ok, true);
assert.equal(calls.length, 3);
const info = new URL(calls[0].url);
assert.match(info.pathname, /SeaaS_SR_management.*\/oalsetCRMRestAPI\/.*resources\/latest\/serviceRequests$/);
assert.equal(info.searchParams.get("q"), "SrId=505");
assert.equal(info.searchParams.get("onlyData"), "true");
assert.equal(info.searchParams.get("orderBy"), "LastUpdateDate:desc");
assert.equal(info.searchParams.get("fields"), "PrimaryPillarNew_c,SecondaryPillarNew_c,SvcHealth_c,SrNumber,AccountPartyId,CountryText_c,Language_c,OpportunityName_Id_c,OpportunityName_c,RegionText_c,ServiceNew_Id_c");
const post = calls[2];
assert.equal(new URL(calls[1].url).searchParams.get("emailAddress"), emailAddress);
assert.match(post.url, /SeaaS_SR_management.*oalsetCRMRestAPICloudAccount.*resources\/latest\/serviceRequests$/);
assert.equal(post.options.method, "POST");
const { SrId, SrNumber, ...parent } = parentInfo();
assert.deepEqual(JSON.parse(post.options.body), { ...parent, ...internalInput(),
ServiceRequest_Id_SRToInternalSR: "505", ServiceNew_Id_c: 404,
InternalDeadline_c: "2027-01-31T00:00:00.000Z", ReportedByPartyId: 303, SvcQuestionsCollection_c: [] });
assert.ok(calls.every(call => call.options.headers.Authorization === "Session" && call.options.credentials === "include"));
const origin = "https://comcipapic-oalprod.integration.ocp.oraclecloud.com";
assert.deepEqual({ ...post.options.headers }, { Accept: "application/json", "Content-Type": "application/json",
Authorization: "Session", Origin: origin, "x-appbuilder-client-id": "internal-client" });
assertDebugVisibility(result, "internal-client");
assert.equal(calls[0].options.headers.Origin, undefined);
assert.equal(result.debugRequests[2].requestHeaders.Origin, origin);
assert.equal(h.headerRules.length, 1);
const { removeRuleIds, addRules } = h.headerRules[0];
assert.deepEqual(removeRuleIds, [7203]);
assert.equal(addRules.length, 1);
const rule = addRules[0];
assert.deepEqual(rule.action, { type: "modifyHeaders", requestHeaders: [{ header: "Origin", operation: "set", value: origin }] });
assert.deepEqual(rule.condition.initiatorDomains, ["synthetic-extension"]);
assert.deepEqual(rule.condition.requestMethods, ["post"]);
assert.deepEqual(rule.condition.resourceTypes, ["xmlhttprequest"]);
const pattern = new RegExp(rule.condition.regexFilter);
assert.ok(pattern.test(post.url));
assert.ok(!pattern.test(calls[0].url));
assert.ok(!pattern.test(post.url + "/other"));
assert.ok(!pattern.test(post.url + "?other=1"));
assert.deepEqual(result.debugRequests.map(entry => entry.name), ["requestSRInfo", "requestResourceUser", "requestPostInternalServiceRequest"]);
});
}
test("internal SR queries the current parent on every Save and reuses captured client ID after worker restart", async () => {
const sessionStorage = {};
const queries = [], posts = [];
const fetch = async (url, options) => {
if (url.includes("/serviceRequests?")) {
const id = Number(new URL(url).searchParams.get("q").slice("SrId=".length));
queries.push(id);
const parent = { ...parentInfo(), SrId: id, SecondaryPillarNew_c: null, ServiceNew_Id_c: id + 1000 };
if (id === 606) delete parent.ServiceNew_Id_c;
return id === 505 ? withClientId({ items: [parent] }, 200, "saved-internal-client") : response({ items: [parent] });
}
if (url.includes("/resourceUsers?")) return response({ resourcePartyId: 303 });
posts.push(options);
return response({ SrNumber: "ISR1" }, 201);
};
const h = loadBackground({ fetch, sessionStorage });
assert.equal((await h.dispatch({ type: internalType, emailAddress, payload: internalInput() })).ok, true);
const restarted = loadBackground({ fetch, sessionStorage });
assert.equal((await restarted.dispatch({ type: internalType, emailAddress,
payload: { ...internalInput(), ServiceRequest_Id_SRToInternalSR: "606" } })).ok, true);
assert.deepEqual(queries, [505, 606]);
assert.equal(posts[1].headers["x-appbuilder-client-id"], "saved-internal-client");
assert.equal(JSON.parse(posts[1].body).ServiceRequest_Id_SRToInternalSR, "606", "links to parent SrId, independently of the parent's service ID");
assert.equal(JSON.parse(posts[1].body).SecondaryPillarNew_c, null);
});
test("internal SR save validates form fields and parent ID before requesting SRInfo", async () => {
let calls = 0;
const h = loadBackground({ fetch: async () => { calls++; return response({}, 403); } });
for (const change of [{ Title: "" }, { ProblemDescription: "" }, { TechPlatformLookUp_c: "" }, { InternalDeadline_c: "2026-02-30" },
{ CustomerDeadline_c: "2026-02-30" }, { ServiceNew_Id_c: "bad" }, { ServiceRequest_Id_SRToInternalSR: "bad" }]) {
assert.equal((await h.dispatch({ type: internalType, emailAddress, payload: { ...internalInput(), ...change } })).ok, false);
}
assert.equal(calls, 0);
assert.equal((await h.dispatch({ type: internalType, emailAddress, payload: internalInput() })).ok, false);
assert.equal(calls, 1);
});
test("internal SR stops after missing or rejected parent data, failed user lookup or POST and never retries creation", async () => {
for (const mode of ["info-401", "info-500", "empty", "multiple", "wrong-parent", "missing-country", "user", "post-403", "post-500", "network"]) {
const calls = [];
const h = loadBackground({ fetch: async (url, options) => {
calls.push({ url, options });
if (url.includes("/serviceRequests?")) {
const parent = parentInfo();
if (mode === "wrong-parent") parent.SrId = 999;
if (mode === "missing-country") delete parent.CountryText_c;
return response({ items: mode === "empty" ? [] : mode === "multiple" ? [parent, parent] : [parent] }, mode === "info-401" ? 401 : mode === "info-500" ? 500 : 200);
}
if (url.includes("/resourceUsers?")) return response({ resourcePartyId: 303 }, mode === "user" ? 403 : 200);
if (mode === "network") throw new Error("Synthetic network failure");
return response({}, mode === "post-403" ? 403 : 500);
} });
const result = await h.dispatch({ type: internalType, emailAddress, payload: internalInput() });
assert.equal(result.ok, false, mode);
const hasPost = ["post-403", "post-500", "network"].includes(mode);
assert.equal(calls.length, hasPost ? 3 : mode === "user" ? 2 : 1, mode);
assert.equal(calls.filter(call => call.options.method === "POST").length, hasPost ? 1 : 0);
assert.equal(result.debugRequests.at(-1).status, ["empty", "multiple", "wrong-parent", "missing-country"].includes(mode) ? "success" : "failed");
}
});
const payload = () => ({ AccountPartyId: 100000000000101, OpportunityName_Id_c: 202,
ServiceNew_Id_c: 404, Title: "Service-OP1-Customer", RegionText_c: "LAD", CountryText_c: "BR",
ProblemDescription: "Test description", Language_c: "ENGLISH", CustomerDeadline_c: "2027-01-31",
TechPlatformLookUp_c: "NO_ALL_PLATFORM", PrimaryPillarNew_c: "Tech_Cloud_Infrastructure" });
const withClientId = (body, status, value) => {
const result = response(body, status);
result.headers.set("X-AppBuilder-Client-Id", value);
return result;
};
for (const family of ["chrome", "firefox"]) {
test(`${family}: response client ID from resource lookup appears in confirmation and token request`, async () => {
const calls = [];
const h = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
if (url.includes("/resourceUsers?")) return withClientId({ resourcePartyId: 303 }, 200, "response-client");
if (url.endsWith("/Catalogue_c/505")) return response({ result: "test-token" });
return response({ SrNumber: "SR1" }, 201);
} });
const prepared = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
assert.equal(prepared.ok, true);
assert.equal(prepared.headers.requestSrTokenCatalog["x-appbuilder-client-id"], "response-client");
assert.deepEqual(prepared.headers.requestPostServiceRequest, { ...prepared.headers.requestSrTokenCatalog,
"vb-proxy-header-x-oracle-ectoken": "(generated after confirmation)" });
assert.equal(calls.length, 1);
const result = await h.dispatch({ type, phase: "confirm", catalogId: 505, payload: prepared.payload });
assert.equal(result.ok, true);
assert.equal(calls[1].options.headers["x-appbuilder-client-id"], "response-client");
assert.deepEqual({ ...calls[2].options.headers }, { ...calls[1].options.headers, "vb-proxy-header-x-oracle-ectoken": "test-token" });
assert.equal(calls.filter(call => call.url.endsWith("/mgmt/serviceRequests")).length, 1);
});
}
test("response observation includes background responses and excludes other apps and private requests", async () => {
const h = loadBackground();
assert.equal(h.responseHeaderListeners.length, 1);
const capture = h.responseHeaderListeners[0];
assert.deepEqual(capture.options, ["responseHeaders"]);
const details = { tabId: -1,
url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/example",
responseHeaders: [{ name: "X-AppBuilder-Client-ID", value: "background-response-client" }, { name: "Set-Cookie", value: "not-retained" }] };
capture.listener({ ...details, incognito: true });
capture.listener({ ...details, url: details.url.replace("SeaaS_initiation", "unrelated") });
assert.deepEqual(h.sessionStorage, {});
capture.listener(details);
assert.equal(Object.values(h.sessionStorage)[0].value, "background-response-client");
assert.ok(!JSON.stringify(h.sessionStorage).includes("not-retained"));
});
test("token 403 retries once only with a newly returned client ID; SR POST is never retried", async () => {
for (const mode of ["recovered", "still-forbidden", "unchanged", "no-header", "save-failure"]) {
const calls = [];
let tokens = 0;
const h = loadBackground({ fetch: async (url, options) => {
calls.push({ url, options });
if (url.includes("/resourceUsers?")) return withClientId({ resourcePartyId: 303 }, 200, "old-client");
if (url.endsWith("/Catalogue_c/505")) {
tokens += 1;
if (tokens === 1) return mode === "no-header" ? response({}, 403)
: withClientId({}, 403, mode === "unchanged" ? "old-client" : "new-client");
if (mode === "still-forbidden") return withClientId({}, 403, "another-client");
return response({ result: "retry-token" });
}
return response({ SrNumber: "SR1" }, mode === "save-failure" ? 403 : 201);
} });
const prepared = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
const result = await h.dispatch({ type, phase: "confirm", catalogId: 505, payload: prepared.payload });
assert.equal(result.ok, mode === "recovered");
const expectedTokens = ["unchanged", "no-header"].includes(mode) ? 1 : 2;
assert.equal(tokens, expectedTokens);
const tokenCalls = calls.filter(call => call.url.endsWith("/Catalogue_c/505"));
assert.equal(tokenCalls[0].options.headers["x-appbuilder-client-id"], "old-client");
if (expectedTokens === 2) assert.equal(tokenCalls[1].options.headers["x-appbuilder-client-id"], "new-client");
assert.equal(calls.filter(call => call.url.endsWith("/mgmt/serviceRequests")).length, ["recovered", "save-failure"].includes(mode) ? 1 : 0);
if (["recovered", "save-failure"].includes(mode)) {
assert.deepEqual({ ...calls.at(-1).options.headers }, { ...tokenCalls.at(-1).options.headers, "vb-proxy-header-x-oracle-ectoken": "retry-token" });
}
assertDebugVisibility(result, "retry-token");
}
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: Save resolves current user, discovers catalog, obtains token and creates SR once with protected diagnostics`, async () => {
const calls = [];
const h = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
if (calls.length === 1) return response({ emailAddress, resourceEmail: emailAddress, resourcePartyId: 303, resourceProfileId: 999 });
if (calls.length === 2) return response({ items: [{ Id: 505 }] });
if (calls.length === 3) return response({ result: "synthetic-secret-token" });
return response({ SrId: 606, SrNumber: "SR606" }, 201);
} });
const prepared = await h.dispatch({ type, phase: "prepare", emailAddress, payload: { ...payload(), ReportedByPartyId: 999 } });
assert.equal(prepared.ok, true);
assert.equal(calls.length, 1, "preview only resolves the user, never generates a token or creates SR");
assert.equal(h.headerRules.length, 0, "preview does not install request modifications");
assert.equal(prepared.headers.requestSrTokenCatalog["Content-Type"], "application/vnd.oracle.adf.action+json");
assert.equal(prepared.headers.requestPostServiceRequest["vb-proxy-header-x-oracle-ectoken"], "(generated after confirmation)");
assert.equal(prepared.payload.ReportedByPartyId, 303);
const result = await h.dispatch({ type, phase: "confirm", payload: prepared.payload });
assert.equal(result.ok, true);
assert.equal(result.result.SrNumber, "SR606");
assert.equal(calls.length, 4);
assert.equal(calls[0].options.method, "GET");
assert.equal(new URL(calls[0].url).searchParams.get("emailAddress"), emailAddress);
assert.ok(calls[0].url.includes("/set/seaas/crm/resourceUsers?"));
assert.equal(calls[1].options.method, "GET");
assert.ok(calls[2].url.endsWith("/Catalogue_c/505"));
assert.deepEqual(JSON.parse(calls[2].options.body), { name: "getJwtToken" });
assert.equal(calls[2].options.headers["Content-Type"], "application/vnd.oracle.adf.action+json");
assert.equal(calls[2].options.headers.Origin, new URL(calls[2].url).origin);
assert.deepEqual({ ...calls[3].options.headers }, { ...calls[2].options.headers, "vb-proxy-header-x-oracle-ectoken": "synthetic-secret-token" });
assert.equal(h.headerRules.length, 1);
const rule = h.headerRules[0].addRules[0];
const serviceRule = h.headerRules[0].addRules[1];
assert.ok(new RegExp(serviceRule.condition.regexFilter).test(calls[3].url));
assert.ok(!new RegExp(serviceRule.condition.regexFilter).test(calls[2].url));
assert.ok(!new RegExp(serviceRule.condition.regexFilter).test(calls[3].url + "/other"));
assert.deepEqual(serviceRule.condition.initiatorDomains, ["synthetic-extension"]);
assert.deepEqual(serviceRule.condition.requestMethods, ["post"]);
assert.deepEqual(serviceRule.action, rule.action);
assert.deepEqual(rule.condition.initiatorDomains, ["synthetic-extension"]);
assert.deepEqual(rule.condition.requestMethods, ["post"]);
assert.ok(new RegExp(rule.condition.regexFilter).test(calls[2].url));
assert.ok(!new RegExp(rule.condition.regexFilter).test(calls[3].url));
assert.ok(!new RegExp(rule.condition.regexFilter).test(calls[2].url + "/other"));
assert.deepEqual(rule.action.requestHeaders, [{ header: "Origin", operation: "set", value: new URL(calls[2].url).origin }]);
assert.equal(calls[3].options.method, "POST");
assert.ok(calls[3].url.endsWith("/set/seaas/mgmt/serviceRequests"));
assert.equal(calls[3].options.headers["vb-proxy-header-x-oracle-ectoken"], "synthetic-secret-token");
assert.ok(calls.slice(0, 3).every(call => !call.options.headers["vb-proxy-header-x-oracle-ectoken"]));
assert.ok(calls.every(call => call.options.credentials === "include" && call.options.headers.Authorization === "Session"));
assert.deepEqual(JSON.parse(calls[3].options.body), { ...payload(), ReportedByPartyId: 303, SvcQuestionsCollection_c: [],
SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" });
assert.equal(calls[3].options.body, JSON.stringify(prepared.payload));
assertDebugVisibility(result, "synthetic-secret-token");
assert.equal(h.tabs.length, 0);
});
}
test("missing region, invalid dates or unsafe IDs stop before token acquisition", async () => {
const h = loadBackground({ fetch: async () => assert.fail("Unexpected network") });
for (const change of [{ RegionText_c: null }, { CustomerDeadline_c: "2027-02-30" },
{ AccountPartyId: "9007199254740993" }, { OpportunityName_Id_c: "bad/id" }]) {
assert.equal((await h.dispatch({ type, phase: "prepare", emailAddress, payload: { ...payload(), ...change } })).ok, false);
}
assert.equal((await h.dispatch({ type, phase: "prepare", payload: payload() })).ok, false);
assert.equal((await h.dispatch({ type, phase: "confirm", payload: payload() })).ok, false);
assert.equal((await h.dispatch({ type, emailAddress, payload: payload() })).ok, false);
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: captures only SeaaS client ID, reuses it after worker restart and expires stale values`, async () => {
const tokenCalls = [];
const sessionStorage = {};
const fetch = async (url, options) => {
if (url.includes("/resourceUsers?")) return response({ resourcePartyId: 303 });
if (url.endsWith("/Catalogue_c/505")) {
tokenCalls.push(options);
return response({ result: "synthetic-token" });
}
return response({ SrNumber: "SR1" }, 201);
};
const h = loadBackground({ family, fetch, sessionStorage });
assert.equal(h.headerListeners.length, 1);
const capture = h.headerListeners[0];
assert.deepEqual(capture.options, ["requestHeaders"]);
assert.deepEqual(capture.filter.types, ["xmlhttprequest"]);
const request = { tabId: 12, url: "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live;profile=PROD/services/auth/1.1/proxy/example",
requestHeaders: [{ name: "X-AppBuilder-Client-Id", value: "synthetic-client" },
{ name: "Cookie", value: "do-not-store-cookie" }, { name: "Authorization", value: "do-not-store-auth" }] };
capture.listener({ ...request, url: request.url.replace("SeaaS_initiation", "otherApp") });
capture.listener({ ...request, url: request.url.replace("comcipapic-oalprod.integration.ocp.oraclecloud.com", "other.example") });
capture.listener({ ...request, tabId: -1 });
capture.listener({ ...request, incognito: true });
capture.listener({ ...request, requestHeaders: [{ name: "x-appbuilder-client-id", value: "invalid\r\nheader" }] });
assert.deepEqual(sessionStorage, {});
capture.listener(request);
assert.equal(Object.keys(sessionStorage).length, 1);
assert.ok(!JSON.stringify(sessionStorage).includes("do-not-store"));
const prepared = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
assert.equal(prepared.headers.requestSrTokenCatalog["x-appbuilder-client-id"], "synthetic-client");
assert.equal(tokenCalls.length, 0);
const restarted = loadBackground({ family, fetch, sessionStorage });
const result = await restarted.dispatch({ type, phase: "confirm", catalogId: 505, payload: prepared.payload });
assert.equal(result.ok, true);
assert.equal(tokenCalls[0].headers["x-appbuilder-client-id"], "synthetic-client");
assert.equal(result.debugRequests[0].metadata.appBuilderClientId, "Observed in SeaaS requests");
restarted.advance(60 * 60 * 1000 + 1);
const expired = await restarted.dispatch({ type, phase: "confirm", catalogId: 505, payload: prepared.payload });
assert.equal(expired.ok, true);
assert.equal(tokenCalls[1].headers["x-appbuilder-client-id"], undefined);
assert.equal(expired.debugRequests[0].metadata.appBuilderClientId, "Not observed in SeaaS requests");
capture.listener({ ...request, requestHeaders: [{ name: "x-appbuilder-client-id", value: "renewed-client" }] });
const refreshed = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
assert.equal(refreshed.headers.requestSrTokenCatalog["x-appbuilder-client-id"], "renewed-client");
assert.equal(h.tabs.length, 0, "header discovery does not add background page loads");
});
}
test("Origin rule failure blocks token acquisition and SR creation", async () => {
const h = loadBackground({ ruleError: true, fetch: async () => assert.fail("No request expected") });
const result = await h.dispatch({ type, phase: "confirm", catalogId: 505, payload: { ...payload(), ReportedByPartyId: 303 } });
assert.equal(result.ok, false);
assert.match(result.error, /Rule permission denied/);
});
test("Origin rule failure prevents the Internal SR POST", async () => {
const calls = [];
const h = loadBackground({ ruleError: true, fetch: async url => {
calls.push(url);
if (url.includes("/serviceRequests?")) return response({ items: [parentInfo()] });
assert.match(url, /\/resourceUsers\?/);
return response({ resourcePartyId: 303 });
} });
const result = await h.dispatch({ type: internalType, emailAddress, payload: internalInput() });
assert.equal(result.ok, false);
assert.match(result.error, /Rule permission denied/);
assert.equal(calls.length, 2);
});
test("resource lookup failures or invalid resourcePartyId never obtain a token or create SR", async () => {
for (const body of [{ resourcePartyId: null }, { resourceProfileId: 999 }, { resourcePartyId: "9007199254740993" }, { resourcePartyId: 0 }, "unauthorized"]) {
let calls = 0;
const h = loadBackground({ fetch: async () => {
calls += 1;
return response(body, body === "unauthorized" ? 401 : 200);
} });
const result = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
assert.equal(result.ok, false);
assert.equal(calls, 1);
assert.equal(result.debugRequests[0].name, "requestResourceUser");
}
});
test("token rejection never creates SR; creation failures are never automatically retried", async () => {
for (const failure of ["token", "create", "empty-token", "network"]) {
let calls = 0;
const h = loadBackground({ fetch: async () => {
calls += 1;
if (calls === 1) return response({ resourcePartyId: 303 });
if (calls === 2) return failure === "token" ? response({}, 401)
: response({ result: failure === "empty-token" ? "" : "secret-token" });
if (failure === "network") throw new Error("Network error");
return response({}, 500);
} });
const prepared = await h.dispatch({ type, phase: "prepare", emailAddress, payload: payload() });
const result = await h.dispatch({ type, phase: "confirm", catalogId: 505, payload: prepared.payload });
assert.equal(result.ok, false);
assert.equal(calls, failure === "token" || failure === "empty-token" ? 2 : 3);
assertDebugVisibility(result, "secret-token");
assert.equal(h.tabs.length, 0);
}
});

View File

@@ -0,0 +1,47 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
test("preparation runs once, shares pending work and reports loading and completion", async () => {
const app = loadApplication("content");
const progress = [];
let requests = 0;
let resolveUser, resolveSession;
app.currentUser.ensure = () => new Promise(resolve => { resolveUser = resolve; });
app.currentUser.allowsCrmActivities = () => true;
app.activityProgress.update = (...args) => progress.push(args);
app.debugContentLog.appendBackgroundRequestLog = () => {};
app.platformContentMessages.sendRuntimeMessage = message => {
assert.equal(message.type, "opportunitiesExtension.prepareSrSession");
requests += 1;
return new Promise(resolve => { resolveSession = resolve; });
};
const first = app.srSessionPreparation.ensure();
assert.equal(app.srSessionPreparation.ensure(), first);
assert.equal(requests, 0, "identity must load first");
resolveUser();
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(progress, [["session", "running"]]);
resolveSession({ ok: true });
await first;
await app.srSessionPreparation.ensure();
assert.equal(requests, 1);
assert.deepEqual(progress.at(-1), ["session", "complete"]);
});
test("SALES_REP skips preparation; failed preparation does not auto-repeat or reject modal loading", async () => {
for (const allowed of [false, true]) {
const app = loadApplication("content");
let requests = 0;
const progress = [];
app.currentUser.ensure = async () => {};
app.currentUser.allowsCrmActivities = () => allowed;
app.activityProgress.update = (...args) => progress.push(args);
app.debugContentLog.appendBackgroundRequestLog = () => {};
app.platformContentMessages.sendRuntimeMessage = async () => { requests += 1; return { ok: false }; };
await app.srSessionPreparation.ensure();
await app.srSessionPreparation.ensure();
assert.equal(requests, allowed ? 1 : 0);
assert.deepEqual(progress, allowed ? [["session", "running"], ["session", "error"]] : []);
}
});

View File

@@ -57,7 +57,7 @@ function loadContent({ fetch = unexpectedFetch, storage = new Map(), xsrf = null
};
}
function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = {}, cookies = () => [], storageError = false } = {}) {
function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage = {}, sessionStorage = {}, cookies = () => [], storageError = false, ruleError = false, messageTimeout = 2000 } = {}) {
const time = clock();
const listeners = [];
const progress = [];
@@ -65,7 +65,17 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
const tabs = [];
const removedTabs = [];
const cookieQueries = [];
const api = { runtime: { onMessage: { addListener: fn => listeners.push(fn) } } };
const headerListeners = [];
const responseHeaderListeners = [];
const headerRules = [];
const api = { runtime: { onMessage: { addListener: fn => listeners.push(fn) },
getURL: path => `${family === "firefox" ? "moz" : "chrome"}-extension://synthetic-extension${path}` },
webRequest: {
onBeforeSendHeaders: { addListener: (listener, filter, options) => headerListeners.push({ listener, filter: plain(filter), options: plain(options) }) },
onHeadersReceived: { addListener: (listener, filter, options) => responseHeaderListeners.push({ listener, filter: plain(filter), options: plain(options) }) }
},
declarativeNetRequest: { updateSessionRules: async rules => { if (ruleError) throw new Error("Rule permission denied"); headerRules.push(plain(rules)); } }
};
const readCookies = details => { cookieQueries.push(plain(details)); return cookies(details); };
const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) });
const store = value => Object.assign(storage, plain(value));
@@ -110,6 +120,10 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
getAllCookieStores(callback) { callback([{ id: "test-store" }]); }
};
}
api.storage.session = {
get: async key => ({ [key]: sessionStorage[key] === undefined ? undefined : plain(sessionStorage[key]) }),
set: async value => Object.assign(sessionStorage, plain(value))
};
const context = {
[family === "firefox" ? "browser" : "chrome"]: api,
fetch, URL, Date: time.Date,
@@ -123,7 +137,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
function dispatch(message) {
return new Promise((resolve, reject) => {
let replies = 0;
const timer = setTimeout(() => reject(new Error("Message did not receive a response")), 2000);
const timer = setTimeout(() => reject(new Error("Message did not receive a response")), messageTimeout);
const pending = listeners[0](message, sender, result => {
clearTimeout(timer);
assert.equal(++replies, 1, "Message must receive exactly one response");
@@ -132,7 +146,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
if (pending !== true) { clearTimeout(timer); reject(new Error("Expected asynchronous response contract")); }
});
}
return { dispatch, listener: listeners[0], progress, requestDebug, tabs, removedTabs, cookieQueries, storage, advance: time.advance };
return { dispatch, listener: listeners[0], progress, requestDebug, tabs, removedTabs, cookieQueries, storage, sessionStorage, headerRules, headerListeners, responseHeaderListeners, advance: time.advance };
}
module.exports = { root, plain, response, loadContent, loadBackground };

View File

@@ -0,0 +1,623 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
const lookupData = () => Object.fromEntries(["ROLE", "NAME", "TEAM", "STATUS", "COMPLEXITY"].map(key => [
`WWB_ACTION_${key}`, [{ code: `${key}_CODE`, name: `${key} <synthetic>` }]
]));
function setup() {
let document;
class Element {
constructor(tag) { this.tag = tag; this.children = []; this.attributes = {}; this.listeners = {}; this.isConnected = true; this.text = ""; }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text + this.children.map(child => child.textContent).join(""); }
append(...nodes) { for (const node of nodes) { node.parent = this; this.children.push(node); } }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { document.activeElement = this; }
remove() { this.parent.children = this.parent.children.filter(child => child !== this); this.isConnected = false; }
click() { this.listeners.click?.({ target: this, preventDefault() {}, stopPropagation() {} }); }
}
const host = new Element("host");
document = { createElement: tag => new Element(tag), createElementNS: (_, tag) => new Element(tag),
getElementById: id => id === "opportunities-extension-modal" ? host : null,
listeners: {}, addEventListener(key, fn) { this.listeners[key] = fn; } };
const app = loadApplication("content", { document, window: {} });
app.currentUser.allowsCrmActivities = () => true;
app.authSessionState.principal = "synthetic@example.invalid";
app.productsContentState.opptyProductsByNumber.set("A", { items: [] });
app.productsContentState.opptyProductsByNumber.set("B", { items: [] });
app.platformContentMessages.sendRuntimeMessage = () => assert.fail("Form must not send requests");
const walk = node => [node, ...node.children.flatMap(walk)];
const nodes = () => walk(host);
const form = () => nodes().find(node => node.tag === "form");
return { app, host, document, nodes, form };
}
test("missing SR plus opens pillar form, handles retry, ignores closed requests and respects roles", async () => {
const h = setup();
h.app.opportunitiesTableState.opportunitiesTableState.items = [
{ OptyNumber: "A", Name: "<Opportunity A>", CustomerAccount: { PartyUniqueName: "Customer A" }, EffectiveDate: "2026-11-30" }
];
h.app.opportunitiesQueryState.periodRequestVersion = 1;
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [{ SrNumber: "SR1", OpportunityName_Id_c: { OptyNumber: "B" } }];
h.app.debugContentView.renderDebugPanel = () => {};
await h.app.opportunityCrmServiceRequests.load("test", 1);
const calls = [];
const extraCalls = [];
h.app.currentUser.getUserId = () => "user@example.invalid";
h.app.platformContentMessages.sendRuntimeMessage = message => {
if (message.type.endsWith("requestSrLookupPrimaryPillar")) return new Promise(resolve => calls.push({ message, resolve }));
extraCalls.push(message);
return Promise.resolve({ ok: true, items: message.type.endsWith("requestSrLookupOpportunityBusiness")
? [{ Id: "123", RecordName: "Service", Abstract_c: "<Description>" }]
: [{ code: "CODE", name: "Label" }] });
};
const badge = h.app.opportunityCrmServiceRequests.createBadge("A");
const plus = badge.children.find(node => node.tag === "button");
assert.ok(plus);
assert.equal(plus.textContent, "+");
assert.ok(!h.app.opportunityCrmServiceRequests.createBadge("B").children.some(node => node.className === "opportunities-extension-add-action"));
plus.click();
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Name", "Close Date"]);
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["Customer A", "<Opportunity A>", "30/11/2026"]);
assert.equal(calls.length, 1);
assert.equal(calls[0].message.type, "opportunitiesExtension.requestSrLookupPrimaryPillar");
const field = () => h.nodes().find(node => node.name === "primaryPillar");
assert.equal(field().disabled, true);
calls[0].resolve({ ok: false, error: "Unauthorized" });
await flush();
assert.equal(h.form().attributes["data-state"], "error");
assert.equal(extraCalls.length, 3);
assert.equal(extraCalls.find(call => call.type.endsWith("requestSrLookupOpportunityBusiness")).emailAddress, "user@example.invalid");
assert.equal(h.nodes().find(node => node.name === "customerDeadline").type, "date");
assert.equal(h.nodes().find(node => node.name === "description").tag, "textarea");
assert.equal(h.nodes().find(node => node.name === "language").children[1].value, "CODE");
assert.equal(h.nodes().find(node => node.name === "platform").children[1].textContent, "Label");
assert.equal(h.nodes().find(node => node.name === "service").value, "123");
assert.equal(h.nodes().find(node => node.name === "service").type, "radio");
assert.ok(h.nodes().some(node => node.textContent === "<Description>"));
h.nodes().find(node => node.textContent === "Retry" && !node.hidden).click();
calls[1].resolve({ ok: true, items: [{ code: "SaaS_HCM", name: "SaaS - HCM" }] });
await flush();
assert.equal(field().disabled, false);
assert.equal(field().children[1].value, "SaaS_HCM");
assert.equal(field().children[1].textContent, "SaaS - HCM");
const search = h.nodes().find(node => node.id === `${field().id}-search`);
assert.equal(field().hidden, true);
assert.equal(search.disabled, false);
search.value = "hcm";
search.listeners.input();
const options = h.nodes().find(node => node.id === search.attributes["aria-controls"]);
assert.equal(options.children.length, 1);
assert.equal(options.children[0].textContent, "SaaS - HCM");
search.listeners.keydown({ key: "ArrowDown", preventDefault() {} });
search.listeners.keydown({ key: "Enter", preventDefault() {} });
assert.equal(field().value, "SaaS_HCM");
assert.equal(search.value, "SaaS - HCM");
assert.equal(options.hidden, true);
search.value = "does not exist";
search.listeners.input();
assert.equal(options.textContent, "No results");
search.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
assert.equal(search.value, "SaaS - HCM");
assert.ok(h.form(), "Escape closes only the options");
assert.equal(calls.length, 2, "searching does not issue another request");
h.nodes().find(node => node.textContent === "Cancel").click();
assert.equal(h.host.children.length, 0);
h.app.opportunitiesTableState.opportunitiesTableState.items = [];
plus.click();
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["-", "-", "-"]);
h.app.opportunityCrmServiceRequests.reset();
calls[2].resolve({ ok: true, items: [] });
await flush();
assert.equal(h.host.children.length, 0);
h.app.currentUser.allowsCrmActivities = () => false;
plus.click();
assert.equal(h.host.children.length, 0);
});
test("SR Save validates form, maps dynamic values, locks duplicate clicks and preserves failures", async () => {
const h = setup();
h.app.opportunitiesQueryState.periodRequestVersion = 1;
h.app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [];
h.app.debugContentView.renderDebugPanel = () => {};
await h.app.opportunityCrmServiceRequests.load("test", 1);
h.app.currentUser.getUserId = () => "current@example.invalid";
h.app.opportunitiesTableState.opportunitiesTableState.items = [{ OptyNumber: "B", CustomerAccount: { PartyId: 999, Region_c: "EMEA", PrimaryAddress: { CountryCode: "US" } } }, {
OptyNumber: "A", OptyId: 202, PartyId: 888, AccountPartyId: 777, Owner: { PartyId: 666 }, Region_c: "EMEA", RegionText_c: "LAD", Name: "Opportunity", CountryCode: "CA",
CustomerAccount: { PartyId: 100000000000101, PartyUniqueName: "Customer", Region_c: "APAC", CountryCode: "AR", PrimaryAddress: { CountryCode: "BR" } }
}];
const posts = [];
const notices = [];
h.app.opportunitiesLinks.showCopyConfirmation = (...args) => notices.push(args);
h.app.platformContentMessages.sendRuntimeMessage = message => {
if (message.type.endsWith("requestPostServiceRequest")) return new Promise(resolve => posts.push({ message, resolve }));
return Promise.resolve({ ok: true, items: message.type.endsWith("requestSrLookupOpportunityBusiness")
? [{ Id: "404", RecordName: "Opportunity Pursuit", Abstract_c: "Description" }] : [{ code: "CODE", name: "Label" }] });
};
const plus = h.app.opportunityCrmServiceRequests.createBadge("A").children.find(node => node.tag === "button");
plus.click();
await flush();
const save = () => h.nodes().find(node => node.tag === "button" && ["Save", "Preparing...", "Saving..."].includes(node.textContent));
const submit = () => h.form().listeners.submit({ preventDefault() {} });
assert.equal(save().disabled, false);
await submit();
assert.equal(posts.length, 0);
assert.ok(h.nodes().some(node => node.attributes["aria-invalid"] === "true"));
for (const name of ["primaryPillar", "language", "platform"]) h.nodes().find(node => node.name === name).value = "CODE";
h.nodes().find(node => node.name === "customerDeadline").value = "2027-01-31";
h.nodes().find(node => node.name === "description").value = "Dynamic description";
h.nodes().find(node => node.name === "service").checked = true;
const saving = submit();
await submit();
assert.equal(posts.length, 1);
assert.equal(save().textContent, "Preparing...");
assert.equal(save().disabled, true);
h.nodes().find(node => node.textContent === "Cancel").click();
assert.ok(h.form(), "cannot dismiss while Save is in flight");
const payload = posts[0].message.payload;
assert.equal(payload.AccountPartyId, 100000000000101, "uses only the selected item's CustomerAccount.PartyId");
assert.equal(payload.OpportunityName_Id_c, 202);
assert.equal(payload.ReportedByPartyId, undefined);
assert.equal(posts[0].message.emailAddress, "current@example.invalid");
assert.equal(payload.Title, "Opportunity Pursuit-A-Customer");
assert.equal(payload.ServiceNew_Id_c, "404");
assert.equal(payload.PrimaryPillarNew_c, "CODE");
assert.equal(payload.Language_c, "CODE");
assert.equal(payload.TechPlatformLookUp_c, "CODE");
assert.equal(payload.CountryText_c, "BR", "uses the selected opportunity's CustomerAccount.PrimaryAddress.CountryCode, not CustomerAccount, root or another opportunity");
assert.equal(payload.RegionText_c, "APAC", "uses CustomerAccount.Region_c from the selected opportunity, not a root-level region or another opportunity");
assert.equal(payload.ProblemDescription, "Dynamic description");
assert.equal(payload.CustomerDeadline_c, "2027-01-31");
assert.equal(posts[0].message.phase, "prepare");
const preparedPayload = { ...payload, ReportedByPartyId: 303, SvcQuestionsCollection_c: [],
SourceCd: "SVC_OPPORTUNITY_SCREEN", SeverityCd: "ORA_SVC_SEV2", ChannelTypeCd: "ORA_SVC_WEB" };
const preparedHeaders = {
requestSrTokenCatalog: { Authorization: "Session", "Content-Type": "application/vnd.oracle.adf.action+json", Origin: "https://example.invalid" },
requestPostServiceRequest: { Authorization: "Session", "Content-Type": "application/vnd.oracle.adf.action+json", Origin: "https://example.invalid", "vb-proxy-header-x-oracle-ectoken": "(generated after confirmation)" }
};
posts[0].resolve({ ok: true, payload: preparedPayload, headers: preparedHeaders });
await flush();
const confirmation = () => h.nodes().find(node => node.className?.includes("opportunities-extension-sr-confirmation"));
const confirm = () => h.nodes().find(node => node.textContent === "Confirm Save");
assert.ok(confirmation());
const preview = h.nodes().find(node => node.tag === "pre");
assert.equal(preview.textContent, JSON.stringify(preparedPayload, null, 2));
const previews = h.nodes().filter(node => node.tag === "pre");
assert.equal(previews.length, 3);
assert.equal(previews[1].textContent, JSON.stringify(preparedHeaders.requestSrTokenCatalog, null, 2));
assert.equal(previews[2].textContent, JSON.stringify(preparedHeaders.requestPostServiceRequest, null, 2));
assert.ok(h.nodes().some(node => node.textContent === "x-appbuilder-client-id: not yet observed in SeaaS requests."));
assert.equal(h.form().parent.inert, true);
assert.equal(h.document.activeElement.textContent, "Cancel");
await submit();
assert.equal(posts.length, 1, "awaiting confirmation must never send a creation request");
confirm().focus();
confirmation().listeners.keydown({ key: "Tab", preventDefault() {}, stopPropagation() {} });
assert.equal(h.document.activeElement, preview);
h.nodes().filter(node => node.tag === "button" && node.textContent === "Cancel").at(-1).click();
await saving;
assert.equal(posts.length, 1, "Cancel never creates SR");
assert.equal(confirmation(), undefined);
assert.equal(h.form().parent.inert, false);
assert.equal(save().disabled, false);
assert.equal(h.nodes().find(node => node.name === "description").value, "Dynamic description");
assert.equal(h.document.activeElement, save());
assert.equal(notices.length, 0);
const escaped = submit();
posts[1].resolve({ ok: true, payload: preparedPayload });
await flush();
confirmation().listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
await escaped;
assert.equal(posts.length, 2);
assert.ok(h.form(), "Escape closes only the confirmation");
const failed = submit();
posts[2].resolve({ ok: true, payload: preparedPayload });
await flush();
const confirmButton = confirm();
confirmButton.click();
confirmButton.click();
await flush();
assert.equal(posts.length, 4, "double confirm sends only one creation request");
assert.equal(posts[3].message.phase, "confirm");
assert.deepEqual(posts[3].message.payload, preparedPayload);
assert.equal(posts[3].message.payload.AccountPartyId, 100000000000101);
assert.equal(save().textContent, "Saving...");
posts[3].resolve({ ok: false, error: "Synthetic failure" });
await failed;
assert.equal(notices.at(-1)[1], true);
delete h.app.opportunitiesTableState.opportunitiesTableState.items[1].CustomerAccount.CountryCode;
const retry = submit();
assert.equal(posts[4].message.payload.CountryText_c, "BR", "uses PrimaryAddress.CountryCode even without CustomerAccount.CountryCode");
posts[4].resolve({ ok: true, payload: preparedPayload });
await flush();
confirm().click();
await flush();
posts[5].resolve({ ok: true, result: { SrId: 505, SrNumber: "SR505" } });
await retry;
assert.equal(h.form(), undefined);
assert.equal(notices.at(-1)[0], "Service Request saved successfully.");
});
test("only plus opens the nine-field form; a missing workload stays disabled without a save operation", () => {
const h = setup();
const badge = h.app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge.textContent, "0+");
badge.click();
assert.equal(h.host.children.length, 0);
const plus = badge.children.find(node => node.tag === "button");
assert.equal(plus.textContent, "+");
assert.equal(plus.attributes["aria-haspopup"], "dialog");
plus.click();
assert.equal(h.host.children.length, 1);
assert.equal(h.host.children[0].attributes.role, "dialog");
assert.deepEqual(h.nodes().filter(node => node.name).map(node => node.name), ["owner", "role", "action", "team", "startDate", "endDate", "status", "complexity", "notes"]);
assert.equal(h.nodes().find(node => node.name === "notes").tag, "textarea");
assert.equal(h.nodes().find(node => node.name === "startDate").type, "date");
assert.equal(h.document.activeElement.title, "Close");
assert.ok(h.nodes().filter(node => node.name).every(node => node.disabled));
assert.equal(h.form().attributes["data-state"], "error");
assert.equal(h.nodes().find(node => node.textContent === "Save").disabled, true);
let prevented = false;
h.form().listeners.submit({ preventDefault() { prevented = true; } });
assert.equal(prevented, true);
h.nodes().find(node => node.textContent === "Cancel").click();
assert.equal(h.host.children.length, 0);
assert.equal(h.document.activeElement, plus);
});
test("form traps focus, handles Escape, replaces rather than duplicates, and resets its fields", () => {
const h = setup();
h.app.contentModal.ensureEscapeKeyHandler();
const first = h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button");
first.click();
h.nodes().find(node => node.name === "notes").value = "Draft";
const second = h.app.opportunityWorkloadActions.createBadge("B").children.find(node => node.tag === "button");
second.click();
assert.equal(h.host.children.length, 1);
assert.equal(h.nodes().find(node => node.name === "notes").value, undefined);
const cancel = h.nodes().find(node => node.textContent === "Cancel");
cancel.focus();
let prevented = false;
h.host.children[0].listeners.keydown({ key: "Tab", preventDefault() { prevented = true; } });
assert.equal(prevented, true);
assert.equal(h.document.activeElement.title, "Close");
h.document.listeners.keydown({ key: "Escape", preventDefault() {} });
assert.equal(h.host.children.length, 0);
assert.equal(h.document.activeElement, second);
});
test("SALES_REP, loading/unknown Actions and stale triggers cannot open the modal", () => {
const h = setup();
const plus = h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button");
h.app.currentUser.allowsCrmActivities = () => false;
plus.click();
assert.equal(h.host.children.length, 0);
assert.equal(h.app.opportunityWorkloadActions.createBadge("A"), null);
h.app.currentUser.allowsCrmActivities = () => true;
h.app.productsContentState.opptyProductsByNumber.set("A", { items: [{ RevnId: "pending" }] });
plus.click();
assert.equal(h.host.children.length, 0);
assert.equal(h.app.opportunityWorkloadActions.createBadge("A").children.some(node => node.tag === "button"), false);
h.app.authSessionState.principal = "";
assert.equal(h.app.opportunityWorkloadActions.createBadge("B").children.some(node => node.tag === "button"), false);
});
test("resetting Actions closes an open form without leaving an old opportunity context", () => {
const h = setup();
h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button").click();
h.app.opportunityWorkloadActions.reset();
assert.equal(h.host.children.length, 0);
});
test("Action header shows the selected opportunity customer, name and formatted close date safely", () => {
const h = setup();
h.app.opportunitiesTableState.opportunitiesTableState.items = [
{ OptyNumber: "A", Name: "<script>synthetic</script>", CustomerAccount: { PartyUniqueName: "Customer A" }, EffectiveDate: "2026-11-30" },
{ OptyNumber: "B", Name: "Opportunity B", CustomerAccount: { PartyUniqueName: "Customer B" }, EffectiveDate: null }
];
h.app.opportunityWorkloadActions.createBadge("A").children.find(node => node.tag === "button").click();
assert.deepEqual(h.nodes().filter(node => node.tag === "dt").map(node => node.textContent), ["Customer", "Name", "Close Date"]);
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["Customer A", "<script>synthetic</script>", "30/11/2026"]);
assert.equal(h.nodes().some(node => node.tag === "script"), false);
h.app.opportunityWorkloadActions.createBadge("B").children.find(node => node.tag === "button").click();
assert.deepEqual(h.nodes().filter(node => node.tag === "dd").map(node => node.textContent), ["Customer B", "Opportunity B", "-"]);
});
function optionsSetup(products = [{ RevnId: "123", WorkloadName_c: "Synthetic workload" }]) {
const h = setup();
h.calls = [];
h.app.productsContentState.opptyProductsByNumber.set("A", { items: products });
h.app.opportunityWorkloadActions.getSummary = () => ({ state: "missing" });
h.app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
h.app.debugContentView.renderDebugPanel = () => {};
h.app.platformContentMessages.sendRuntimeMessage = message => new Promise((resolve, reject) => h.calls.push({ message, resolve, reject }));
h.field = name => h.nodes().find(node => node.name === name);
h.open = () => h.app.workloadActionForm.open("A", null);
h.workload = () => h.nodes().find(node => node.id === "opportunities-extension-action-form-workload");
h.completeLookup = () => h.calls.find(call => call.message.type.endsWith("requestActionFindLookup")).resolve({ ok: true, lookups: lookupData(), debugRequests: [] });
return h;
}
test("both options requests must succeed before fields enable; email and lookup codes stay distinct from labels", async () => {
const h = optionsSetup();
h.open();
assert.equal(h.form().attributes["aria-busy"], "true");
assert.ok(h.nodes().filter(node => node.name).every(node => node.disabled));
await flush();
assert.equal(h.calls.length, 2);
assert.deepEqual(h.calls.map(call => call.message.type), ["opportunitiesExtension.requestActionFindLookup", "opportunitiesExtension.requestActionTeamMembers"]);
const team = h.calls[1];
assert.equal(team.message.workloadId, "123");
for (const call of h.calls) assert.equal(call.message.authorization.value, "synthetic-wwb");
team.resolve({ ok: true, members: [{ email: "owner@example.invalid" }], debugRequests: [] });
await flush();
assert.equal(h.field("owner").disabled, true, "a partial result cannot enable the form");
h.completeLookup();
await flush();
assert.equal(h.form().attributes["data-state"], "ready");
assert.equal(h.form().attributes["aria-busy"], "false");
assert.equal(h.field("owner").tag, "select");
assert.equal(h.field("owner").children[1].textContent, "owner@example.invalid");
assert.equal(h.field("owner").children[1].value, "owner@example.invalid");
assert.equal(h.field("owner").value, "", "unmatched user is not replaced by the first owner");
for (const [name, key] of [["role", "ROLE"], ["action", "NAME"], ["team", "TEAM"], ["status", "STATUS"], ["complexity", "COMPLEXITY"]]) {
const field = h.field(name);
assert.equal(field.tag, "select");
assert.equal(field.disabled, false);
assert.equal(field.children[1].value, `${key}_CODE`);
assert.equal(field.children[1].textContent, `${key} <synthetic>`);
assert.equal(field.children[1].children.length, 0);
}
assert.equal(h.document.activeElement.id, `${h.field("owner").id}-search`);
assert.equal(h.nodes().find(node => node.textContent === "Save").disabled, false);
let prevented = false;
h.form().listeners.submit({ preventDefault() { prevented = true; } });
assert.equal(prevented, true);
assert.equal(h.calls.length, 2, "no save request");
});
test("Action defaults use current user and lookup labels; only Notes is optional", async () => {
const h = optionsSetup();
const identity = h.app.currentUser.ensure();
h.calls[0].resolve({ ok: true, user: { id: "OWNER@example.invalid", roles: ["SIMPLE_USER"] } });
await identity;
assert.equal(h.app.currentUser.getUserId(), "OWNER@example.invalid");
h.calls.length = 0;
h.open();
await flush();
const data = lookupData();
data.WWB_ACTION_ROLE = [{ code: "CLOUD_ARCHITECT", name: "Cloud Architect" }];
data.WWB_ACTION_NAME = [{ code: "SUCCESS_PLAN_CONSUM_RVW", name: "Success Plan & Consumption review" }];
data.WWB_ACTION_STATUS = [{ code: "COMPLETE", name: "Complete" }];
h.calls[0].resolve({ ok: true, lookups: data });
h.calls[1].resolve({ ok: true, members: [{ email: "other@example.invalid" }, { email: "owner@example.invalid" }] });
await flush();
for (const [name, value, label] of [
["owner", "owner@example.invalid", "owner@example.invalid"],
["role", "CLOUD_ARCHITECT", "Cloud Architect"],
["action", "SUCCESS_PLAN_CONSUM_RVW", "Success Plan & Consumption review"],
["status", "COMPLETE", "Complete"]
]) {
assert.equal(h.field(name).value, value);
assert.equal(h.nodes().find(node => node.id === `${h.field(name).id}-search`).value, label);
}
for (const name of ["owner", "role", "action", "team", "startDate", "endDate", "status", "complexity"]) {
const field = h.field(name);
assert.equal(field.required, true);
assert.equal(field.attributes["aria-required"], "true");
assert.ok(field.parent.children[0].textContent.endsWith("(required)"));
if (field.tag === "select") {
assert.equal(h.nodes().find(node => node.id === `${field.id}-search`).required, true);
}
}
assert.equal(h.field("team").value, "");
assert.equal(h.field("complexity").value, "");
assert.equal(h.field("notes").required, false);
assert.equal(h.field("notes").parent.children[0].textContent, "Notes (optional)");
assert.equal(h.calls.length, 2);
});
test("Save validates, prevents duplicate POSTs, preserves errors and reports success", async () => {
const h = optionsSetup();
h.app.productsContentJobs.queueProductsTableRender = () => {};
const notifications = [];
h.app.opportunitiesLinks.showCopyConfirmation = (...args) => notifications.push(args);
h.app.currentUser.getUserId = () => "creator@example.invalid";
h.open();
await flush();
h.completeLookup();
h.calls[1].resolve({ ok: true, members: [{ email: "owner@example.invalid" }] });
await flush();
const submit = () => h.form().listeners.submit({ preventDefault() {} });
await submit();
assert.equal(h.calls.length, 2);
assert.equal(h.nodes().find(node => node.id === `${h.field("owner").id}-search`).attributes["aria-invalid"], "true");
for (const name of ["owner", "role", "action", "team", "status", "complexity"]) h.field(name).value = h.field(name).children[1].value;
h.field("startDate").value = "2026-09-14";
h.field("endDate").value = "2026-09-13";
await submit();
assert.equal(h.calls.length, 2);
assert.equal(h.field("endDate").attributes["aria-invalid"], "true");
h.field("endDate").value = "2026-09-15";
h.field("notes").value = "My note";
const first = submit();
await flush();
await submit();
assert.equal(h.calls.length, 3);
const call = h.calls[2];
assert.equal(call.message.type, "opportunitiesExtension.requestMgmtAction");
assert.equal(call.message.authorization.value, "synthetic-wwb");
assert.deepEqual(JSON.parse(JSON.stringify(call.message.payload)), {
parentType: "WORKLOAD", parentId: "123", action: {
ownerEmail: "owner@example.invalid", role: "ROLE_CODE", name: "NAME_CODE", team: "TEAM_CODE",
tags: "", startDate: "2026-09-14T00:00:00.000Z", endDate: "2026-09-15T00:00:00.000Z",
status: "STATUS_CODE", complexity: "COMPLEXITY_CODE", notes: ["My note"], objective: "",
createdBy: "creator@example.invalid", updatedBy: "creator@example.invalid"
}
});
call.resolve({ ok: false, error: "HTTP 403" });
await first;
assert.equal(h.field("notes").value, "My note");
assert.deepEqual(notifications[0], ["HTTP 403", true]);
h.field("notes").value = "";
const second = submit();
await flush();
assert.equal(h.calls[3].message.payload.action.notes.length, 0);
h.calls[3].resolve({ ok: true, result: { id: 77, ownerEmail: "synthetic@example.invalid", name: "REVIEW" } });
await second;
assert.equal(h.host.children.length, 0);
assert.deepEqual(notifications[1], ["Action saved successfully."]);
assert.equal(h.app.opportunityWorkloadActions.createBadge("A").attributes["data-state"], "matched");
});
test("autocomplete searches labels and codes, selects by keyboard and restores committed values", async () => {
const h = optionsSetup();
h.open();
await flush();
const data = lookupData();
data.WWB_ACTION_ROLE = [{ code: "CA", name: "Cloud Architect" }, { code: "SE", name: "Solution Engineer" }];
h.calls[0].resolve({ ok: true, lookups: data });
h.calls[1].resolve({ ok: true, members: [{ email: "owner@example.invalid" }] });
await flush();
const input = h.nodes().find(node => node.id === `${h.field("role").id}-search`);
const list = h.nodes().find(node => node.id === input.attributes["aria-controls"]);
list.style = {};
input.closest = () => ({ getBoundingClientRect: () => ({ top: 20, bottom: 600 }) });
input.getBoundingClientRect = () => ({ top: 100, bottom: 138 });
const key = key => input.listeners.keydown({ key, preventDefault() {}, stopPropagation() {} });
input.value = "architect";
input.listeners.input();
assert.equal(list.attributes["data-placement"], "below");
assert.equal(list.style.maxHeight, "180px");
assert.equal(list.children.length, 1);
assert.equal(list.children[0].textContent, "Cloud Architect");
key("ArrowDown");
assert.equal(list.children[0].attributes["aria-selected"], "true");
key("Enter");
assert.equal(h.field("role").value, "CA");
assert.equal(input.value, "Cloud Architect");
assert.equal(list.hidden, true);
input.value = "not present";
input.getBoundingClientRect = () => ({ top: 500, bottom: 538 });
input.listeners.input();
assert.equal(list.attributes["data-placement"], "above");
assert.equal(list.style.maxHeight, "180px");
assert.equal(list.textContent, "No results");
key("Escape");
assert.equal(input.value, "Cloud Architect");
input.value = "SE";
input.listeners.input();
list.children[0].click();
assert.equal(h.field("role").value, "SE");
assert.equal(input.value, "Solution Engineer");
input.value = "free text";
input.listeners.blur();
assert.equal(input.value, "Solution Engineer");
assert.equal(h.calls.length, 2, "searching never issues requests");
});
test("workload autocomplete uses the selected RevnId and disables metadata search during loading", async () => {
const h = optionsSetup([{ RevnId: "123", WorkloadName_c: "First" }, { RevnId: "456", WorkloadName_c: "Second" }]);
h.open();
await flush();
const input = h.nodes().find(node => node.id === `${h.workload().id}-search`);
input.value = "second";
input.listeners.input();
input.listeners.keydown({ key: "Enter", preventDefault() {} });
await flush();
assert.equal(h.workload().value, "456");
assert.equal(h.calls[1].message.workloadId, "456");
assert.ok(h.nodes().filter(node => node.attributes.role === "combobox" && node !== input).every(node => node.disabled));
});
test("multiple workloads require selection, share the lookup and ignore an older member response", async () => {
const h = optionsSetup([{ RevnId: "123", WorkloadName_c: "First" }, { RevnId: "456", WorkloadName_c: "Second" }, { RevnId: "123" }]);
h.open();
await flush();
assert.equal(h.calls.length, 1);
assert.equal(h.form().attributes["data-state"], "waiting");
const workload = h.workload();
assert.equal(workload.children.length, 3, "duplicate revenue IDs do not duplicate workloads");
assert.equal(workload.parent.hidden, false);
workload.value = "123";
workload.listeners.change();
await flush();
workload.value = "456";
workload.listeners.change();
await flush();
assert.deepEqual(h.calls.slice(1).map(call => call.message.workloadId), ["123", "456"]);
h.completeLookup();
h.calls[2].resolve({ ok: true, members: [{ email: "second@example.invalid" }] });
await flush();
assert.equal(h.field("owner").children[1].value, "second@example.invalid");
h.calls[1].resolve({ ok: true, members: [{ email: "first@example.invalid" }] });
await flush();
assert.equal(h.field("owner").children[1].value, "second@example.invalid");
assert.equal(h.form().attributes["data-state"], "ready");
});
test("errors remain local, register diagnostics under Actions and can be retried without duplicate requests", async () => {
const h = optionsSetup();
h.open();
await flush();
h.completeLookup();
h.calls[1].resolve({ ok: false, error: "Synthetic HTTP 403", debugRequests: [{
name: "requestActionTeamMembers", status: "failed", httpStatus: 403, startedAt: "2026-09-28T12:00:00Z",
completedAt: "2026-09-28T12:00:01Z", durationMs: 1000, requestHeaders: { "Wwb-Provider-Authorization": "(present)" }
}] });
await flush();
assert.equal(h.form().attributes["data-state"], "error");
assert.equal(h.field("owner").disabled, true);
assert.equal(h.field("owner").children[0].textContent, "Unavailable");
const retry = h.nodes().find(node => node.textContent === "Retry");
assert.equal(retry.hidden, false);
assert.equal(h.app.debugState.requestLog.length, 1);
assert.equal(h.app.debugContentView.getRequestCategory(h.app.debugState.requestLog[0]), "actions");
assert.equal(h.app.debugContentView.getRequestCategory({ name: "requestActionFindLookup" }), "actions");
retry.click(); retry.click();
await flush();
assert.equal(h.calls.length, 4);
h.calls[2].resolve({ ok: true, lookups: lookupData() });
h.calls[3].resolve({ ok: true, members: [] });
await flush();
assert.equal(h.form().attributes["data-state"], "ready");
assert.equal(h.field("owner").disabled, true);
assert.equal(h.field("owner").children[0].textContent, "No options available");
assert.equal(h.field("role").disabled, false);
});
test("closing or reopening the form discards late responses and waits for fresh options", async () => {
const h = optionsSetup();
h.open();
await flush();
const previous = h.form();
h.app.workloadActionForm.close(false);
h.open();
await flush();
h.completeLookup();
h.calls[1].resolve({ ok: true, members: [{ email: "old@example.invalid" }] });
await flush();
assert.notEqual(h.form(), previous);
assert.equal(h.form().attributes["data-state"], "loading");
h.calls[2].resolve({ ok: true, lookups: lookupData() });
h.calls[3].resolve({ ok: true, members: [{ email: "fresh@example.invalid" }] });
await flush();
assert.equal(h.field("owner").children[1].value, "fresh@example.invalid");
});
test("closing before authentication finishes prevents both options requests", async () => {
const h = optionsSetup();
let finish;
const auth = new Promise(resolve => { finish = resolve; });
h.app.authContentSession.requestConsumerTokenServiceOnce = () => auth;
h.open();
h.app.workloadActionForm.close(false);
finish({ workloadActions: { value: "synthetic" } });
await flush();
assert.equal(h.calls.length, 0);
});

View File

@@ -52,7 +52,9 @@ test("workload actions reject missing credentials, invalid ids, HTTP/network/JSO
});
function contentFixture(send) {
const app = loadApplication("content", { document: { createElement: () => ({ attributes: {}, setAttribute(k, v) { this.attributes[k] = v; } }) } });
const document = { createElement: () => ({ attributes: {}, append() {}, addEventListener() {}, setAttribute(k, v) { this.attributes[k] = v; } }) };
document.createElementNS = (_, tag) => document.createElement(tag);
const app = loadApplication("content", { document });
app.currentUser.allowsCrmActivities = () => true;
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
app.authSessionState.principal = "test@example.invalid";
@@ -62,6 +64,108 @@ function contentFixture(send) {
return app;
}
test("Actions progress counts product workloads, not opportunities, even with filters", async () => {
const pending = new Map();
const app = contentFixture(message => new Promise(resolve => pending.set(message.revnId, resolve)));
const progress = [];
app.activityProgress.update = (...args) => progress.push(args);
app.opportunitiesFilterState.tableSearch = "hidden";
const a = { items: [{ RevnId: "1" }, { RevnId: "2" }] };
const b = { items: [{ RevnId: "3" }] };
app.productsContentState.opptyProductsByNumber = new Map([["A", a], ["B", b]]);
const loading = [app.opportunityWorkloadActions.loadProducts(a), app.opportunityWorkloadActions.loadProducts(b)];
await new Promise(resolve => setImmediate(resolve));
assert.equal(pending.size, 3);
pending.get("1")({ ok: true, actions: [], hasNext: false });
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(progress.at(-1), ["actions", "running", 1, 3]);
pending.get("2")({ ok: true, actions: [], hasNext: false });
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(progress.at(-1), ["actions", "running", 2, 3]);
pending.get("3")({ ok: true, actions: [], hasNext: false });
await Promise.all(loading);
assert.deepEqual(progress.at(-1), ["actions", "complete", 3, 3]);
});
test("saved response updates the workload badge, tooltip and matching cache without fetching", async () => {
const app = contentFixture(() => assert.fail("Save response must not trigger a GET"));
const writes = [];
app.activityCache.write = async (...args) => writes.push(args);
const products = { items: [{ RevnId: "123" }], cachedAt: 1000 };
app.productsContentState.opptyProductsByNumber.set("A", products);
app.productsContentState.opptyProductsByNumber.set("B", { items: [{ RevnId: "456" }] });
const action = { id: 77, name: "REVIEW", ownerEmail: "test@example.invalid", revenueId: 123, status: "COMPLETE" };
assert.equal(await app.opportunityWorkloadActions.applySavedAction("A", "123", action), true);
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions.length, 1);
assert.equal(app.opportunityWorkloadActions.getSummary("B").actions.length, 0);
assert.ok(app.opportunityWorkloadActions.createBadge("A").title.includes("ID: 77"));
assert.deepEqual(writes[0].slice(0, 3), ["actions", "123", 1000]);
assert.equal(writes[0][3][0].id, 77);
await app.opportunityWorkloadActions.applySavedAction("A", "123", { action: { ...action, status: "OPEN" } });
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions.length, 1);
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions[0].status, "OPEN");
await app.opportunityWorkloadActions.applySavedAction("A", "123", { actions: [{ ...action, id: 78, ownerEmail: "other@example.invalid" }] });
assert.equal(app.opportunityWorkloadActions.getSummary("A").actions.length, 2);
assert.equal(await app.opportunityWorkloadActions.applySavedAction("B", "123", action), false);
assert.equal(await app.opportunityWorkloadActions.applySavedAction("A", "123", null), false);
});
test("saved Action uses its email rather than display name for green badge and removes plus", async () => {
const app = contentFixture(() => assert.fail("No follow-up GET"));
app.activityCache.write = async () => {};
app.productsContentState.opptyProductsByNumber.set("A", { items: [{ RevnId: "123" }] });
const saved = { id: 90, owner: "Test User", ownerEmail: " TEST@example.invalid ", name: "REVIEW" };
await app.opportunityWorkloadActions.applySavedAction("A", "123", saved);
const badge = app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge.attributes["data-state"], "matched");
assert.equal(badge.textContent, "1");
await app.opportunityWorkloadActions.applySavedAction("A", "123", { id: 90, owner: "Test User" }, { ownerEmail: "test@example.invalid" });
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "matched");
app.authSessionState.principal = "another@example.invalid";
assert.equal(app.opportunityWorkloadActions.getSummary("A").state, "missing");
});
test("Actions completion includes queued calls, shared pending records, failures and cache writes", async () => {
const pending = [];
let finishWrite;
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
app.activityCache.read = async () => null;
app.activityCache.write = () => new Promise(resolve => { finishWrite = resolve; });
const products = { items: Array.from({ length: 5 }, (_, i) => ({ RevnId: String(i + 1) })) };
let complete = false, sharedComplete = false;
const batch = app.opportunityWorkloadActions.loadProducts(products, true, { cachedAt: 200 }).then(() => { complete = true; });
const shared = app.opportunityWorkloadActions.loadProducts(products).then(() => { sharedComplete = true; });
await flush();
assert.equal(pending.length, 4);
for (let attempt = 0; attempt < 5; attempt += 1) {
pending.slice(attempt * 4, attempt * 4 + 4).forEach(resolve => resolve({ ok: false, error: "Synthetic" }));
await flush();
}
assert.equal(pending.length, 21);
assert.equal(complete, false);
assert.equal(sharedComplete, false);
pending[20]({ ok: true, actions: [], hasNext: false });
await flush();
assert.equal(complete, false, "cache write still pending");
finishWrite();
await batch;
await shared;
assert.equal(complete, true);
assert.equal(sharedComplete, true);
});
test("Actions reset settles discarded queue entries without waiting for unstarted requests", async () => {
const pending = [];
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
const batch = app.opportunityWorkloadActions.loadProducts({ items: Array.from({ length: 8 }, (_, i) => ({ RevnId: String(i) })) });
await flush();
app.opportunityWorkloadActions.reset(false);
pending.forEach(resolve => resolve({ ok: true, actions: [], hasNext: false }));
await batch;
assert.equal(pending.length, 4);
});
test("actions aggregate all products, share RevnId queries across Reference changes and restart on reopen", async () => {
const calls = [];
const app = contentFixture(async message => {
@@ -71,18 +175,18 @@ test("actions aggregate all products, share RevnId queries across Reference chan
const products = { items: [{ RevnId: "1" }, { RevnId: 2 }, { RevnId: "1" }] };
app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products);
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ...");
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "...");
await flush();
assert.equal(calls.length, 2);
assert.equal(calls[0].authorization.value, "synthetic-wwb");
const badge = app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge.textContent, "\u2705 Act");
assert.equal(badge.textContent, "1");
assert.match(badge.title, /<script>safe<\/script>/);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
assert.equal(calls.length, 2);
app.productsContentState.opptyProductsByNumber.set("B", { items: [{ RevnId: 2 }] });
assert.equal(app.opportunityWorkloadActions.createBadge("B").textContent, "\u26d4 Act");
assert.equal(app.opportunityWorkloadActions.createBadge("B").textContent, "0");
app.opportunityWorkloadActions.reset(false);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
@@ -100,9 +204,9 @@ test("errors, missing ids and partial empty pages never appear as confirmed abse
app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "?");
app.productsContentState.opptyProductsByNumber.set("A", { items: [{}] });
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "?");
}
});
@@ -117,15 +221,17 @@ test("Act requires an owner matching tokenrelay principal while tooltip retains
app.opportunityWorkloadActions.loadProducts(products);
await flush();
const badge = () => app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge().textContent, "\u2705 Act");
assert.equal(badge().textContent, "2");
assert.equal(badge().attributes["data-state"], "matched");
assert.match(badge().title, /Other action/);
assert.match(badge().title, /My action/);
app.authSessionState.principal = "different@example.invalid";
assert.equal(badge().textContent, "\u26d4 Act", "ownerEmail must not determine ownership");
assert.equal(badge().textContent, "2");
assert.equal(badge().attributes["data-state"], "missing", "ownerEmail must not determine ownership");
assert.match(badge().title, /Other action/);
assert.match(badge().title, /My action/);
app.authSessionState.principal = "";
assert.equal(badge().textContent, "Act ?");
assert.equal(badge().textContent, "?");
});
test("general tokenrelay retains principal without changing access-token return or accepting sales-common identity", async () => {
@@ -158,7 +264,7 @@ test("actions queue caps concurrency and discards results/queued jobs from close
assert.equal(app.opportunityWorkloadActions.createBadge("A"), null);
});
test("product progress triggers Actions for cached products and ignores stale product jobs", async () => {
test("product progress triggers Actions only for fresh products and ignores stale product jobs", async () => {
let listener;
const calls = [];
const app = loadApplication("content", {
@@ -173,14 +279,19 @@ test("product progress triggers Actions for cached products and ignores stale pr
return { ok: true, actions: [], hasNext: false };
};
app.productsContentState.currentProductsRequestId = "current";
app.productActivities.begin("current", "synthetic");
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [];
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
const message = { type: app.messageTypes?.opptyProductsProgress || "opportunitiesExtension.opptyProductsProgress",
requestId: "current", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: true };
requestId: "current", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: false };
listener({ ...message, requestId: "old" });
await flush();
assert.equal(calls.length, 0);
listener(message);
await flush();
assert.equal(calls.length, 0);
listener({ ...message, optyNumber: undefined, state: "complete" });
await flush();
assert.equal(calls.length, 1);
assert.equal(calls[0].type, "opportunitiesExtension.requestWlActions");
assert.equal(calls[0].revnId, "123");