diff --git a/dist/chromium/content.js b/dist/chromium/content.js index 925e3fe..3a70658 100644 --- a/dist/chromium/content.js +++ b/dist/chromium/content.js @@ -1692,6 +1692,7 @@ function createOpportunitiesController({ async function requestOpportunitiesForPeriod(period) { const dateRange = getFiscalPeriodRange(period, new Date()); const requestVersion = ++opportunitiesQueryState.periodRequestVersion; + resetWorkloadActions(); await ensureCurrentUser(); if (!isCurrentPeriodRequest(requestVersion)) return; @@ -2158,20 +2159,22 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre function createProductActivities({ productsContentState, opportunitiesQueryState, loadCrm, loadActions, blockActions, render }) { let cycle = null; - function begin(requestId, token) { - cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false }; + function begin(requestId, token, opportunityNumbers = null) { + cycle = { requestId, token, numbers: opportunityNumbers && new Set(opportunityNumbers.map(String)), version: opportunitiesQueryState.periodRequestVersion, completed: false }; } function complete(requestId) { const current = cycle; if (!current || current.completed || current.requestId !== requestId || productsContentState.currentProductsRequestId !== requestId) return; current.completed = true; - const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error); + const entries = [...productsContentState.opptyProductsByNumber].filter(([number]) => !current.numbers || current.numbers.has(String(number))); + const products = entries.map(([, result]) => result).filter(result => !result.error); if (!products.length) return; const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0)); const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId && opportunitiesQueryState.periodRequestVersion === current.version; - return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => { + // Product cache hits do not guarantee that the corresponding activity cache exists. + return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: false })).then(async success => { if (!isActive()) return; if (success === false) { products.forEach(blockActions); @@ -2187,7 +2190,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState seen.add(id); return true; }); - pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true })); + pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: false })); } await Promise.allSettled(pending); if (!isActive()) return; @@ -2349,7 +2352,7 @@ function createActivityProgress({ MODAL_ID, environment }) { : state === "complete-with-errors" ? `${label} ${count} (errors)` : state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`; if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading"; - entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Workload action requests completed"; + entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters"; if (state === "complete") entry.timer = setTimeout(() => { entry.badge.setAttribute("data-fading", "true"); const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; @@ -3124,6 +3127,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie for (const id of idsFor(products)) { session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." }); } + updateProgress(session); } function pump(current) { while (current.enabled && current === session && current.running < 4 && current.queue.length) { @@ -3232,6 +3236,8 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie ].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products." : state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests."; if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled."; + const errors = [...new Set(records.filter(record => record?.error).map(record => record.error))]; + if (errors.length) badge.title += `\n${errors.join("\n")}`; if (!principal) badge.title += "\nTokenrelay principal is unavailable; ownership cannot be verified."; else if (actions.length && state === "missing") badge.title += "\nNone of the returned actions belong to the signed-in principal."; if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked."; @@ -5004,7 +5010,7 @@ function createProductsContentJobs({ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; productsContentState.currentProductsRequestId = requestId; - beginProductActivities(requestId, token); + beginProductActivities(requestId, token, optyNumbers); productsContentState.opptyProductsByNumber = new Map(); productsContentState.expandedProductOptyNumbers = new Set(); productsContentState.productsProgress = { @@ -5175,7 +5181,7 @@ function createProductsDetails({ const badge = document.createElement("span"); badge.className = "opportunities-extension-products-ramp"; badge.setAttribute("data-missing", String(values.includes("N/D"))); - setBadgeContent(badge, "ramp", values.join(",")); + setBadgeContent(badge, "ramp", values.join(" | ")); badge.title = `Ramp Months: ${values.join(", ")}`; badge.setAttribute("aria-label", badge.title); return badge; diff --git a/dist/firefox/content.js b/dist/firefox/content.js index 925e3fe..3a70658 100644 --- a/dist/firefox/content.js +++ b/dist/firefox/content.js @@ -1692,6 +1692,7 @@ function createOpportunitiesController({ async function requestOpportunitiesForPeriod(period) { const dateRange = getFiscalPeriodRange(period, new Date()); const requestVersion = ++opportunitiesQueryState.periodRequestVersion; + resetWorkloadActions(); await ensureCurrentUser(); if (!isCurrentPeriodRequest(requestVersion)) return; @@ -2158,20 +2159,22 @@ function createOpportunityCrmServiceRequests({ requestCrmServiceRequest, isCurre function createProductActivities({ productsContentState, opportunitiesQueryState, loadCrm, loadActions, blockActions, render }) { let cycle = null; - function begin(requestId, token) { - cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false }; + function begin(requestId, token, opportunityNumbers = null) { + cycle = { requestId, token, numbers: opportunityNumbers && new Set(opportunityNumbers.map(String)), version: opportunitiesQueryState.periodRequestVersion, completed: false }; } function complete(requestId) { const current = cycle; if (!current || current.completed || current.requestId !== requestId || productsContentState.currentProductsRequestId !== requestId) return; current.completed = true; - const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error); + const entries = [...productsContentState.opptyProductsByNumber].filter(([number]) => !current.numbers || current.numbers.has(String(number))); + const products = entries.map(([, result]) => result).filter(result => !result.error); if (!products.length) return; const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0)); const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId && opportunitiesQueryState.periodRequestVersion === current.version; - return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => { + // Product cache hits do not guarantee that the corresponding activity cache exists. + return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: false })).then(async success => { if (!isActive()) return; if (success === false) { products.forEach(blockActions); @@ -2187,7 +2190,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState seen.add(id); return true; }); - pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true })); + pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: false })); } await Promise.allSettled(pending); if (!isActive()) return; @@ -2349,7 +2352,7 @@ function createActivityProgress({ MODAL_ID, environment }) { : state === "complete-with-errors" ? `${label} ${count} (errors)` : state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`; if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading"; - entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Workload action requests completed"; + entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters"; if (state === "complete") entry.timer = setTimeout(() => { entry.badge.setAttribute("data-fading", "true"); const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; @@ -3124,6 +3127,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie for (const id of idsFor(products)) { session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." }); } + updateProgress(session); } function pump(current) { while (current.enabled && current === session && current.running < 4 && current.queue.length) { @@ -3232,6 +3236,8 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie ].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products." : state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests."; if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled."; + const errors = [...new Set(records.filter(record => record?.error).map(record => record.error))]; + if (errors.length) badge.title += `\n${errors.join("\n")}`; if (!principal) badge.title += "\nTokenrelay principal is unavailable; ownership cannot be verified."; else if (actions.length && state === "missing") badge.title += "\nNone of the returned actions belong to the signed-in principal."; if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked."; @@ -5004,7 +5010,7 @@ function createProductsContentJobs({ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; productsContentState.currentProductsRequestId = requestId; - beginProductActivities(requestId, token); + beginProductActivities(requestId, token, optyNumbers); productsContentState.opptyProductsByNumber = new Map(); productsContentState.expandedProductOptyNumbers = new Set(); productsContentState.productsProgress = { @@ -5175,7 +5181,7 @@ function createProductsDetails({ const badge = document.createElement("span"); badge.className = "opportunities-extension-products-ramp"; badge.setAttribute("data-missing", String(values.includes("N/D"))); - setBadgeContent(badge, "ramp", values.join(",")); + setBadgeContent(badge, "ramp", values.join(" | ")); badge.title = `Ramp Months: ${values.join(", ")}`; badge.setAttribute("aria-label", badge.title); return badge; diff --git a/src/features/opportunities/activity-progress.js b/src/features/opportunities/activity-progress.js index b59759f..db7eb4d 100644 --- a/src/features/opportunities/activity-progress.js +++ b/src/features/opportunities/activity-progress.js @@ -28,7 +28,7 @@ function createActivityProgress({ MODAL_ID, environment }) { : state === "complete-with-errors" ? `${label} ${count} (errors)` : state === "complete" ? `${label} ready ${completed}` : `${label} ${count}`; if (key === "session") entry.badge.textContent = state === "complete" ? "SR session ready" : state === "error" ? "SR session unavailable" : "SR session loading"; - entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Workload action requests completed"; + entry.badge.title = key === "session" ? "Service Request session initialization" : key === "sr" ? "CRM service request records loaded" : "Product workloads checked for Actions in the selected Reference, regardless of display filters"; if (state === "complete") entry.timer = setTimeout(() => { entry.badge.setAttribute("data-fading", "true"); const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; diff --git a/src/features/opportunities/controller.js b/src/features/opportunities/controller.js index 8305b3d..71da350 100644 --- a/src/features/opportunities/controller.js +++ b/src/features/opportunities/controller.js @@ -23,6 +23,7 @@ function createOpportunitiesController({ async function requestOpportunitiesForPeriod(period) { const dateRange = getFiscalPeriodRange(period, new Date()); const requestVersion = ++opportunitiesQueryState.periodRequestVersion; + resetWorkloadActions(); await ensureCurrentUser(); if (!isCurrentPeriodRequest(requestVersion)) return; diff --git a/src/features/opportunities/product-activities.js b/src/features/opportunities/product-activities.js index 0e8f8cb..b6c852a 100644 --- a/src/features/opportunities/product-activities.js +++ b/src/features/opportunities/product-activities.js @@ -1,20 +1,22 @@ function createProductActivities({ productsContentState, opportunitiesQueryState, loadCrm, loadActions, blockActions, render }) { let cycle = null; - function begin(requestId, token) { - cycle = { requestId, token, version: opportunitiesQueryState.periodRequestVersion, completed: false }; + function begin(requestId, token, opportunityNumbers = null) { + cycle = { requestId, token, numbers: opportunityNumbers && new Set(opportunityNumbers.map(String)), version: opportunitiesQueryState.periodRequestVersion, completed: false }; } function complete(requestId) { const current = cycle; if (!current || current.completed || current.requestId !== requestId || productsContentState.currentProductsRequestId !== requestId) return; current.completed = true; - const products = [...productsContentState.opptyProductsByNumber.values()].filter(result => !result.error); + const entries = [...productsContentState.opptyProductsByNumber].filter(([number]) => !current.numbers || current.numbers.has(String(number))); + const products = entries.map(([, result]) => result).filter(result => !result.error); if (!products.length) return; const cachedAt = Math.max(...products.map(result => Number(result.cachedAt) || 0)); const isActive = () => cycle === current && productsContentState.currentProductsRequestId === requestId && opportunitiesQueryState.periodRequestVersion === current.version; - return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: products.every(result => result.fromCache) })).then(async success => { + // Product cache hits do not guarantee that the corresponding activity cache exists. + return Promise.resolve(loadCrm(current.token, current.version, { cachedAt, cacheOnly: false })).then(async success => { if (!isActive()) return; if (success === false) { products.forEach(blockActions); @@ -30,7 +32,7 @@ function createProductActivities({ productsContentState, opportunitiesQueryState seen.add(id); return true; }); - pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: result.fromCache === true })); + pending.push(loadActions({ ...result, items }, true, { cachedAt: result.cachedAt, cacheOnly: false })); } await Promise.allSettled(pending); if (!isActive()) return; diff --git a/src/features/opportunities/workload-actions.js b/src/features/opportunities/workload-actions.js index d6e5aeb..5f459f1 100644 --- a/src/features/opportunities/workload-actions.js +++ b/src/features/opportunities/workload-actions.js @@ -52,6 +52,7 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie for (const id of idsFor(products)) { session.records.set(id, { status: "error", actions: [], hasNext: false, error: "CRM service requests unavailable." }); } + updateProgress(session); } function pump(current) { while (current.enabled && current === session && current.running < 4 && current.queue.length) { @@ -160,6 +161,8 @@ function createOpportunityWorkloadActions({ authSessionState, allowsCrmActivitie ].join("\n")).join("\n\n") : state === "missing" ? "No actions found for this opportunity's products." : state === "loading" ? "Loading workload actions..." : "Unable to confirm workload actions. Check product RevnId and Debug Requests."; if (records.some(record => record?.hasNext)) badge.title += "\nAdditional actions exist; pagination is not enabled."; + const errors = [...new Set(records.filter(record => record?.error).map(record => record.error))]; + if (errors.length) badge.title += `\n${errors.join("\n")}`; if (!principal) badge.title += "\nTokenrelay principal is unavailable; ownership cannot be verified."; else if (actions.length && state === "missing") badge.title += "\nNone of the returned actions belong to the signed-in principal."; if (actions.length && (pending || uncertain)) badge.title += "\nSome workloads are still loading or could not be fully checked."; diff --git a/src/features/products/content-jobs.js b/src/features/products/content-jobs.js index af7812a..fdb208f 100644 --- a/src/features/products/content-jobs.js +++ b/src/features/products/content-jobs.js @@ -53,7 +53,7 @@ function createProductsContentJobs({ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; productsContentState.currentProductsRequestId = requestId; - beginProductActivities(requestId, token); + beginProductActivities(requestId, token, optyNumbers); productsContentState.opptyProductsByNumber = new Map(); productsContentState.expandedProductOptyNumbers = new Set(); productsContentState.productsProgress = { diff --git a/src/features/products/details.js b/src/features/products/details.js index b17a519..99ba867 100644 --- a/src/features/products/details.js +++ b/src/features/products/details.js @@ -18,7 +18,7 @@ function createProductsDetails({ const badge = document.createElement("span"); badge.className = "opportunities-extension-products-ramp"; badge.setAttribute("data-missing", String(values.includes("N/D"))); - setBadgeContent(badge, "ramp", values.join(",")); + setBadgeContent(badge, "ramp", values.join(" | ")); badge.title = `Ramp Months: ${values.join(", ")}`; badge.setAttribute("aria-label", badge.title); return badge; diff --git a/tests/activityCache.test.cjs b/tests/activityCache.test.cjs index 4289726..7adb3b3 100644 --- a/tests/activityCache.test.cjs +++ b/tests/activityCache.test.cjs @@ -44,6 +44,41 @@ function setup(family, saved = {}, principal = "user@example.invalid") { } return { app, calls, load, saved, local }; } + +test("all cached opportunities load missing Actions despite filters, then reuse their completed cache", async () => { + const h = setup("firefox"); + const stamp = Date.now() - 1000; + const app = h.app; + app.opportunitiesFilterState.tableSearch = "hide every opportunity"; + app.productsContentJobs.renderProductsProgress = () => {}; + const products = Array.from({ length: 7 }, (_, index) => [String(index + 1), { + items: [{ RevnId: String(index + 1) }], cachedAt: stamp, fromCache: true + }]); + app.productsContentState.opptyProductsByNumber = new Map(products); + await app.activityCache.write("actions", "1", stamp, []); + await app.activityCache.write("actions", "2", stamp - 1, []); + const queried = []; + app.platformContentMessages.sendRuntimeMessage = async message => { + queried.push(message.revnId); + return { ok: true, actions: message.revnId === "3" ? [{ owner: app.authSessionState.principal }] : [], hasNext: false }; + }; + async function cycle(id) { + app.productsContentState.currentProductsRequestId = id; + app.productActivities.begin(id, "synthetic-token"); + await app.productActivities.complete(id); + } + await cycle("first"); + assert.deepEqual(queried.sort(), ["2", "3", "4", "5", "6", "7"]); + assert.deepEqual(h.calls, ["sr"], "a missing SR cache must not block the Actions queue"); + for (const [number] of products) { + assert.equal(app.opportunityWorkloadActions.getSummary(number).state, number === "3" ? "matched" : "missing"); + } + app.opportunityWorkloadActions.reset(); + app.opportunityCrmServiceRequests.reset(); + await cycle("second"); + assert.equal(queried.length, 6, "valid matching action cache avoids all repeat requests"); + assert.deepEqual(h.calls, ["sr"]); +}); for (const family of ["chrome", "firefox"]) { test(`${family}: Current Quarter -> Next Quarter -> Current Quarter restores SR hours and filters without requests`, async () => { const h = setup(family); diff --git a/tests/badgeIcons.test.cjs b/tests/badgeIcons.test.cjs index 9c90593..d7e012e 100644 --- a/tests/badgeIcons.test.cjs +++ b/tests/badgeIcons.test.cjs @@ -56,7 +56,7 @@ test("consistent inline vectors replace raster icons without parentheses or lost assert.equal(count.title, "3 products"); assert.equal(typeof count.listeners.click, "function"); const ramp = app.productsDetails.createProductsRampBadge(products); - checkIcon(ramp, "ramp", "1,2,3"); + checkIcon(ramp, "ramp", "1 | 2 | 3"); assert.equal(ramp.title, "Ramp Months: 1, 2, 3"); const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] }); checkIcon(missingRamp, "ramp", "N/D"); diff --git a/tests/productActivities.test.cjs b/tests/productActivities.test.cjs index f3e9f6d..351803b 100644 --- a/tests/productActivities.test.cjs +++ b/tests/productActivities.test.cjs @@ -94,8 +94,8 @@ function setup() { 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") { - app.productsContentJobs.requestOpptyProductsInBackground([{ OptyNumber: "A" }], token); + function start(token = "token", numbers = ["A"]) { + app.productsContentJobs.requestOpptyProductsInBackground(numbers.map(OptyNumber => ({ OptyNumber })), token); return app.productsContentState.currentProductsRequestId; } function progress(requestId, data) { @@ -147,19 +147,33 @@ test("partial failures use successful products only; closed, failed and empty cy assert.equal(h.calls.includes("closed"), false); }); -test("cached products restore activities without network; mixed cycles fetch only fresh products", async () => { +test("cached and mixed product cycles both allow activity cache misses to be fetched", async () => { const h = setup(); const cached = h.start(); h.progress(cached, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true }); h.progress(cached, { state: "complete" }); await flush(); - assert.deepEqual(h.calls, []); - const mixed = h.start(); + assert.deepEqual(h.calls, ["SR token", "cached"]); + h.calls.length = 0; + const mixed = h.start("token", ["A", "B"]); h.progress(mixed, { optyNumber: "A", items: [{ RevnId: "cached" }], fromCache: true }); h.progress(mixed, { optyNumber: "B", items: [{ RevnId: "fresh" }], fromCache: false }); h.progress(mixed, { state: "complete" }); await flush(); - assert.deepEqual(h.calls, ["SR token", "fresh"]); + assert.deepEqual(h.calls, ["SR token", "cached", "fresh"]); +}); + +test("activity scope includes hidden reference opportunities and excludes entries from other references", async () => { + const h = setup(); + h.app.opportunitiesFilterState.tableSearch = "hide everything"; + const id = h.start("token", ["A", "B"]); + h.progress(id, { optyNumber: "A", items: [{ RevnId: "1" }, { RevnId: "2" }] }); + h.progress(id, { optyNumber: "B", items: [{ RevnId: "3" }] }); + h.progress(id, { optyNumber: "OTHER_REFERENCE", items: [{ RevnId: "outside" }] }); + h.progress(id, { state: "complete" }); + await flush(); + assert.deepEqual(h.calls, ["SR token", "1", "3"]); + assert.equal(h.calls.includes("outside"), false); }); test("reopening an existing modal does not reload products or reset/query activities", async () => { diff --git a/tests/workloadActions.test.cjs b/tests/workloadActions.test.cjs index 498137a..91d8a75 100644 --- a/tests/workloadActions.test.cjs +++ b/tests/workloadActions.test.cjs @@ -64,6 +64,29 @@ function contentFixture(send) { return app; } +test("Actions progress counts product workloads, not opportunities, even with filters", async () => { + const pending = new Map(); + const app = contentFixture(message => new Promise(resolve => pending.set(message.revnId, resolve))); + const progress = []; + app.activityProgress.update = (...args) => progress.push(args); + app.opportunitiesFilterState.tableSearch = "hidden"; + const a = { items: [{ RevnId: "1" }, { RevnId: "2" }] }; + const b = { items: [{ RevnId: "3" }] }; + app.productsContentState.opptyProductsByNumber = new Map([["A", a], ["B", b]]); + const loading = [app.opportunityWorkloadActions.loadProducts(a), app.opportunityWorkloadActions.loadProducts(b)]; + await new Promise(resolve => setImmediate(resolve)); + assert.equal(pending.size, 3); + pending.get("1")({ ok: true, actions: [], hasNext: false }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(progress.at(-1), ["actions", "running", 1, 3]); + pending.get("2")({ ok: true, actions: [], hasNext: false }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(progress.at(-1), ["actions", "running", 2, 3]); + pending.get("3")({ ok: true, actions: [], hasNext: false }); + await Promise.all(loading); + assert.deepEqual(progress.at(-1), ["actions", "complete", 3, 3]); +}); + test("saved response updates the workload badge, tooltip and matching cache without fetching", async () => { const app = contentFixture(() => assert.fail("Save response must not trigger a GET")); const writes = [];