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

@@ -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");
});