Implemented the customer installed-base view

This commit is contained in:
2026-09-22 16:30:07 -03:00
parent 8d73954362
commit 3ee8eb6f21
22 changed files with 1102 additions and 19 deletions

View File

@@ -31,12 +31,23 @@ Do not edit files under `dist` directly. Both targets use Manifest V3, with a
Chromium service worker and Firefox background scripts. The build composes classic
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
context. It serializes `src/content/styles/redwood.css` followed by the scoped
`src/features/debug/styles.css` and `src/features/opportunities/styles.css` at
`src/features/debug/styles.css`, `src/features/opportunities/styles.css` and
`src/features/install-base/styles.css` at
build time. There is no
runtime module loader, browser-side eval, ES-module requirement or new dependency.
## Tests and Maintenance
The Installed Base action sits beside customer consumption below Customer. It
opens a skeleton dialog, then calls `requestAccountSummary` with ACCP-TS and uses
the returned `registryId` in `requestInstallBase`. The latter uses the SAIB-TS
`uiaasHeader.label` and `uiaasHeader.value`, and loads pages of 25 until `hasMore`
is false. Missing credentials, invalid collections and stalled pagination show
an error rather than partial results. These requests appear in debug; credential
headers in this flow are masked before delivery to the content script. No new
cache, permissions or session recovery logic is introduced. Amounts use two
decimal places without inferring a currency from the separate LC fields.
The opportunities table places product counts below Name and the customer
consumption action below Customer, without a separate Actions column. Compact
columns retain their values; Name and Customer share the remaining width while
@@ -54,7 +65,7 @@ duration or error. Cache hits do not create HTTP entries. Product diagnostics
mask authorization and sensitive response headers before messaging the source
tab/frame; this change adds no timeout, retry or authentication behavior.
Debug tabs group Authentication, Opportunities, Products, AI and Consumption
Debug tabs group Authentication, Opportunities, Products, AI, Consumption and Installed Base
requests; unknown request types appear under Other. Only categories with entries
are shown. All preserves the complete timeline. Every tab sorts by request start
time, retaining original request IDs and showing start times in collapsed rows.

View File

