Added the Refresh All button to invalidate caches and force requests to be sent again

This commit is contained in:
2026-09-25 15:26:09 -03:00
parent 6a3e9e6adf
commit cc567c8ff1
31 changed files with 1211 additions and 143 deletions

View File

@@ -19,6 +19,16 @@ 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`
@@ -37,11 +47,19 @@ Records are linked by `OpportunityName_Id_c.OptyNumber`. A green SR badge indica
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. SR results remain in memory only. CRM SR and Actions
refresh only after actual successful product API requests, not cache reads.
Cache-only, empty and entirely failed cycles neither query nor clear activities.
In mixed cycles, Actions refreshes only workloads from fresh products and CRM SR
refreshes once. Closing and reopening the modal preserves results and does not
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.
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; SR refreshes once.
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.
@@ -53,7 +71,8 @@ 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.
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.

View File

@@ -15,6 +15,7 @@ function createMessageTypes() {
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
@@ -1134,8 +1135,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 +1163,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 +1216,7 @@ function createProductsCache({
return productsCacheState.productsCacheWritePromise;
}
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
}
// Source: src/services/fusion/products.js
@@ -1720,6 +1729,7 @@ function startBackgroundApplication(dependencies) {
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
invalidateProductsCache,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
@@ -1739,6 +1749,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return false;
}
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) => {
@@ -2014,7 +2028,7 @@ function createBackgroundApplication(overrides = {}) {
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

File diff suppressed because one or more lines are too long

View File

@@ -15,6 +15,7 @@ function createMessageTypes() {
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"
@@ -1134,8 +1135,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 +1163,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 +1216,7 @@ function createProductsCache({
return productsCacheState.productsCacheWritePromise;
}
return { getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
return { invalidate, getOpptyProducts, normalizeCacheMaxAge, getProductsCache, saveProductsCache };
}
// Source: src/services/fusion/products.js
@@ -1720,6 +1729,7 @@ function startBackgroundApplication(dependencies) {
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
invalidateProductsCache,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
@@ -1739,6 +1749,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return false;
}
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) => {
@@ -2014,7 +2028,7 @@ function createBackgroundApplication(overrides = {}) {
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

File diff suppressed because one or more lines are too long

View File

@@ -33,6 +33,9 @@ module.exports = {
"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/crm-sr-popover.js",
"src/features/opportunities/workload-actions.js",
"src/features/opportunities/fiscal-periods.js",

View File

@@ -123,7 +123,7 @@ function createBackgroundApplication(overrides = {}) {
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, invalidateProductsCache: () => productsCache.invalidate(), requestCurrentUser, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestWlActions, requestConsumerTokenService, requestConsumptionTrend, requestConsumptionPlanDetail, requestRatecardByPlan, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,

View File

@@ -1,6 +1,7 @@
/** Register synchronously; replies retain the WebExtension sendResponse contract. */
function registerBackgroundMessages({
messageTypes,
invalidateProductsCache,
getXsrfTokenCookie,
refreshXsrfCookie,
requestAccountPlans,
@@ -20,6 +21,10 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return false;
}
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) => {

View File

@@ -30,6 +30,7 @@ function createContentApplication(overrides = {}) {
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();
@@ -89,6 +90,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),
@@ -218,6 +220,8 @@ function createContentApplication(overrides = {}) {
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args), environment
});
const opportunityCrmServiceRequests = createOpportunityCrmServiceRequests({
activityCache,
reportProgress: (...args) => activityProgress.update("sr", ...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
bindPopover: (...args) => crmSrPopover.bind(...args),
closePopover: (...args) => crmSrPopover.close(...args),
@@ -325,6 +329,29 @@ 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 productActivities = createProductActivities({
productsContentState, opportunitiesQueryState,
resetCrm: () => opportunityCrmServiceRequests.reset(),
@@ -347,6 +374,8 @@ function createContentApplication(overrides = {}) {
environment
});
const opportunityWorkloadActions = createOpportunityWorkloadActions({
activityCache,
reportProgress: (...args) => activityProgress.update("actions", ...args),
allowsCrmActivities: () => currentUser.allowsCrmActivities(),
authSessionState,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
@@ -557,6 +586,9 @@ function createContentApplication(overrides = {}) {
preferencesView,
productsContentJobs,
productActivities,
activityProgress,
refreshAll,
activityCache,
productsDetails,
aiSummaryContent,
consumptionPlansAction,

View File

@@ -70,5 +70,20 @@ 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;
}
return { createThemeIcon, createSettingsIcon, createCloseIcon, createRefreshIcon };
}

View File

@@ -32,6 +32,7 @@ function createContentModal({
closeOwnerFilterPanel,
closePreferencesModal,
createSettingsIcon,
createRefreshAllButton,
debugState,
ensureExtensionStyles,
getCustomerOptions,
@@ -108,7 +109,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";
@@ -120,10 +120,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);
@@ -143,9 +139,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");
@@ -174,7 +171,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);

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

@@ -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,52 @@
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;
return { key: `opportunitiesExtension.activities.v1:${JSON.stringify([principal, kind, String(id)])}`, fetchedAt };
}
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);
const entry = result?.[scope.key];
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) } });
} catch { /* Storage failure must not discard successfully fetched data. */ }
}
return { invalidate, read, write };
}

View File

@@ -0,0 +1,38 @@
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 === "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}`;
entry.badge.title = key === "sr" ? "CRM service request records loaded" : "Workload action requests completed";
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

