Updated the calendar to improve opportunity display and navigation

This commit is contained in:
2026-10-27 03:22:10 -03:00
parent 7606643ff4
commit 1d5b28e95b
51 changed files with 1486 additions and 1237 deletions

View File

@@ -1,219 +0,0 @@
const assert = require("node:assert/strict");
const { loadApplication } = require("./support/application.cjs");
class Element {
constructor(tag) {
this.tag = tag;
this.children = [];
this.dataset = {};
this.attributes = {};
this.listeners = {};
this.classes = new Set();
this.classList = { toggle: (name, on) => on ? this.classes.add(name) : this.classes.delete(name) };
}
append(...children) { this.children.push(...children); }
setAttribute(name, value) { this.attributes[name] = value; }
addEventListener(name, callback) { this.listeners[name] = callback; }
replaceWith(element) { this.replacement = element; }
click() { this.listeners.click({ preventDefault() {}, stopPropagation() {} }); }
}
function setup(mockSalesToken = true) {
const calls = [];
let now = Date.now();
class TestDate extends Date {
constructor(...args) { super(...(args.length ? args : [now])); }
static now() { return now; }
}
let visibleAlerts = [];
const context = {
document: {
createElement: (tag) => new Element(tag),
createElementNS: (_, tag) => new Element(tag),
querySelectorAll: () => visibleAlerts
},
fetch: (url, options) => new Promise((resolve, reject) => calls.push({ url, options, resolve, reject })),
Intl, Date: TestDate, Map, Set
};
const app = loadApplication("content", context);
app.authSessionState.accessToken = "test-bearer-secret";
app.authXsrfContent.getXsrfToken = async () => ({ token: "test-xsrf-cookie", cookieName: "XSRF-TOKEN-DYNAMIC", source: "test" });
app.platformContentMessages.refreshXsrfCookie = async () => ({ refreshed: false });
if (mockSalesToken) app.authSalesCommon.requestTokenRelaySalesCommon = async () => "test-sales-common-secret";
app.debugContentView.renderDebugPanel = () => {};
app.opportunitiesTable.renderOpportunitiesTable = () => {};
return {
...app.aiSummaryContent, ...app.productsDetails, ...app.aiSummaryState,
...app.productsContentState, ...app.debugContentLog, ...app.debugState,
requestTokenRelay: app.authTokenRelay.requestTokenRelay,
requestTokenRelaySalesCommon: app.authSalesCommon.requestTokenRelaySalesCommon,
getAccessToken: () => app.authSessionState.accessToken,
calls, advance: (ms) => { now += ms; }, show: (alerts) => { visibleAlerts = alerts; }
};
}
function json(data, status = 200) {
return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } });
}
const flush = () => new Promise((resolve) => setImmediate(resolve));
async function run() {
const h = setup();
const products = { items: [{ ProdGroupName: "Cloud", RevnAmount: "10000", WinProb: 30 }] };
h.opptyProductsByNumber.set("A1", products);
h.createProductsCountButton("A1", products).click();
const first = h.aiSummariesByOptyNumber.get("A1").promise;
await flush();
assert.equal(h.calls.length, 1);
assert.equal(h.requestGenerateAiSummary("A1"), first);
assert.equal(h.calls.length, 1, "in-flight requests are deduplicated");
const { url, options } = h.calls[0];
assert.equal(url, "https://eeho.fa.us2.oraclecloud.com/api/sales-common/gen-ai/v1/summarizations/generate");
assert.equal(options.method, "POST");
assert.equal(options.headers.Authorization, "Bearer test-sales-common-secret");
assert.equal(options.headers["Content-Type"], "application/json");
assert.deepEqual(JSON.parse(options.body), {
path: "/api/sales-common/gen-ai/v1/summarizations/generate",
promptCode: "cx.sales.generate_opportunity_summary", object: "opportunities", puid: "A1"
});
const loadingRow = h.createProductsDetailRow({ OptyNumber: "A1" });
const panel = loadingRow.children[0].children[0];
assert.equal(panel.children[1].attributes["aria-busy"], "true");
assert.equal(panel.children[2].tag, "table", "products remain below the summary");
assert.equal(panel.children[2].children[1].children.length, 1);
h.createProductsCountButton("A1", products).click();
assert.equal(h.calls.length, 1, "collapsing does not request another summary");
assert.equal(h.expandedProductOptyNumbers.has("A1"), false);
const second = h.requestGenerateAiSummary("A2");
await flush();
const visible = h.createAiSummaryAlert("A2");
h.show([visible]);
h.calls[1].resolve(json({ summaryText: "Second opportunity" }));
await second;
assert.equal(visible.replacement.children[1].textContent, "Second opportunity");
const unsafeText = "<img src=x onerror=alert(1)>\nLong summary";
h.calls[0].resolve(json({ summaryText: unsafeText, lastUpdateDate: "not displayed" }));
await first;
assert.equal(h.expandedProductOptyNumbers.has("A1"), false, "late responses do not reopen rows");
assert.equal(visible.replacement.children[1].textContent, "Second opportunity");
const ready = h.createAiSummaryAlert("A1");
assert.equal(ready.children[0].textContent, "Summary generated by AI");
assert.equal(ready.children[1].textContent, unsafeText);
assert.equal(ready.children[1].children.length, 0, "AI text is not parsed as HTML");
assert.equal(ready.children.length, 2);
assert.equal(h.requestLog[0].name, "requestGenerateAiSummary");
assert.equal(h.requestLog[0].httpStatus, 200);
assert.equal(h.requestLog[0].status, "success");
assert.ok(h.requestLog[0].responseBody.includes("summaryText"));
assert.ok(!h.formatHeaderBlock(h.requestLog[0].requestHeaders).includes("test-sales-common-secret"));
h.createProductsCountButton("A1", products).click();
await flush();
assert.equal(h.calls.length, 3, "expanding again generates a fresh summary");
h.createProductsCountButton("A1", products).click();
h.createProductsCountButton("A1", products).click();
assert.equal(h.calls.length, 3, "rapid collapse/expand reuses the pending request");
h.calls[2].resolve(json({ summaryText: "Fresh summary" }));
await h.aiSummariesByOptyNumber.get("A1").promise;
for (const response of [json({}, 500), new Response("<html>login</html>"), json({}), json({ summaryText: " " }), json({ summaryText: 123 })]) {
const failure = h.requestGenerateAiSummary("error");
await flush();
h.calls.at(-1).resolve(response);
await failure;
assert.equal(h.aiSummariesByOptyNumber.get("error").status, "error");
assert.equal(h.requestLog.at(-1).status, "failed");
assert.ok(h.requestLog.at(-1).httpStatus);
assert.ok(h.createAiSummaryAlert("error").classes.has("opportunities-extension-ai-summary-error"));
}
const networkFailure = h.requestGenerateAiSummary("error");
await flush();
h.calls.at(-1).reject(new Error("Network unavailable"));
await networkFailure;
assert.equal(h.requestLog.at(-1).error, "Network unavailable");
const retry = h.requestGenerateAiSummary("error");
await flush();
h.calls.at(-1).resolve(json({ summaryText: "Recovered" }));
await retry;
assert.equal(h.aiSummariesByOptyNumber.get("error").status, "ready");
const scoped = setup(false);
const generation = scoped.requestGenerateAiSummary("SC1");
await flush();
assert.equal(scoped.calls.length, 1, "summary waits for its scoped token");
assert.equal(scoped.calls[0].url, "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F");
assert.equal(scoped.calls[0].options.method, "GET");
assert.equal(scoped.calls[0].options.credentials, "include");
assert.equal(scoped.calls[0].options.headers["x-xsrf-token"], "test-xsrf-cookie");
scoped.calls[0].resolve(json({ access_token: "scoped-token", expires_in: 3600 }));
await flush();
assert.equal(scoped.calls[1].options.headers.Authorization, "Bearer scoped-token");
scoped.calls[1].resolve(json({ summaryText: "Scoped summary" }));
await generation;
assert.equal(scoped.requestLog[0].name, "requestTokenRelaySalesCommon");
assert.equal(scoped.requestLog[0].httpStatus, 200);
assert.equal(scoped.getAccessToken(), "test-bearer-secret", "scoped token does not replace the general token");
const normal = scoped.requestTokenRelay();
await flush();
assert.equal(scoped.calls[2].url, "https://eeho.fa.us2.oraclecloud.com/fscmRestApi/tokenrelay");
assert.equal(scoped.calls[2].options.headers["x-xsrf-token"], scoped.calls[0].options.headers["x-xsrf-token"]);
scoped.calls[2].resolve(json({ access_token: "normal-token" }));
await normal;
assert.equal(scoped.requestLog[2].name, "requestTokenRelay");
assert.equal(await scoped.requestTokenRelaySalesCommon(), "scoped-token");
assert.equal(scoped.calls.length, 3, "valid token is reused");
scoped.advance(3600 * 1000);
const failedScope = scoped.requestGenerateAiSummary("SC2");
await flush();
scoped.calls[3].resolve(json({ error: "Unavailable" }, 500));
await failedScope;
assert.equal(scoped.calls.length, 4, "no summary POST after scoped authentication failure");
assert.equal(scoped.aiSummariesByOptyNumber.get("SC2").status, "error");
assert.equal(scoped.requestLog[3].status, "failed");
const pendingToken = scoped.requestTokenRelaySalesCommon();
assert.equal(scoped.requestTokenRelaySalesCommon(), pendingToken, "concurrent refresh is deduplicated");
await flush();
scoped.calls[4].resolve(json({ access_token: "renewed-token", expires_in: 3600 }));
await pendingToken;
assert.equal(await scoped.requestTokenRelaySalesCommon(), "renewed-token");
assert.equal(scoped.calls.length, 5, "failed refresh can be retried and then cached");
const rejected = scoped.requestGenerateAiSummary("REJECTED");
const concurrent = scoped.requestGenerateAiSummary("CONCURRENT");
await flush();
assert.equal(scoped.calls[5].options.headers.Authorization, "Bearer renewed-token");
scoped.calls[5].resolve(json({}, 401));
await flush();
assert.equal(scoped.calls[7].options.method, "GET");
scoped.calls[7].resolve(json({ access_token: "replacement-token", expires_in: 3600 }));
await flush();
assert.equal(scoped.calls[8].options.headers.Authorization, "Bearer replacement-token");
scoped.calls[8].resolve(json({ summaryText: "Recovered after refresh" }));
await rejected;
scoped.calls[6].resolve(json({}, 401));
await flush();
assert.equal(scoped.calls[9].options.method, "POST", "late rejection must reuse the already refreshed token");
assert.equal(scoped.calls[9].options.headers.Authorization, "Bearer replacement-token");
scoped.calls[9].resolve(json({ summaryText: "Concurrent recovery" }));
await concurrent;
assert.equal(scoped.calls.length, 10);
const forbidden = scoped.requestGenerateAiSummary("FORBIDDEN");
await flush();
scoped.calls[10].resolve(json({}, 403));
await flush();
scoped.calls[11].resolve(json({ access_token: "still-forbidden", expires_in: 3600 }));
await flush();
scoped.calls[12].resolve(json({}, 403));
await forbidden;
assert.equal(scoped.calls.length, 13, "only one renewal and one summary retry per generation");
assert.equal(scoped.aiSummariesByOptyNumber.get("FORBIDDEN").status, "error");
assert.equal(scoped.requestLog.at(-1).httpStatus, 403);
assert.equal(scoped.getAccessToken(), "test-bearer-secret");
console.log("AI summary tests passed.");
}
run().catch((error) => { console.error(error); process.exitCode = 1; });