@@ -11,6 +11,7 @@ function createMessageTypes() {
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
});
}
@@ -156,7 +157,8 @@ function createAuthConsumerSession({
ok: true,
authorizations: {
accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization
accountPlans: accountPlansAuthorization,
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
},
debugRequests
};
@@ -438,6 +440,68 @@ function createSpaAccounts({
return { requestAccountPlans };
}
// Source: src/services/spa/install-base.js
function createSpaInstallBase({ ACCOUNT_SUMMARY_URL, fetchJson, findNestedObject, normalizeMessageAuthorization }) {
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-installbase-provider/service/account/";
async function requestInstallBase(message) {
const debugRequests = [];
try {
const partyNumber = String(message.partyNumber || "").trim();
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
const installAuth = normalizeMessageAuthorization(message.authorizations?.installBase, "");
if (!partyNumber) throw new Error("Customer Party Number is required.");
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
if (!installAuth.name || !installAuth.value) throw new Error("SAIB-TS uiaasHeader.label/value is unavailable.");
const summary = await fetchJson(ACCOUNT_SUMMARY_URL, {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", [summaryAuth.name]: summaryAuth.value },
body: JSON.stringify([{ key: "regId", value: partyNumber }])
}, "requestAccountSummary", debugRequests);
const account = findNestedObject(summary, value =>
(typeof value.registryId === "string" || typeof value.registryId === "number") && String(value.registryId).trim() !== "");
if (!account) throw new Error("registryId was not found in requestAccountSummary.");
const payload = {
text: "", accountHierarchy: "single", accountScope: "all", productScope: "all",
installStatus: ["active", "inactive", "unsupported"], productIds: null,
contractEndDate: { to: null, from: null }, contractStartDate: { to: null, from: null },
purchaseStartDate: { to: null, from: null }, registryIds: [], gecPartyNumbers: null
};
const items = [];
let offset = 0;
// Bound malformed pagination without silently returning a partial result.
for (let page = 0; page < 1000; page += 1) {
const url = `${endpoint}${encodeURIComponent(String(account.registryId).trim())}?orderBy=currentSupport%3Adesc&limit=25&offset=${offset}`;
const result = await fetchJson(url, {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", [installAuth.name]: installAuth.value },
body: JSON.stringify(payload)
}, "requestInstallBase", debugRequests);
if (!result || !Array.isArray(result.items) || typeof result.hasMore !== "boolean"
|| result.items.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
throw new Error("requestInstallBase returned an invalid collection.");
}
if (result.offset !== undefined && result.offset !== offset) throw new Error("requestInstallBase returned an unexpected offset.");
items.push(...result.items);
if (!result.hasMore) return { ok: true, items, debugRequests };
if (!result.items.length) throw new Error("requestInstallBase pagination made no progress.");
offset += result.items.length;
}
throw new Error("requestInstallBase pagination limit reached.");
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
// The provider chooses the credential header name; never rely on its spelling for redaction.
for (const entry of debugRequests) {
for (const key of Object.keys(entry.requestHeaders || {})) {
if (!/^(accept|content-type)$/i.test(key)) entry.requestHeaders[key] = "(present)";
}
}
}
}
return { requestInstallBase };
}
// Source: src/services/spa/consumption.js
function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS,
@@ -1448,6 +1512,7 @@ function registerBackgroundMessages({
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestConsumerTokenService,
requestConsumptionTrend,
requestOpptyProducts,
@@ -1527,6 +1592,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if (message.type === messageTypes.requestInstallBase) {
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load installed base.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestAccountPlans) {
requestAccountPlans(message)
.then(sendResponse)
@@ -1596,6 +1669,12 @@ function createBackgroundApplication(overrides = {}) {
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaInstallBase = createSpaInstallBase({
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaConsumption = createSpaConsumption({
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
@@ -1659,12 +1738,13 @@ function createBackgroundApplication(overrides = {}) {
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,
@@ -1672,6 +1752,7 @@ function createBackgroundApplication(overrides = {}) {
authConsumerSession,
authSpaCredentials,
spaAccounts,
spaInstallBase,
spaConsumption,
spaResponseShapes,
httpBackgroundJson,

File diff suppressed because one or more lines are too long

View File

@@ -11,6 +11,7 @@ function createMessageTypes() {
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
});
}
@@ -156,7 +157,8 @@ function createAuthConsumerSession({
ok: true,
authorizations: {
accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization
accountPlans: accountPlansAuthorization,
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
},
debugRequests
};
@@ -438,6 +440,68 @@ function createSpaAccounts({
return { requestAccountPlans };
}
// Source: src/services/spa/install-base.js
function createSpaInstallBase({ ACCOUNT_SUMMARY_URL, fetchJson, findNestedObject, normalizeMessageAuthorization }) {
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-installbase-provider/service/account/";
async function requestInstallBase(message) {
const debugRequests = [];
try {
const partyNumber = String(message.partyNumber || "").trim();
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
const installAuth = normalizeMessageAuthorization(message.authorizations?.installBase, "");
if (!partyNumber) throw new Error("Customer Party Number is required.");
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
if (!installAuth.name || !installAuth.value) throw new Error("SAIB-TS uiaasHeader.label/value is unavailable.");
const summary = await fetchJson(ACCOUNT_SUMMARY_URL, {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", [summaryAuth.name]: summaryAuth.value },
body: JSON.stringify([{ key: "regId", value: partyNumber }])
}, "requestAccountSummary", debugRequests);
const account = findNestedObject(summary, value =>
(typeof value.registryId === "string" || typeof value.registryId === "number") && String(value.registryId).trim() !== "");
if (!account) throw new Error("registryId was not found in requestAccountSummary.");
const payload = {
text: "", accountHierarchy: "single", accountScope: "all", productScope: "all",
installStatus: ["active", "inactive", "unsupported"], productIds: null,
contractEndDate: { to: null, from: null }, contractStartDate: { to: null, from: null },
purchaseStartDate: { to: null, from: null }, registryIds: [], gecPartyNumbers: null
};
const items = [];
let offset = 0;
// Bound malformed pagination without silently returning a partial result.
for (let page = 0; page < 1000; page += 1) {
const url = `${endpoint}${encodeURIComponent(String(account.registryId).trim())}?orderBy=currentSupport%3Adesc&limit=25&offset=${offset}`;
const result = await fetchJson(url, {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", [installAuth.name]: installAuth.value },
body: JSON.stringify(payload)
}, "requestInstallBase", debugRequests);
if (!result || !Array.isArray(result.items) || typeof result.hasMore !== "boolean"
|| result.items.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
throw new Error("requestInstallBase returned an invalid collection.");
}
if (result.offset !== undefined && result.offset !== offset) throw new Error("requestInstallBase returned an unexpected offset.");
items.push(...result.items);
if (!result.hasMore) return { ok: true, items, debugRequests };
if (!result.items.length) throw new Error("requestInstallBase pagination made no progress.");
offset += result.items.length;
}
throw new Error("requestInstallBase pagination limit reached.");
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
// The provider chooses the credential header name; never rely on its spelling for redaction.
for (const entry of debugRequests) {
for (const key of Object.keys(entry.requestHeaders || {})) {
if (!/^(accept|content-type)$/i.test(key)) entry.requestHeaders[key] = "(present)";
}
}
}
}
return { requestInstallBase };
}
// Source: src/services/spa/consumption.js
function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS,
@@ -1448,6 +1512,7 @@ function registerBackgroundMessages({
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestConsumerTokenService,
requestConsumptionTrend,
requestOpptyProducts,
@@ -1527,6 +1592,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if (message.type === messageTypes.requestInstallBase) {
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load installed base.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestAccountPlans) {
requestAccountPlans(message)
.then(sendResponse)
@@ -1596,6 +1669,12 @@ function createBackgroundApplication(overrides = {}) {
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaInstallBase = createSpaInstallBase({
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaConsumption = createSpaConsumption({
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
@@ -1659,12 +1738,13 @@ function createBackgroundApplication(overrides = {}) {
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,
@@ -1672,6 +1752,7 @@ function createBackgroundApplication(overrides = {}) {
authConsumerSession,
authSpaCredentials,
spaAccounts,
spaInstallBase,
spaConsumption,
spaResponseShapes,
httpBackgroundJson,

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,7 @@ 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"]
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
}

View File

@@ -40,6 +40,8 @@ module.exports = {
"src/features/products/details.js",
"src/features/ai-summary/content.js",
"src/features/consumption/plans-action.js",
"src/features/install-base/action.js",
"src/features/install-base/view.js",
"src/features/consumption/plans-view.js",
"src/features/consumption/trend-controller.js",
"src/features/consumption/chart.js",
@@ -56,6 +58,7 @@ module.exports = {
"src/auth/consumer-session.js",
"src/auth/spa-credentials.js",
"src/services/spa/accounts.js",
"src/services/spa/install-base.js",
"src/services/spa/consumption.js",
"src/services/spa/response-shapes.js",
"src/services/http/background-json.js",

View File

@@ -58,7 +58,8 @@ function createAuthConsumerSession({
ok: true,
authorizations: {
accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization
accountPlans: accountPlansAuthorization,
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
},
debugRequests
};

View File

@@ -34,6 +34,12 @@ function createBackgroundApplication(overrides = {}) {
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaInstallBase = createSpaInstallBase({
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaConsumption = createSpaConsumption({
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
@@ -97,12 +103,13 @@ function createBackgroundApplication(overrides = {}) {
const getXsrfTokenCookie = (...args) => authXsrfCookies.getXsrfTokenCookie(...args);
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,
@@ -110,6 +117,7 @@ function createBackgroundApplication(overrides = {}) {
authConsumerSession,
authSpaCredentials,
spaAccounts,
spaInstallBase,
spaConsumption,
spaResponseShapes,
httpBackgroundJson,

View File

@@ -4,6 +4,7 @@ function registerBackgroundMessages({
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestConsumerTokenService,
requestConsumptionTrend,
requestOpptyProducts,
@@ -83,6 +84,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if (message.type === messageTypes.requestInstallBase) {
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load installed base.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestAccountPlans) {
requestAccountPlans(message)
.then(sendResponse)

View File

@@ -76,6 +76,7 @@ function createContentApplication(overrides = {}) {
applyTheme: (...args) => contentTheme.applyTheme(...args),
authSessionState,
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
@@ -197,6 +198,7 @@ function createContentApplication(overrides = {}) {
OPPORTUNITIES_TABLE_ID: contracts.OPPORTUNITIES_TABLE_ID,
OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
@@ -317,6 +319,20 @@ function createContentApplication(overrides = {}) {
requestTokenRelaySalesCommon: (...args) => authSalesCommon.requestTokenRelaySalesCommon(...args),
environment
});
const installBaseView = createInstallBaseView({
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
environment
});
const installBaseAction = createInstallBaseAction({
messageTypes,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
openInstallBaseModal: (...args) => installBaseView.openInstallBaseModal(...args),
environment
});
const consumptionPlansAction = createConsumptionPlansAction({
messageTypes,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
@@ -425,6 +441,8 @@ function createContentApplication(overrides = {}) {
productsDetails,
aiSummaryContent,
consumptionPlansAction,
installBaseAction,
installBaseView,
consumptionPlansView,
consumptionTrendController,
consumptionChart,

View File

@@ -26,6 +26,7 @@ function createContentModal({
applyTheme,
authSessionState,
closeAccountPlansModal,
closeInstallBaseModal,
closeCustomerFilterPanel,
closeOwnerFilterPanel,
closePreferencesModal,
@@ -432,6 +433,7 @@ function createContentModal({
if (modal) {
closePreferencesModal();
closeAccountPlansModal();
closeInstallBaseModal();
modal.hidden = true;
}
@@ -460,6 +462,10 @@ function createContentModal({
}
if (event.key === "Escape") {
if (closeInstallBaseModal()) {
event.preventDefault();
return;
}
const accountPlansModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
if (accountPlansModal && !accountPlansModal.hidden) {

View File

@@ -11,7 +11,7 @@ function createDebugContentView({
const categories = [
["all", "All"], ["authentication", "Authentication"],
["opportunities", "Opportunities"], ["products", "Products"],
["ai", "AI"], ["consumption", "Consumption"], ["other", "Other"]
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"], ["other", "Other"]
];
let selectedCategory = "all";
const expandedIds = new Set();
@@ -21,6 +21,7 @@ function createDebugContentView({
if (entry.name === "requestOpportunities") return "opportunities";
if (entry.name === "requestOpptyProducts") return "products";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService)/.test(entry.name)) return "consumption";
return "other";
}

View File

@@ -0,0 +1,53 @@
function createInstallBaseAction({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, openInstallBaseModal, environment }) {
const { document } = environment;
const pending = new Map();
async function load(partyNumber) {
const authorizations = await requestConsumerTokenServiceOnce();
const response = await sendRuntimeMessage({
type: messageTypes.requestInstallBase, partyNumber,
authorizations: { accountSummary: authorizations.accountSummary, installBase: authorizations.installBase }
});
appendBackgroundRequestLog(response?.debugRequests);
if (!response?.ok) throw new Error(response?.error || "Unable to load installed base.");
return response.items;
}
function createInstallBaseButton(opportunity) {
const button = document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-action-button";
button.title = "View customer installed base";
button.setAttribute("aria-label", `View installed base for ${opportunity.CustomerAccount?.PartyUniqueName || "customer"}`);
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", "M4 3h16v7H4z M4 14h16v7H4z M7 6.5h.01 M7 17.5h.01 M11 6.5h6 M11 17.5h6 M8 10v4 M16 10v4");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.7");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
icon.append(path);
button.append(icon);
button.addEventListener("click", async () => {
const view = openInstallBaseModal(opportunity, button);
const partyNumber = String(opportunity.CustomerAccount?.PartyNumber || "");
try {
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
if (!pending.has(partyNumber)) {
const request = load(partyNumber).finally(() => pending.delete(partyNumber));
pending.set(partyNumber, request);
}
view.render(await pending.get(partyNumber));
} catch (error) {
view.render([], error.message || "Unable to load installed base.");
}
});
return button;
}
return { createInstallBaseButton };
}

View File

@@ -0,0 +1,43 @@
.opportunities-extension-account-plans-dialog.opportunities-extension-install-base-dialog {
width: min(1700px, 100%);
}
.opportunities-extension-install-base-content {
min-width: 0;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
.opportunities-extension-install-base-message { margin: 16px; font-size: 13px; }
.opportunities-extension-install-base-table {
width: 100%; min-width: 1400px; table-layout: fixed; border-collapse: collapse; font-size: 12px;
}
.opportunities-extension-install-base-table col { width: 7%; }
.opportunities-extension-install-base-table .install-base-col-partyName { width: 12%; }
.opportunities-extension-install-base-table .install-base-col-productGroupName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-productName { width: 13%; }
.opportunities-extension-install-base-table .install-base-col-quantity { width: 5%; }
.opportunities-extension-install-base-table .install-base-col-quantityType { width: 8%; }
.opportunities-extension-install-base-table th,
.opportunities-extension-install-base-table td {
padding: 12px 8px; text-align: left; vertical-align: middle; overflow-wrap: anywhere;
border-bottom: 1px solid #e8e5e1; line-height: 1.4;
}
.opportunities-extension-install-base-table th { position: sticky; top: 0; z-index: 1; background: #faf9f8; }
.opportunities-extension-install-base-table td[data-format="amount"],
.opportunities-extension-install-base-table td[data-format="quantity"] { text-align: right; font-variant-numeric: tabular-nums; }
.opportunities-extension-install-base-table td[data-format="date"] { white-space: nowrap; }
.opportunities-extension-install-base-status {
display: inline-block; padding: 3px 6px; border-radius: 12px; font-size: 10px; font-weight: 600;
color: #5f5a55; background: #eceae8; white-space: nowrap;
}
.opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #17693a; background: #e1f1e5; }
.opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #855300; background: #fff0d6; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th { background: #353331; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-table td { border-color: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status { color: #dedbd7; background: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status[data-status="ACTIVE"] { color: #b8e4c6; background: #214d34; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-install-base-status[data-status="UNSUPPORTED"] { color: #ffdfa2; background: #614418; }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-install-base-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
}

View File

@@ -0,0 +1,126 @@
function createInstallBaseView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, environment }) {
const { document } = environment;
let active = null;
const columns = [
["partyName", "Customer"], ["productGroupName", "Product Group"], ["productName", "Product"],
["currentSupport", "Current Support", "amount"], ["orderNumber", "Order Number"],
["supportStatus", "Support Status", "status"], ["quantityType", "Quantity Type"],
["quantity", "Quantity", "quantity"], ["supportStartDate", "Support Start Date", "date"],
["supportEndDate", "Support End Date", "date"], ["licenseValue", "License Value", "amount"],
["shipDate", "Ship Date", "date"]
];
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function closeInstallBaseModal() {
if (!active) return false;
const previous = active;
active = null;
previous.modal.remove();
if (previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createTable(items, loading = false) {
const table = element("table", "opportunities-extension-install-base-table");
table.setAttribute("aria-label", "Installed base items");
const head = element("thead");
const header = element("tr");
const group = element("colgroup");
columns.forEach(([key, label]) => {
const col = element("col");
col.className = `install-base-col-${key}`;
group.append(col);
const th = element("th", "", label);
th.scope = "col";
header.append(th);
});
head.append(header);
const body = element("tbody");
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => {
const row = element("tr");
columns.forEach(([key, , type]) => {
const cell = element("td");
if (loading) {
const bar = element("span", "opportunities-extension-account-plans-skeleton-bar");
bar.setAttribute("aria-hidden", "true");
cell.append(bar);
} else {
const value = item[key];
const missing = value === null || value === undefined || value === "";
let text = missing ? "-" : String(value).trim();
if (!missing && type === "date") text = formatOracleResponseDate(value);
if (!missing && (type === "amount" || type === "quantity") && Number.isFinite(Number(value))) {
text = new Intl.NumberFormat("en-US", {
minimumFractionDigits: type === "amount" ? 2 : 0, maximumFractionDigits: type === "amount" ? 2 : 4
}).format(Number(value));
}
if (type === "status") {
const badge = element("span", "opportunities-extension-install-base-status", text);
badge.dataset.status = text.toUpperCase();
cell.append(badge);
} else cell.textContent = text;
if (type) cell.dataset.format = type;
}
row.append(cell);
});
body.append(row);
});
table.append(group, head, body);
return table;
}
function openInstallBaseModal(opportunity, trigger) {
closeInstallBaseModal();
const overlay = document.getElementById(MODAL_ID);
if (!overlay || overlay.hidden) return { render() {} };
const modal = element("section", "opportunities-extension-account-plans-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-busy", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-install-base-title");
const dialog = element("div", "opportunities-extension-account-plans-dialog opportunities-extension-install-base-dialog");
const header = element("header", "opportunities-extension-account-plans-header");
const heading = element("div");
const title = element("h2", "", "Installed Base");
title.id = "opportunities-extension-install-base-title";
heading.append(title, element("p", "", opportunity.CustomerAccount?.PartyUniqueName || "Customer"));
const close = element("button", "opportunities-extension-icon-button");
close.type = "button";
close.title = "Close";
close.setAttribute("aria-label", "Close installed base");
close.append(createCloseIcon());
close.addEventListener("click", closeInstallBaseModal);
header.append(heading, close);
const content = element("div", "opportunities-extension-install-base-content");
content.tabIndex = 0;
content.setAttribute("role", "region");
content.setAttribute("aria-label", "Installed base results");
const status = element("p", "opportunities-extension-install-base-message", "Loading installed base...");
status.setAttribute("role", "status");
content.append(status, createTable([], true));
dialog.append(header, content);
modal.append(dialog);
overlay.append(modal);
active = { modal, trigger };
modal.addEventListener("keydown", event => {
if (event.key === "Tab") {
event.preventDefault();
(document.activeElement === close ? content : close).focus();
}
});
close.focus();
return { render(items, error = "") {
// A closed or replaced dialog must never receive a late response.
if (active?.modal !== modal || overlay.hidden) return;
modal.setAttribute("aria-busy", "false");
content.textContent = "";
status.textContent = error || (items.length ? `${items.length} installed base items` : "No installed base items found for this customer.");
status.setAttribute("role", error ? "alert" : "status");
content.append(status);
if (!error && items.length) content.append(createTable(items));
} };
}
return { openInstallBaseModal, closeInstallBaseModal, createTable };
}

View File

@@ -4,6 +4,7 @@ function createOpportunitiesTable({
OPPORTUNITIES_TABLE_ID,
OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton,
createInstallBaseButton,
createCopyOpportunityButton,
createDetailLink,
createProductsCountButton,
@@ -148,7 +149,7 @@ function createOpportunitiesTable({
);
const customerActions = document.createElement("span");
customerActions.className = "opportunities-extension-cell-actions";
customerActions.append(createCloudConsumptionButton(item));
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item));
customerCellContent.append(customerLink, customerActions);
cell.append(customerCellContent);
} else {

View File

@@ -0,0 +1,60 @@
function createSpaInstallBase({ ACCOUNT_SUMMARY_URL, fetchJson, findNestedObject, normalizeMessageAuthorization }) {
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-installbase-provider/service/account/";
async function requestInstallBase(message) {
const debugRequests = [];
try {
const partyNumber = String(message.partyNumber || "").trim();
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
const installAuth = normalizeMessageAuthorization(message.authorizations?.installBase, "");
if (!partyNumber) throw new Error("Customer Party Number is required.");
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
if (!installAuth.name || !installAuth.value) throw new Error("SAIB-TS uiaasHeader.label/value is unavailable.");
const summary = await fetchJson(ACCOUNT_SUMMARY_URL, {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", [summaryAuth.name]: summaryAuth.value },
body: JSON.stringify([{ key: "regId", value: partyNumber }])
}, "requestAccountSummary", debugRequests);
const account = findNestedObject(summary, value =>
(typeof value.registryId === "string" || typeof value.registryId === "number") && String(value.registryId).trim() !== "");
if (!account) throw new Error("registryId was not found in requestAccountSummary.");
const payload = {
text: "", accountHierarchy: "single", accountScope: "all", productScope: "all",
installStatus: ["active", "inactive", "unsupported"], productIds: null,
contractEndDate: { to: null, from: null }, contractStartDate: { to: null, from: null },
purchaseStartDate: { to: null, from: null }, registryIds: [], gecPartyNumbers: null
};
const items = [];
let offset = 0;
// Bound malformed pagination without silently returning a partial result.
for (let page = 0; page < 1000; page += 1) {
const url = `${endpoint}${encodeURIComponent(String(account.registryId).trim())}?orderBy=currentSupport%3Adesc&limit=25&offset=${offset}`;
const result = await fetchJson(url, {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", [installAuth.name]: installAuth.value },
body: JSON.stringify(payload)
}, "requestInstallBase", debugRequests);
if (!result || !Array.isArray(result.items) || typeof result.hasMore !== "boolean"
|| result.items.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
throw new Error("requestInstallBase returned an invalid collection.");
}
if (result.offset !== undefined && result.offset !== offset) throw new Error("requestInstallBase returned an unexpected offset.");
items.push(...result.items);
if (!result.hasMore) return { ok: true, items, debugRequests };
if (!result.items.length) throw new Error("requestInstallBase pagination made no progress.");
offset += result.items.length;
}
throw new Error("requestInstallBase pagination limit reached.");
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
// The provider chooses the credential header name; never rely on its spelling for redaction.
for (const entry of debugRequests) {
for (const key of Object.keys(entry.requestHeaders || {})) {
if (!/^(accept|content-type)$/i.test(key)) entry.requestHeaders[key] = "(present)";
}
}
}
}
return { requestInstallBase };
}

View File

@@ -8,6 +8,7 @@ function createMessageTypes() {
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
});
}

View File

@@ -93,7 +93,7 @@ test("tabs support arrows, Home and End with a single selected tab and labelled
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"]
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.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"));

159
tests/installBase.test.cjs Normal file
View File

@@ -0,0 +1,159 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response, plain } = require("./support/characterization.cjs");
const { loadApplication } = require("./support/application.cjs");
const authorizations = {
accountSummary: { name: "Account-Ti-Authorization", value: "synthetic-summary" },
installBase: { name: "synthetic-provider-header", value: "synthetic-installed" }
};
const message = { type: "opportunitiesExtension.requestInstallBase", partyNumber: "customer-123", authorizations };
for (const family of ["chrome", "firefox"]) {
test(`${family}: summary registryId, SAIB header, payload, pagination and protected 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: { label: "Account-Ti-Authorization", value: "synthetic-summary" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { label: "G2m-Authorization", value: "synthetic-consumption" } },
{ tokenService: "SAIB-TS", uiaasHeader: { label: "synthetic-provider-header", value: "synthetic-installed" } }
]);
if (url.endsWith("accountsummary")) return response({ id: "NOT-THE-REGISTRY", registryId: "registry/test" });
const offset = Number(new URL(url).searchParams.get("offset"));
return response({ items: [{ productName: `Product ${offset}` }], offset, limit: 25, hasMore: offset === 0 });
} });
const tokens = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
assert.deepEqual(tokens.authorizations.installBase, { ...authorizations.installBase, expires: "" });
const result = await h.dispatch({ ...message, authorizations: tokens.authorizations });
assert.equal(result.ok, true);
assert.equal(result.items.length, 2);
assert.deepEqual(JSON.parse(calls[1].options.body), [{ key: "regId", value: "customer-123" }]);
assert.equal(calls[1].options.headers[authorizations.accountSummary.name], "synthetic-summary");
for (const [index, call] of calls.slice(2).entries()) {
assert.ok(call.url.includes(`/account/registry%2Ftest?orderBy=currentSupport%3Adesc&limit=25&offset=${index}`));
assert.equal(call.options.method, "POST");
assert.equal(call.options.credentials, "include");
assert.equal(call.options.headers[authorizations.installBase.name], "synthetic-installed");
assert.deepEqual(JSON.parse(call.options.body), {
text: "", accountHierarchy: "single", accountScope: "all", productScope: "all",
installStatus: ["active", "inactive", "unsupported"], productIds: null,
contractEndDate: { to: null, from: null }, contractStartDate: { to: null, from: null },
purchaseStartDate: { to: null, from: null }, registryIds: [], gecPartyNumbers: null
});
}
assert.deepEqual(result.debugRequests.map(entry => entry.name), ["requestAccountSummary", "requestInstallBase", "requestInstallBase"]);
assert.doesNotMatch(JSON.stringify(result.debugRequests), /synthetic-installed|synthetic-summary/);
});
}
test("missing optional SAIB does not break existing consumer token flow", async () => {
const h = loadBackground({ fetch: async () => response([
{ tokenService: "ACCP-TS", uiaasHeader: { value: "synthetic-summary" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption" } }
]) });
const tokens = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
assert.equal(tokens.ok, true);
const result = await h.dispatch({ ...message, authorizations: tokens.authorizations });
assert.equal(result.ok, false);
assert.match(result.error, /SAIB-TS/);
assert.equal(result.debugRequests.length, 0);
});
test("invalid registry, pagination, HTTP, JSON and network failures preserve error/debug envelopes", async () => {
for (const kind of ["registry", "stalled", "offset", "collection", "http", "json", "network"]) {
const h = loadBackground({ fetch: async url => {
if (url.endsWith("accountsummary")) return response(kind === "registry" ? { id: "wrong" } : { registryId: "test" });
if (kind === "network") throw new Error("Synthetic network failure");
if (kind === "http") return response({}, 403);
if (kind === "json") return new Response("<html>fallback</html>");
if (kind === "collection") return response({ items: "invalid", hasMore: false });
return response({ items: [], offset: kind === "offset" ? 25 : 0, hasMore: true });
} });
const result = await h.dispatch(message);
assert.equal(result.ok, false, kind);
assert.ok(result.error, kind);
assert.equal(result.debugRequests.length, kind === "registry" ? 1 : 2);
assert.doesNotMatch(JSON.stringify(result.debugRequests), /synthetic-installed|synthetic-summary/);
}
});
class Element {
constructor(tag, document) { this.tag = tag; this.document = document; this.children = []; this.dataset = {}; this.attributes = {}; this.listeners = {}; }
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text || ""; }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { this.document.activeElement = this; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(node => node !== this); this.parent = null; }
get isConnected() { return Boolean(this.parent); }
click() { return this.listeners.click(); }
}
function setup() {
const document = { createElement: tag => new Element(tag, document), createElementNS: (_, tag) => new Element(tag, document),
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
const overlay = new Element("main", document);
const app = loadApplication("content", { document, Intl, Date });
return { app, overlay, document };
}
const customer = { CustomerAccount: { PartyNumber: "customer-123", PartyUniqueName: "Synthetic customer" } };
const walk = node => [node, ...node.children.flatMap(walk)];
test("modal shows skeleton, renders only requested fields safely and ignores closed/replaced responses", () => {
const { app, overlay, document } = setup();
const first = app.installBaseView.openInstallBaseModal(customer);
const modal = overlay.children[0];
assert.equal(modal.attributes["aria-busy"], "true");
assert.equal(walk(modal).filter(node => node.tag === "th").length, 12);
const second = app.installBaseView.openInstallBaseModal(customer);
first.render([{ productName: "Stale product" }]);
assert.equal(overlay.children[0].attributes["aria-busy"], "true");
second.render([{ productName: "<script>unsafe</script>", supportStatus: "ACTIVE", quantity: 0,
currentSupport: 1234.56, licenseValue: 0, supportStartDate: "2026-03-12", secret: "not-visible" }]);
const current = overlay.children[0];
const cells = walk(current).filter(node => node.tag === "td");
assert.equal(cells.length, 12);
assert.equal(cells[2].textContent, "<script>unsafe</script>");
assert.equal(cells[2].children.length, 0);
assert.equal(cells[3].textContent, "1,234.56");
assert.equal(cells[5].children[0].dataset.status, "ACTIVE");
assert.equal(cells[7].textContent, "0");
assert.equal(cells[10].textContent, "0.00");
assert.ok(cells[8].textContent.includes("2026"));
current.listeners.keydown({ key: "Tab", preventDefault() {} });
assert.equal(document.activeElement.attributes.role, "region");
assert.equal(app.installBaseView.closeInstallBaseModal(), true);
second.render([], "Late error");
assert.equal(overlay.children.length, 0);
assert.equal(app.installBaseView.closeInstallBaseModal(), false);
});
test("action opens before auth, deduplicates in-flight customer queries, and allows retry after failure", async () => {
const { app, overlay } = setup();
let resolveAuth;
let calls = 0;
const logs = [];
app.authContentSession.requestConsumerTokenServiceOnce = () => new Promise(resolve => { resolveAuth = resolve; });
app.platformContentMessages.sendRuntimeMessage = async message => {
calls += 1;
assert.equal(message.type, "opportunitiesExtension.requestInstallBase");
assert.equal(message.partyNumber, "customer-123");
assert.deepEqual(plain(message.authorizations), authorizations);
return { ok: false, error: "Synthetic failure", debugRequests: ["debug"] };
};
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(entries);
const button = app.installBaseAction.createInstallBaseButton(customer);
const first = button.click();
assert.equal(overlay.children[0].attributes["aria-busy"], "true");
const second = button.click();
resolveAuth(authorizations);
await Promise.all([first, second]);
assert.equal(calls, 1);
assert.equal(logs.length, 1);
assert.ok(walk(overlay.children[0]).some(node => node.textContent === "Synthetic failure"));
const retry = button.click();
resolveAuth(authorizations);
await retry;
assert.equal(calls, 2);
});

View File

@@ -61,6 +61,7 @@ test("ten columns retain their links and put product/customer controls below the
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");
assert.equal(customer.children[1].children[1].title, "View customer installed base");
assert.equal(findClass(h.cell("optyNumber"), "opportunities-extension-copy-button").title, "Copy opportunity number");
assert.equal(findClass(h.cell("name"), "opportunities-extension-action-button"), undefined);
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
@@ -153,6 +154,6 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.equal(fixed + remaining / 2 + remaining / 2, width);
assert.ok(remaining > 0);
}
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.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"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});