@@ -1,19 +1,29 @@
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, environment }) {
function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurrentPeriodRequest, bindPopover, closePopover, allowsCrmActivities, reportProgress, activityCache, 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) {
function reset() { closePopover(); generation += 1; pending = null; status = "idle"; byOpportunity = new Map(); reportProgress("idle"); }
function load(token, version, cacheOptions = null) {
if (!allowsCrmActivities() || !isCurrentPeriodRequest(version)) return;
if (!pending) pending = run(token, version, generation);
if (!pending) pending = run(token, version, generation, cacheOptions);
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);
const cached = cacheOptions ? await activityCache.read("sr", "all", cacheOptions.cachedAt) : null;
if (!isActive()) return;
if (!cached && cacheOptions?.cacheOnly) { status = "idle"; return; }
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;
const grouped = new Map();
for (const item of items) {
@@ -25,8 +35,9 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre
}
byOpportunity = grouped;
status = "ready";
if (!cached) reportProgress("complete", items.length, items.length);
} catch {
if (isActive()) status = "error";
if (isActive()) { status = "error"; reportProgress("error"); }
}
}
function createBadge(optyNumber) {

View File

@@ -9,7 +9,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
if (!current || current.completed || current.requestId !== requestId
|| productsContentState.currentProductsRequestId !== requestId) return;
current.completed = true;
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error && !result.fromCache);
const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error);
if (!products.length) return;
resetCrm();
const seen = new Set();
@@ -20,9 +20,10 @@ function createProductActivities({ productsContentState, opportunitiesQueryState
seen.add(id);
return true;
});
loadActions({ ...result, items }, true);
loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true });
}
Promise.resolve(loadCrm(current.token, current.version)).finally(() => {
const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0));
Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).finally(() => {
if (cycle === current && productsContentState.currentProductsRequestId === requestId) render();
}).catch(() => {});
}

View File

@@ -0,0 +1,50 @@
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.productsLastCachedAt = 0;
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

@@ -311,3 +311,41 @@
.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; }
}

View File