View File

@@ -41,9 +41,9 @@ test("every source belongs to an explicit runtime composition, with no duplicate
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/features/org-chart/model.js", "src/shared/time-dates.js", "src/shared/messages.js", "src/shared/debug-body.js"]);
});
test("Redwood cascade is byte-identical to the characterized original", () => {
test("Redwood cascade is unchanged after removing obsolete AI summary styles", () => {
const hash = createHash("sha256").update(read("src/content/styles/redwood.css")).digest("hex");
assert.equal(hash, "eaeb7e53757d48a3095f9111c5029d2c923d0dcab92af23454ccd14bdcfc81c4");
assert.equal(hash, "3a40f3f75cc1481444be20f8fa8705e6679fc18a8f748561ab820545fbac7853");
});
test("fiscal module can run alone without DOM, browser APIs or network", () => {
@@ -58,13 +58,9 @@ test("per-frame state and credentials do not leak between application instances"
const b = loadApplication("content");
for (const key of Object.keys(a).filter(key => key.endsWith("State"))) assert.notEqual(a[key], b[key], key);
a.authSessionState.accessToken = "synthetic-general";
a.authSalesCommonState.salesCommonToken = { access_token: "synthetic-scoped", expiresAt: 123 };
a.productsContentState.opptyProductsByNumber.set("TEST", { items: [] });
a.aiSummaryState.aiSummariesByOptyNumber.set("TEST", { summaryText: "Synthetic" });
assert.equal(b.authSessionState.accessToken, "");
assert.equal(b.authSalesCommonState.salesCommonToken, null);
assert.equal(b.productsContentState.opptyProductsByNumber.size, 0);
assert.equal(b.aiSummaryState.aiSummariesByOptyNumber.size, 0);
});
test("blocked localStorage access remains lazy and handled by preference fallbacks", () => {

View File

@@ -50,7 +50,7 @@ function checkIcon(badge, kind, value) {
test("consistent inline vectors replace raster icons without parentheses or lost tooltips", async () => {
const app = setup();
const products = { items: [1, 2, 3].map(RampMonths_c => ({ RampMonths_c, RevnId: "123" })) };
const products = { items: [1, 2, 3].map(RampMonths_c => ({ RampMonths_c, RevnId: "123", WorkloadName_c: "Synthetic workload" })) };
const count = app.productsDetails.createProductsCountButton("TEST", products);
checkIcon(count, "products", "3");
assert.equal(count.title, "3 products");
@@ -58,7 +58,7 @@ test("consistent inline vectors replace raster icons without parentheses or lost
const ramp = app.productsDetails.createProductsRampBadge(products);
checkIcon(ramp, "ramp", "1 | 2 | 3");
assert.equal(ramp.title, "Ramp Months: 1, 2, 3");
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null }] });
const missingRamp = app.productsDetails.createProductsRampBadge({ items: [{ RampMonths_c: null, WorkloadName_c: "Synthetic workload" }] });
checkIcon(missingRamp, "ramp", "N/A");
assert.equal(missingRamp.attributes["data-missing"], "true");

View File

@@ -74,7 +74,7 @@ test("preparation closes immediately on the second cookie event even while getAl
assert.equal(listeners.size, 0);
});
test("explicit preparation opens SeaaS initiation and observes cookies without any API requests", async () => {
test("explicit preparation opens resource requests Dashboard and observes cookies without any API requests", async () => {
let calls = 0;
let reads = 0;
const domain = "comcipapic-oalprod.integration.ocp.oraclecloud.com";
@@ -97,7 +97,7 @@ test("explicit preparation opens SeaaS initiation and observes cookies without a
assert.deepEqual(result.debugRequests, []);
assert.ok(!JSON.stringify(result).includes("secret"));
assert.equal(h.tabs[0].active, false);
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/");
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live/webApps/Dashboard/?page=shell&shell=main&main=manageresourcerequests");
assert.deepEqual(h.removedTabs, [91]);
});
@@ -191,7 +191,7 @@ for (const family of ["chrome", "firefox"]) {
assert.equal(h.tabs.length, 1);
assert.equal(h.tabs[0].active, false);
assert.equal(h.tabs[0].windowId, 3);
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/SeaaS_initiation/live/webApps/external/");
assert.equal(h.tabs[0].url, "https://comcipapic-oalprod.integration.ocp.oraclecloud.com/ic/builder/rt/oalset_semc/live/webApps/Dashboard/?page=shell&shell=main&main=manageresourcerequests");
assert.equal(h.tabs[0].cookieStoreId, family === "firefox" ? "test-store" : undefined);
assert.deepEqual(h.removedTabs, [91]);
});

View File

@@ -96,7 +96,7 @@ for (const family of ["chrome", "firefox"]) {
});
}
test("expired SPA session recovery handles 401, 403 and network errors, then releases its in-flight request", async () => {
test("expired SPA session recovery handles 401, 403 and network errors, then reuses its successful identity", async () => {
let attempts = 0;
const app = loadBackground({ fetch: async () => {
attempts++;
@@ -111,7 +111,8 @@ test("expired SPA session recovery handles 401, 403 and network errors, then rel
assert.deepEqual(first.debugRequests.map(entry => entry.httpStatus), [401, 403, null, 200]);
assert.deepEqual(app.removedTabs, [91]);
assert.equal((await app.dispatch({ type })).ok, true);
assert.equal(attempts, 5, "background must not persist identity after the shared request settles");
assert.equal(attempts, 4, "successful identity must not be fetched again");
assert.equal((await app.dispatch({ type })).debugRequests.length, 0);
assert.equal(app.tabs.length, 1);
});
@@ -273,3 +274,25 @@ for (const family of ["chrome", "firefox"]) {
assert.equal(attempts, payloads.length);
});
}
test("identity cache survives background recreation, shares windows and isolates containers", async () => {
const factory = new (require("node:vm").Script)(require("node:fs").readFileSync("src/services/spa/current-user.js", "utf8") + "\ncreateSpaCurrentUser").runInNewContext();
const data = {};
const sessionStorage = { async get(key) { return { [key]: data[key] }; }, async set(values) { Object.assign(data, values); } };
let calls = 0;
const deps = { sessionStorage, fetchJson: async () => { calls++; return validIdentity; } };
const service = factory(deps);
const sender = (windowId, cookieStoreId = "", incognito = false) => ({ tab: { windowId, cookieStoreId, incognito } });
await Promise.all([service.requestCurrentUser(sender(1)), service.requestCurrentUser(sender(2))]);
assert.equal(calls, 1);
await service.requestCurrentUser(sender(3));
assert.equal(calls, 1);
const restored = await factory(deps).requestCurrentUser(sender(4));
assert.equal(restored.user.id, validIdentity.user.id);
assert.equal(restored.debugRequests.length, 0);
assert.equal(calls, 1);
await service.requestCurrentUser(sender(1, "container-2"));
await service.requestCurrentUser(sender(1, "", true));
assert.equal(calls, 3);
assert.ok(Object.values(data).every(user => !user.token));
});

121
tests/debugStorage.test.cjs Normal file
View File

@@ -0,0 +1,121 @@
const { test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const vm = require("node:vm");
const { loadApplication } = require("./support/application.cjs");
const { createDebugDom } = require("./support/debug-dom.cjs");
const factory = new vm.Script(fs.readFileSync("src/features/debug/state.js", "utf8") + "\ncreateDebugState").runInNewContext();
function storage() {
const data = new Map();
return { data, get length() { return data.size; }, key: i => [...data.keys()][i],
getItem: key => data.get(key) ?? null, setItem: (key, value) => data.set(key, value), removeItem: key => data.delete(key) };
}
test("opportunities clears debug only when sending the first page, preserving pagination", async () => {
const localStorage = storage();
const app = loadApplication("content", {
localStorage,
fetch: async (_url, options) => {
if (options.method === "GET") return new Response(JSON.stringify({ items: [{ isDefault: true, savedSearchNumber: "test" }] }));
const offset = JSON.parse(options.body).offset;
return new Response(JSON.stringify({ items: [{ OptyNumber: String(offset) }], offset, count: 1, hasMore: offset === 0 }));
}
});
app.debugContentView.renderDebugPanel = () => {};
app.debugContentLog.logRequestStart({ name: "old", method: "GET" });
const range = { startCloseDate: "2026-09-01", endCloseDate: "2026-12-01" };
const query = () => app.fusionOpportunities.requestOpportunitiesPage("test", "Current Quarter", range, 0, [], 0, 1);
await query();
assert.equal(app.debugState.generation, 1);
assert.deepEqual(Array.from(app.debugState.requestLog, entry => entry.name), ["requestOpportunities", "requestOpportunities"]);
await query();
assert.equal(app.debugState.generation, 2);
assert.equal(app.debugState.requestLog.length, 2);
app.debugState.flush();
assert.equal([...localStorage.data.values()].some(value => value.includes('"name":"old"')), false);
});
test("browser timer receiver does not prevent Token Relay from starting or completing", async () => {
let calls = 0;
const app = loadApplication("content", {
localStorage: storage(), AbortController,
setTimeout: function(callback, delay) {
"use strict";
assert.equal(this, undefined);
return setTimeout(callback, delay);
},
clearTimeout: function(id) { "use strict"; assert.equal(this, undefined); clearTimeout(id); },
fetch: async () => { calls++; return new Response(JSON.stringify({ access_token: "synthetic" })); }
});
app.debugContentView.renderDebugPanel = () => {};
app.authXsrfContent.getXsrfToken = async () => ({ token: "synthetic-xsrf" });
assert.equal(await app.authTokenRelay.requestTokenRelay(), "synthetic");
app.debugState.flush();
assert.equal(calls, 1);
assert.equal(app.debugState.requestLog[0].status, "success");
});
test("Token Relay times out and records failure when fetch never completes", async () => {
let signal;
const app = loadApplication("content", {
AbortController,
setTimeout: (callback, delay) => setTimeout(callback, delay === 30000 ? 5 : delay),
clearTimeout,
fetch: (_url, options) => { signal = options.signal; return new Promise(() => {}); }
});
app.debugContentView.renderDebugPanel = () => {};
app.authXsrfContent.getXsrfToken = async () => ({ token: "synthetic-xsrf" });
await assert.rejects(app.authTokenRelay.requestTokenRelay(), /timed out/);
app.debugState.flush();
assert.equal(signal.aborted, true);
assert.equal(app.debugState.requestLog[0].status, "failed");
});
test("debug stores individual complete records, reloads Dates and preserves pending request handles", () => {
const localStorage = storage();
const state = factory({ localStorage, crypto: { randomUUID: () => "tab-a" } });
state.reset();
const entry = { id: 1, startedAt: new Date(), completedAt: null, responseBody: "x".repeat(100000), requestHeaders: { Authorization: "synthetic" } };
state.requestLog.push(entry); state.persist(entry);
assert.equal(JSON.parse(localStorage.getItem("opportunities-extension-debug:tab-a:1")).responseBody.length, 100000);
entry.responseBody = "changed in memory";
state.reload();
assert.equal(state.requestLog[0], entry);
assert.equal(entry.responseBody.length, 100000);
assert.equal(typeof entry.startedAt.getTime, "function");
});
test("reset clears only this tab's debug records and ignores old request completions", () => {
const localStorage = storage();
localStorage.setItem("preferences", "preserved");
localStorage.setItem("opportunities-extension-debug:other:1", "preserved");
const state = factory({ localStorage, crypto: { randomUUID: () => "tab-a" } });
const entry = { id: 1 }; state.requestLog.push(entry); state.persist(entry);
state.reset(); state.persist(entry);
assert.equal(state.requestLog.length, 0);
assert.equal(localStorage.getItem("opportunities-extension-debug:tab-a:1"), null);
assert.equal(localStorage.getItem("preferences"), "preserved");
assert.equal(localStorage.getItem("opportunities-extension-debug:other:1"), "preserved");
});
test("quota failure retains complete data in memory without breaking requests", () => {
const state = factory({ localStorage: { setItem() { throw new Error("quota"); } } });
const entry = { id: 1, responseBody: "complete" }; state.requestLog.push(entry);
assert.doesNotThrow(() => state.persist(entry));
assert.match(state.storageError, /full/);
state.reload(); assert.equal(state.requestLog[0].responseBody, "complete");
});
test("hidden debug has no request DOM; opening hydrates summaries and expanding mounts details", () => {
const dom = createDebugDom(); dom.panel.hidden = true;
const app = loadApplication("content", { document: dom.document, localStorage: storage() });
const entry = app.debugContentLog.logRequestStart({ name: "requestExample", method: "GET", url: "https://example.invalid" });
assert.equal(dom.panel.children.length, 0);
app.debugContentView.toggleDebugPanel();
const find = cls => dom.descendants(dom.panel).find(node => node.className === cls);
assert.equal(find("opportunities-extension-debug-details"), undefined);
const row = dom.descendants(dom.panel).find(node => node.tag === "details");
row.open = true; row.listeners.toggle();
assert.ok(find("opportunities-extension-debug-details"));
app.debugContentView.closeDebugPanel();
assert.equal(dom.panel.children.length, 0);
app.debugState.reset();
app.debugContentLog.logRequestFailure(entry, "Late failure");
assert.equal(app.debugState.requestLog.length, 0);
});

View File

@@ -42,6 +42,7 @@ test("catalog and identity secrets are revealed exactly by the eye button and hi
name: "requestCurrentUser", method: "GET", startedAt: new Date().toISOString(), status: "success",
responseBody: JSON.stringify({ user: { roles: ["SIMPLE_USER"] }, token: "identity-exact-secret" })
}]);
h.nodes().filter(node => node.tag === "details").forEach(node => { node.open = true; node.listeners.toggle(); });
const text = node => node.text + node.children.map(text).join("");
const toggle = () => h.nodes().find(node => node.getAttribute("data-debug-focus") === "visibility").listeners.click();
assert.doesNotMatch(text(h.panel), /exact-secret/);
@@ -99,9 +100,9 @@ test("complete large responses stay formatted, masked and colored in both runtim
test("request categories cover every current family and unknown requests stay visible", () => {
const h = fixture();
for (const [name, category] of Object.entries({
requestTokenRelay: "authentication", requestTokenRelaySalesCommon: "authentication",
requestTokenRelay: "authentication",
requestConsumerTokenService: "authentication", requestCustomerTokenService: "authentication",
requestOpportunities: "opportunities", requestOpportunitiesSavedSearch: "opportunities", requestOpportunitiesByContact: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
requestOpportunities: "opportunities", requestOpportunitiesSavedSearch: "opportunities", requestOpportunitiesByContact: "opportunities", requestOpptyProducts: "products",
requestQueryAccountContact: "contacts", requestPersistContact: "contacts",
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests", requestSRInfo: "serviceRequests",
@@ -233,6 +234,7 @@ test("colored JSON preserves exact masked text, safe strings, eye toggle and HTT
name: "requestTokenRelay", method: "POST", startedAt: new Date().toISOString(),
httpStatus: 401, status: "failed", responseBody: response, requestHeaders: { Authorization: "synthetic-private" }
}]);
h.nodes().filter(node => node.tag === "details").forEach(node => { node.open = true; node.listeners.toggle(); });
const text = node => node.text + node.children.map(text).join("");
const responseBlock = () => h.nodes().find(node => node.tag === "section" && node.children[0]?.textContent === "Response Body").children[1];
assert.equal(text(responseBlock()), h.app.debugContentLog.formatDebugBody(response));
@@ -308,7 +310,7 @@ test("full-page debug CSS defines responsive bounds and readable semantic colors
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
const root = path.join(__dirname, "..");
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css"]
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css")
.concat("src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css", "src/content/styles/dialogs.css")
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));

View File

@@ -120,7 +120,7 @@ test("installBase sorts typed values and restores the selected header across app
const second = setup(storage);
second.app.installBaseView.openInstallBaseModal(customer).render(items);
const rows = walk(second.overlay).find(node => node.tag === "tbody").children;
assert.equal(rows[0].children[7].textContent, "2");
assert.equal(rows[0].children[6].textContent, "2");
assert.equal(walk(second.overlay).find(node => node.attributes["data-sort-key"] === "quantity").parent.attributes["aria-sort"], "ascending");
assert.equal(items[0].quantity, 10);
});
@@ -136,13 +136,13 @@ test("installed base searches every displayed column and formatted values, inclu
currentSupport: 1234.56, orderNumber: "ORD-321", supportStatus: "ACTIVE", quantityType: "Processor",
quantity: 7, supportStartDate: "2026-03-12", supportEndDate: "2027-04-13", licenseValue: 9876.54, shipDate: "2020-05-14",
hiddenField: "secretword" };
input.value = "sao beta";
input.value = "group beta";
input.listeners.input();
assert.equal(modal.attributes["aria-busy"], "true");
view.render([row, { productName: "Other product" }]);
const rows = () => walk(modal).filter(node => node.tag === "td").length / 12;
const rows = () => walk(modal).filter(node => node.tag === "td").length / 11;
assert.equal(rows(), 1);
for (const query of [...Object.values(row).slice(0, 12), "1,234.56", "9,876.54", "12/03/2026", "13/04/2027", "14/05/2020"]) {
for (const query of [...Object.values(row).slice(1, 12), "1,234.56", "9,876.54", "12/03/2026", "13/04/2027", "14/05/2020"]) {
input.value = String(query); input.listeners.input();
assert.equal(rows(), 1, String(query));
assert.equal(walk(modal).find(node => node.tag === "input"), input, "input is not replaced while typing");
@@ -160,7 +160,7 @@ test("installed base searches every displayed column and formatted values, inclu
assert.ok(walk(modal).some(node => node.textContent === "Synthetic failure"));
app.installBaseView.closeInstallBaseModal();
app.installBaseView.openInstallBaseModal(customer).render([row]);
assert.equal(walk(overlay).filter(node => node.tag === "td").length, 12, "reopening starts without an invisible filter");
assert.equal(walk(overlay).filter(node => node.tag === "td").length, 11, "reopening starts without an invisible filter");
});
test("modal shows skeleton, renders only requested fields safely and ignores closed/replaced responses", () => {
@@ -168,7 +168,7 @@ test("modal shows skeleton, renders only requested fields safely and ignores clo
const first = app.installBaseView.openInstallBaseModal(customer);
const modal = overlay.children[0];
assert.equal(modal.attributes["aria-busy"], "true");
assert.equal(walk(modal).filter(node => node.tag === "th").length, 12);
assert.equal(walk(modal).filter(node => node.tag === "th").length, 11);
const second = app.installBaseView.openInstallBaseModal(customer);
first.render([{ productName: "Stale product" }]);
assert.equal(overlay.children[0].attributes["aria-busy"], "true");
@@ -176,14 +176,14 @@ test("modal shows skeleton, renders only requested fields safely and ignores clo
currentSupport: 1234.56, licenseValue: 0, supportStartDate: "2026-03-12", secret: "not-visible" }]);
const current = overlay.children[0];
const cells = walk(current).filter(node => node.tag === "td");
assert.equal(cells.length, 12);
assert.equal(cells[2].textContent, "<script>unsafe</script>");
assert.equal(cells[2].children.length, 0);
assert.equal(cells[3].textContent, "1,234.56");
assert.equal(cells[5].children[0].dataset.status, "ACTIVE");
assert.equal(cells[7].textContent, "0");
assert.equal(cells[10].textContent, "0.00");
assert.ok(cells[8].textContent.includes("2026"));
assert.equal(cells.length, 11);
assert.equal(cells[1].textContent, "<script>unsafe</script>");
assert.equal(cells[1].children.length, 0);
assert.equal(cells[2].textContent, "1,234.56");
assert.equal(cells[4].children[0].dataset.status, "ACTIVE");
assert.equal(cells[6].textContent, "0");
assert.equal(cells[9].textContent, "0.00");
assert.ok(cells[7].textContent.includes("2026"));
current.listeners.keydown({ key: "Tab", preventDefault() {} });
assert.equal(document.activeElement.type, "search");
current.listeners.keydown({ key: "Tab", preventDefault() {} });

View File

@@ -25,9 +25,10 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
const host = new Element("host");
document = { createElement: tag => new Element(tag), getElementById: () => host };
const state = { status: "ready", items };
const h = { state, filtered: items, version: 1, instances: [], loads: 0, host, document };
const h = { state, filtered: items, version: 1, range: { startCloseDate: "2026-09-01", endCloseDate: "2027-03-01" }, instances: [], loads: 0, host, document };
class Calendar {
constructor(element, options) { this.element = element; this.options = options; this.events = options.events; this.dates = []; h.instances.push(this); }
setOption(key, value) { this.options[key] = value; }
render() { this.rendered = true; }
destroy() { this.destroyed = true; }
updateSize() { this.resized = true; }
@@ -37,7 +38,7 @@ function setup(items = [opportunity("A", "2026-11-15")]) {
addEventSource(events) { this.events = events; }
}
h.api = factory({ environment: { document }, loadLibrary: () => { h.loads++; return { Calendar }; },
getState: () => state, getItems: () => h.filtered, getVersion: () => h.version,
getState: () => state, getItems: () => h.filtered, getVersion: () => h.version, getRange: () => h.range,
formatRevenue: () => "$100", formatDate: value => value.slice(0, 10), getOwnerName: () => "Owner",
createDetailLink: (text, href) => { const a = new Element("a"); a.textContent = text; a.href = href; return a; },
createCopyButton: () => new Element("button"), createCloseIcon: () => new Element("svg"),
@@ -57,10 +58,6 @@ test("calendar controls, accessibility labels and missing dates are English", ()
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", null)]);
const { options } = h.activate();
assert.equal(options.locale, "en");
assert.deepEqual(plain(options.buttonText), { today: "Today", month: "Month", week: "Week" });
assert.deepEqual(plain(options.buttonHints), { prev: "Previous", next: "Next", today: "Today", dayGridMonth: "Month", dayGridWeek: "Week" });
assert.equal(options.moreLinkHint(1), "View 1 more opportunity");
assert.equal(options.moreLinkHint(3), "View 3 more opportunities");
assert.ok(h.nodes().some(node => node.attributes["aria-label"] === "Opportunity views"));
assert.ok(h.nodes().some(node => node.textContent === "No valid Close Date (1)"));
});
@@ -126,7 +123,7 @@ test("initial calendar date selects the next close date, last past date or today
assert.equal(api.initialDate([opportunity("A", null)], now), "2026-10-05");
});
test("overview is the default; tabs lazily initialize and reuse a monthly/weekly calendar", () => {
test("overview is the default; tabs lazily initialize and reuse a scrollable monthly calendar", () => {
const h = setup(); h.mount();
const surface = h.byClass("opportunities-extension-calendar");
assert.equal(h.table.hidden, false); assert.equal(surface.hidden, true); assert.equal(h.loads, 0);
@@ -137,13 +134,13 @@ test("overview is the default; tabs lazily initialize and reuse a monthly/weekly
tabs[1].click();
const instance = h.instances[0];
assert.equal(h.table.hidden, true); assert.equal(surface.hidden, false);
assert.equal(instance.options.initialView, "dayGridMonth");
assert.equal(instance.options.initialView, "referenceMonths");
assert.equal(instance.options.height, "100%");
assert.equal(instance.options.expandRows, true);
assert.equal(instance.options.headerToolbar.right, "dayGridMonth,dayGridWeek");
assert.equal(instance.options.editable, false);
assert.equal(instance.options.dayMaxEvents, 2);
assert.equal(instance.options.moreLinkText(7), "... +7");
assert.equal(instance.options.dayMaxEvents, false);
assert.equal(instance.options.headerToolbar, false);
assert.deepEqual(plain(instance.options.validRange), {start:"2026-09-01",end:"2027-03-01"});
assert.equal(instance.options.weekNumbers, true);
tabs[0].click(); tabs[1].click();
assert.equal(h.loads, 1); assert.equal(h.instances.length, 1); assert.equal(instance.resized, true);
@@ -151,24 +148,15 @@ test("overview is the default; tabs lazily initialize and reuse a monthly/weekly
assert.equal(h.table.hidden, false); assert.equal(h.document.activeElement, tabs[0]);
});
test("responsive event limit reserves room for every week and the overflow control", () => {
const { api } = setup();
assert.equal(api.visibleEventLimit(240, 6, 21), 0);
assert.equal(api.visibleEventLimit(500, 5, 21), 1);
assert.equal(api.visibleEventLimit(800, 5, 21), 2);
assert.equal(api.visibleEventLimit(500, 1, 21), 2);
assert.equal(api.visibleEventLimit(0, 6, 21), 0);
});
test("calendar follows filtered data; only a new Reference request changes its date", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-12-12")]);
const calendar = h.activate();
h.filtered = [h.state.items[1]]; h.api.render(h.filtered, h.state);
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["B"]);
assert.equal(calendar.dates.length, 0);
h.version++; h.state.items = [opportunity("C", "2027-02-20")]; h.filtered = h.state.items;
h.range = { startCloseDate: "2026-12-01", endCloseDate: "2027-03-01" }; h.version++; h.state.items = [opportunity("C", "2027-02-20")]; h.filtered = h.state.items;
h.api.render(h.filtered, h.state);
assert.deepEqual(calendar.dates, ["2027-02-20"]);
assert.deepEqual(calendar.dates, ["2026-12-01"]);
assert.deepEqual(plain(calendar.events.map(event => event.id)), ["C"]);
h.filtered = []; h.api.render(h.filtered, h.state);
assert.equal(calendar.events.length, 0);
@@ -194,7 +182,7 @@ test("event details close on changed data, loading/error hide stale calendar con
assert.ok(h.nodes().some(node => node.attributes.role === "dialog"));
h.api.render([], h.state);
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
for (const status of ["loading", "error", "empty"]) {
for (const status of ["loading", "error"]) {
h.api.render([], { status, items: [], message: "State message" });
assert.equal(h.byClass("opportunities-calendar-grid").hidden, true);
assert.equal(h.byClass("opportunities-calendar-undated").hidden, true);
@@ -202,29 +190,6 @@ test("event details close on changed data, loading/error hide stale calendar con
}
});
test("overflow uses our dialog instead of the FullCalendar offsetParent-dependent popover", () => {
const h = setup([opportunity("A", "2026-11-15"), opportunity("B", "2026-11-15"), opportunity("C", "2026-11-15")]);
const calendar = h.activate();
const trigger = h.document.createElement("a");
const allSegs = calendar.events.map(event => ({ event }));
const open = () => calendar.options.moreLinkClick({ date: new Date("2026-11-15T00:00:00Z"), allSegs,
jsEvent: { currentTarget: trigger, preventDefault() {} } });
assert.equal(open(), true, "truthy non-string result suppresses vendor popover");
const list = h.byClass("opportunities-calendar-day-list");
assert.equal(list.children.length, 3);
assert.ok(list.children.every(button => button.children.length === 2 && button.children[1].textContent.includes(" | FORECAST | $100")));
list.children[2].click();
assert.ok(h.nodes().some(node => node.tag === "h2" && node.textContent === "<Opportunity C>"));
h.api.closeDetails();
assert.equal(h.document.activeElement, trigger);
open(); h.api.selectView("overview");
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
h.api.selectView("calendar"); open();
const dialog = h.nodes().find(node => node.attributes.role === "dialog");
dialog.listeners.keydown({ key: "Escape", preventDefault() {}, stopPropagation() {} });
assert.ok(!h.nodes().some(node => node.attributes.role === "dialog"));
});
test("calendar events show their stage and handle missing stage values", () => {
const h = setup();
const calendar = h.activate();
@@ -237,3 +202,13 @@ test("calendar events show their stage and handle missing stage values", () => {
assert.equal(domNodes[0].children[1].title, `A | ${label} | $100`);
}
});
test("Reference boundaries exclude the next period and keep empty months visible", () => {
const h = setup([opportunity("before", "2026-08-31"), opportunity("first", "2026-09-01"), opportunity("last", "2026-11-30"), opportunity("next", "2026-12-01")]);
h.range = {startCloseDate:"2026-09-01",endCloseDate:"2026-12-01"};
const calendar = h.activate();
assert.deepEqual(plain(calendar.events.map(e=>e.id)), ["first","last"]);
h.api.render([], {status:"empty",items:[]});
assert.equal(h.byClass("opportunities-calendar-grid").hidden,false);
assert.equal(calendar.events.length,0);
});

View File

@@ -37,13 +37,11 @@ function setup(items = [synthetic()], localStorage) {
getElementById: id => id === app.contracts.OPPORTUNITIES_TABLE_ID ? table : null };
const app = loadApplication("content", { document, Intl, Date, localStorage });
app.currentUser.allowsCrmActivities = () => true;
const summaries = [];
app.aiSummaryContent.requestGenerateAiSummary = number => summaries.push(number);
app.opportunitiesTable.setOpportunitiesTableState({ status: "ready", items });
const rows = () => table.children.find(child => child.tag === "tbody").children;
const cell = (key, index = 0) => rows()[index].children.find(child => child.attributes["data-column"] === key);
const headers = () => table.children.find(child => child.tag === "thead").children[0].children;
return { app, table, rows, cell, headers, summaries };
return { app, table, rows, cell, headers };
}
test("Overview restores independent column and direction preferences across reloads and new data", () => {
@@ -104,7 +102,7 @@ test("ramp months preserve zero and flag missing values beside the product count
[[0], "0"], [[0, "3", null, " 6 ", 3], "0 | 3 | N/A | 6 | 3"]
]) {
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", {
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c }))
fromCache: true, items: values.map(RampMonths_c => ({ RampMonths_c, WorkloadName_c: "Synthetic workload" }))
});
h.app.opportunitiesTable.renderOpportunitiesTable();
if (expected === null) assert.equal(badge(), undefined);
@@ -121,12 +119,11 @@ test("ramp months preserve zero and flag missing values beside the product count
}
});
test("products remain expandable with AI summary and a ten-column detail row after sorting", () => {
test("products remain expandable with a ten-column detail row after sorting", () => {
const h = setup([synthetic("B", "Zulu"), synthetic("A", "Alpha")]);
h.app.productsContentState.opptyProductsByNumber.set("B", { items: [{ ProdGroupName: "Cloud", RevnAmount: "10" }] });
h.app.opportunitiesTable.renderOpportunitiesTable();
findClass(h.cell("name"), "opportunities-extension-products-count").click();
assert.deepEqual(h.summaries, ["B"]);
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row");
assert.equal(h.rows()[1].children[0].colSpan, 10);
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
@@ -136,7 +133,6 @@ test("products remain expandable with AI summary and a ten-column detail row aft
assert.equal(h.rows()[2].children[0].colSpan, 10);
findClass(h.cell("name", 1), "opportunities-extension-products-count").click();
assert.equal(h.rows().length, 2);
assert.deepEqual(h.summaries, ["B"], "collapsing does not generate another summary");
});
test("product labels support multiple digits without changing their tooltip", () => {
@@ -149,7 +145,7 @@ test("product labels support multiple digits without changing their tooltip", ()
assert.equal(count.attributes["aria-label"], "12 products for opportunity TEST-1");
});
test("opportunity projection alert is last and aggregates only divergent products", () => {
test("opportunity projection alert follows the ramp and aggregates only divergent products", () => {
const h = setup();
const alert = () => findClass(h.cell("name"), "opportunities-extension-opportunity-projection-alert");
const product = amount => ({ RampMonths_c: 0, workload: { adjustedACR: 1200, consumptionStartDate: "2026-10-01",
@@ -159,17 +155,15 @@ test("opportunity projection alert is last and aggregates only divergent product
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(alert().title, "Consumption forecast differs in 2 products. View products.");
const actions = findClass(h.cell("name"), "opportunities-extension-cell-actions");
assert.equal(actions.children.at(-1), alert());
assert.equal(actions.children[2], alert());
assert.equal(alert().tag, "button");
assert.equal(alert().attributes["aria-expanded"], "false");
alert().click();
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row");
assert.equal(alert().attributes["aria-expanded"], "true");
assert.equal(walk(h.rows()[1]).filter(node => node.attributes["aria-haspopup"] === "dialog").length, 2);
assert.deepEqual(h.summaries, ["TEST-1"]);
alert().click();
assert.equal(h.rows()[1].className, "opportunities-extension-products-detail-row", "clicking again never collapses the list");
assert.deepEqual(h.summaries, ["TEST-1"], "already expanded list is not reloaded");
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [product(100), {}] });
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(alert(), undefined);
@@ -200,7 +194,7 @@ test("CRM SR badge follows Ramp Months and remains visible while product data is
await h.app.opportunityCrmServiceRequests.load("synthetic", h.app.opportunitiesQueryState.periodRequestVersion);
h.app.opportunitiesTable.renderOpportunitiesTable();
assert.equal(findClass(h.cell("name"), "opportunities-extension-crm-sr-badge").textContent, "0h");
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3, WorkloadName_c: "Synthetic workload" }] });
h.app.opportunitiesTable.renderOpportunitiesTable();
const actions = h.cell("name").children[0].children[1];
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "3", "0h", "?"]);
@@ -319,6 +313,35 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.ok(remaining > 0);
assert.ok(remaining / 2 >= 12 + 114, "four customer controls fit after the left inset");
}
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css"].map(read).join("\n");
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css", "src/features/opportunities/calendar.css", "src/features/manage-time/styles.css", "src/features/products/ramp-projection.css", "src/features/org-chart/styles.css", "src/content/styles/tables.css", "src/content/styles/dialogs.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});
test("Booking replaces ramp only when every product has no Workload name", () => {
const h = setup();
for (const value of [undefined, null, "", " ", "-"]) {
const badge = h.app.productsDetails.createProductsRampBadge({items:[{WorkloadName_c:value},{WorkloadName_c:" - "}]}, {});
assert.equal(badge.textContent,"Booking");
assert.equal(badge.tag,"span");
assert.equal(badge.attributes["aria-haspopup"],undefined);
}
const mixed = h.app.productsDetails.createProductsRampBadge({items:[{WorkloadName_c:"-",RampMonths_c:2},{WorkloadName_c:"Migration",RampMonths_c:3}]},{});
assert.equal(mixed.textContent,"2 | 3");
assert.equal(mixed.tag,"button");
});
test("Booking and Workload filters use product names and compose with shared opportunity filtering", () => {
const h = setup();
const selected = h.app.opportunitiesFilterState.selectedProductTypes;
const result = { items: [{ WorkloadName_c: "-" }, { WorkloadName_c: "" }] };
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", result);
selected.delete("Workload");
assert.equal(h.app.opportunitiesFilters.getSortedOpportunities().length, 1);
selected.clear(); selected.add("Workload");
assert.equal(h.app.opportunitiesFilters.getSortedOpportunities().length, 0);
result.items[1].WorkloadName_c = "Migration";
assert.equal(h.app.opportunitiesFilters.getSortedOpportunities().length, 1);
selected.clear();
assert.equal(h.app.opportunitiesFilters.getSortedOpportunities().length, 0);
selected.add("Booking"); selected.add("Workload");
assert.equal(h.app.opportunitiesFilters.getSortedOpportunities().length, 1);
});

View File

@@ -227,5 +227,8 @@ test("product column opens accessible read-only comparison and restores focus/ba
assert.equal(content.inert, false);
assert.equal(document.activeElement, alert);
assert.equal(app.rampProjectionView.createIndicator(product(), {}).textContent, "OK");
assert.equal(app.rampProjectionView.createIndicator({}, {}).textContent, "-");
assert.equal(app.rampProjectionView.createIndicator({}, {}).disabled, true);
app.rampProjectionView.open(product(), {}, alert);
assert.ok(walk(host).some(node => node.textContent === "0 months with a difference"));
app.rampProjectionView.close();
});

View File

@@ -2,6 +2,42 @@ const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
test("failed preparation exposes Retry, deduplicates clicks and removes it on success", async () => {
const host = { append() {} };
const nodes = [];
const app = loadApplication("content", {
document: { querySelector: () => host, getElementById: () => null,
createElement: tag => {
const node = { tag, children: [], setAttribute() {}, append(child) { this.children.push(child); },
addEventListener(_event, fn) { this.click = fn; },
set textContent(value) { this.text = value; this.children = []; } };
nodes.push(node); return node;
} },
setTimeout: () => 1, clearTimeout() {}
});
let attempts = 0;
app.currentUser.ensure = async () => {};
app.currentUser.allowsCrmActivities = () => true;
app.currentUser.getUserId = () => "test@example.invalid";
app.debugContentLog.appendBackgroundRequestLog = () => {};
app.platformContentMessages.sendRuntimeMessage = async message => {
if (message.type === "opportunitiesExtension.requestResourceUser") return { ok: true };
return { ok: ++attempts > 1 };
};
await app.srSessionPreparation.ensure();
const retry = nodes.find(node => node.tag === "button");
assert.equal(retry.text, "Retry");
retry.click();
const pending = app.srSessionPreparation.retry();
assert.equal(app.srSessionPreparation.retry(), pending);
await pending;
assert.equal(attempts, 2);
assert.equal(nodes[0].text, "SR session ready");
assert.equal(nodes[0].children.length, 0);
await app.srSessionPreparation.retry();
assert.equal(attempts, 2);
});
test("preparation runs once, shares pending work and reports loading and completion", async () => {
const app = loadApplication("content");
const progress = [];
@@ -26,7 +62,8 @@ test("preparation runs once, shares pending work and reports loading and complet
};
const first = app.srSessionPreparation.ensure();
assert.equal(app.srSessionPreparation.ensure(), first);
assert.equal(requests, 0, "identity must load first");
assert.equal(requests, 1, "SR session starts while SPA identity is pending");
assert.equal(resourceRequests, 0, "resource user still requires the identity");
resolveUser();
await new Promise(resolve => setImmediate(resolve));
assert.deepEqual(progress, [["session", "running"]]);
@@ -38,7 +75,7 @@ test("preparation runs once, shares pending work and reports loading and complet
assert.deepEqual(progress.at(-1), ["session", "complete"]);
});
test("SALES_REP skips preparation; failed preparation does not auto-repeat or reject modal loading", async () => {
test("failed parallel preparation does not auto-repeat or reject modal loading", async () => {
for (const allowed of [false, true]) {
const app = loadApplication("content");
let requests = 0;
@@ -50,7 +87,7 @@ test("SALES_REP skips preparation; failed preparation does not auto-repeat or re
app.platformContentMessages.sendRuntimeMessage = async () => { requests += 1; return { ok: false }; };
await app.srSessionPreparation.ensure();
await app.srSessionPreparation.ensure();
assert.equal(requests, allowed ? 1 : 0);
assert.deepEqual(progress, allowed ? [["session", "running"], ["session", "error"]] : []);
assert.equal(requests, 1);
assert.deepEqual(progress, [["session", "running"], ["session", "error"]]);
}
});

View File

@@ -36,7 +36,7 @@ function loadContent({ fetch = unexpectedFetch, storage = new Map(), xsrf = null
app.platformContentMessages.refreshXsrfCookie = async () => { refreshes.push(true); return { refreshed: true }; };
return {
...app.opportunitiesFiscalPeriods, ...app.fusionOpportunities, ...app.authTokenRelay,
...app.authSalesCommon, ...app.preferencesStorage, ...app.opportunitiesFilters,
...app.preferencesStorage, ...app.opportunitiesFilters,
...app.opportunitiesFormatters, ...app.contentTheme, ...app.debugState,
setVersion: value => { app.opportunitiesQueryState.periodRequestVersion = value; },
setTypes: values => { app.preferencesState.selectedOpportunityTypeValues = new Set(values); },