@@ -1,24 +1,36 @@
function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivities, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, environment }) {
appendBackgroundRequestLog, queueProductsTableRender, productsContentState, messageTypes, reportProgress, activityCache, 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) {
session.enabled = false;
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, refresh = false) {
function loadProducts(products, refresh = false, cacheOptions = null) {
if (!session.enabled || !allowsCrmActivities()) return;
if ([...session.progress].every(record => record.status !== "loading")) session.progress.clear();
for (const revnId of idsFor(products)) {
if (!refresh && session.records.has(revnId)) continue;
session.progress.delete(session.records.get(revnId));
const record = { status: "loading", actions: [], hasNext: false };
session.records.set(revnId, record);
session.queue.push({ revnId, record });
if (!cacheOptions) session.progress.add(record);
session.queue.push({ revnId, record, cacheOptions });
}
updateProgress(session);
pump(session);
}
function reopen() {
@@ -33,23 +45,35 @@ 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);
const authorizations = await requestConsumerTokenServiceOnce();
if (!current.enabled || current !== session || current.records.get(revnId) !== record || !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;
appendBackgroundRequestLog(result?.debugRequests);
if (!result?.ok) throw new Error(result?.error || "Unable to load workload actions.");
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();
if (current.enabled && current === session) { updateProgress(current); queueProductsTableRender(); }
}
}
function createBadge(optyNumber) {

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

@@ -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

@@ -18,7 +18,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
"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;
while (isActive()) {
@@ -41,6 +41,7 @@ function createFusionCrmServiceRequests({ isCurrentPeriodRequest,
}
if (!isActive()) return null;
items.push(...data.items);
onProgress(items.length, Number.isFinite(data.totalResults) && data.totalResults >= items.length ? data.totalResults : null);
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);

View File

@@ -12,6 +12,7 @@ function createMessageTypes() {
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestWlActions: "opportunitiesExtension.requestWlActions",
requestCurrentUser: "opportunitiesExtension.requestCurrentUser",
invalidateProductsCache: "opportunitiesExtension.invalidateProductsCache",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend",
requestConsumptionPlanDetail: "opportunitiesExtension.requestConsumptionPlanDetail",
requestRatecardByPlan: "opportunitiesExtension.requestRatecardByPlan"

View File

@@ -0,0 +1,92 @@
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]: saved[key] }),
set: async values => Object.assign(saved, JSON.parse(JSON.stringify(values)))
};
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)
} } }
};
const app = loadApplication("content", { ...api, document: { createElement: () => ({ setAttribute() {}, addEventListener() {} }) } });
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 };
}
for (const family of ["chrome", "firefox"]) {
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, "\u2705 SR (4h)");
assert.equal(restored.app.opportunityWorkloadActions.createBadge("OP1").textContent, "\u2705 Act");
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, "Act ?");
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("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, "\u2705 SR (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,62 @@
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");
});
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]);
pending[1]({ ok: false, error: "Synthetic failure" });
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(progress.at(-1), ["actions", "complete-with-errors", 2, 2]);
});

View File

@@ -210,7 +210,8 @@ 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, "");
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/);

View File

@@ -12,8 +12,8 @@ function setup() {
const calls = [];
app.opportunityCrmServiceRequests.reset = () => calls.push("reset SR");
app.opportunityWorkloadActions.reset = () => calls.push("reset Act");
app.opportunityCrmServiceRequests.load = async token => calls.push(`SR ${token}`);
app.opportunityWorkloadActions.loadProducts = products => calls.push(products.items[0]?.RevnId || "empty products");
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") {
@@ -69,19 +69,19 @@ test("partial failures use successful products only; closed, failed and empty cy
assert.equal(h.calls.includes("closed"), false);
});
test("cached products never trigger or reset activities; mixed cycles use only fresh products", async () => {
test("cached products restore activities without network; mixed cycles fetch only fresh products", 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, []);
assert.deepEqual(h.calls, ["reset SR"]);
const mixed = h.start();
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, ["reset SR", "fresh", "SR token"]);
assert.deepEqual(h.calls, ["reset SR", "reset SR", "fresh", "SR token"]);
});
test("reopening an existing modal does not reload products or reset/query activities", async () => {

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

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