Badge indicator showing whether an opportunity has an associated Workload Workbench Action

This commit is contained in:
2026-09-24 17:00:32 -03:00
parent f9595b94e0
commit f74071e39e
30 changed files with 1625 additions and 68 deletions

View File

@@ -3,7 +3,7 @@ const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
const response = data => new Response(JSON.stringify(data), { status: 200 });
function setup(fetch) {
const document = { createElement: () => ({ attributes: {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
const document = { createElement: () => ({ attributes: {}, addEventListener() {}, setAttribute(key, value) { this.attributes[key] = value; } }) };
const app = loadApplication("content", { fetch, document, Intl, Date });
app.debugContentView.renderDebugPanel = () => {};
app.opportunitiesQueryState.periodRequestVersion = 1;
@@ -81,7 +81,7 @@ test("CRM query preserves payload, paginates before opportunities, uses tokenrel
assert.equal(found.textContent, "\u2705 SR");
assert.equal(found.attributes["data-state"], "matched");
for (const detail of ["SR-0", "SR-1", "Reported Hours: 0", "Test Owner", "test@example.invalid", "<script>synthetic</script>"])
assert.ok(found.title.includes(detail));
assert.ok(found.attributes["aria-label"].includes(detail));
assert.equal(app.opportunityCrmServiceRequests.createBadge("OTHER").textContent, "\u26d4 SR");
assert.equal(app.opportunityCrmServiceRequests.createBadge(null), null);
app.opportunityCrmServiceRequests.reset();

105
tests/crmSrPopover.test.cjs Normal file
View File

@@ -0,0 +1,105 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
function setup() {
const document = { activeElement: null, listeners: new Map() };
const window = { innerWidth: 400, innerHeight: 600, listeners: new Map(), setTimeout, clearTimeout };
for (const target of [document, window]) {
target.addEventListener = (key, fn) => target.listeners.set(key, fn);
target.removeEventListener = key => target.listeners.delete(key);
}
const walk = node => [node, ...node.children.flatMap(walk)];
function element(tag) {
return {
tag, children: [], attributes: {}, listeners: {}, style: {}, textContent: "",
append(...nodes) { nodes.forEach(node => { node.parent = this; this.children.push(node); }); },
remove() { this.parent.children = this.parent.children.filter(node => node !== this); this.parent = null; },
setAttribute(key, value) { this.attributes[key] = value; },
addEventListener(key, fn) { this.listeners[key] = fn; },
contains(target) { return walk(this).includes(target); },
get isConnected() { return this === modal || !!this.parent?.isConnected; },
getBoundingClientRect() { return { left: 350, bottom: 550, height: 250 }; },
querySelector() { return walk(this).find(node => node.tag === "a" || node.tag === "button"); },
focus() { document.activeElement = this; this.listeners.focus?.(); }
};
}
const modal = element("main");
Object.assign(document, {
createElement: element, createElementNS: (_, tag) => element(tag),
getElementById: id => id === "opportunities-extension-modal" ? modal : null
});
const copied = [];
const app = loadApplication("content", { document, window, setTimeout, clearTimeout,
navigator: { clipboard: { writeText: async value => copied.push(value) } } });
// Keep the copy test independent of the toast's own DOM/timer implementation.
modal.querySelector = () => ({ classList: { toggle() {} } });
window.setTimeout = () => 0;
app.fusionCrmServiceRequests.requestCrmServiceRequest = async () => [
{ SrId: "NOT-THE-URL-ID", SrNumber: "SR /?&1", Title: "<script>synthetic</script>",
ReportedHours_c: 0, OpportunityName_Id_c: { OptyNumber: "OP1" } },
{ SrNumber: "SR2", OpportunityName_Id_c: { OptyNumber: "OP1" } }
];
app.opportunitiesQueryState.periodRequestVersion = 1;
const panel = () => modal.children.find(node => node.className === "opportunities-extension-sr-popover");
return { app, document, window, modal, walk, copied, panel };
}
test("SR popover uses encoded SrNumber, safe details and existing copy control for every record", async () => {
const { app, modal, walk, copied, panel } = setup();
await app.opportunityCrmServiceRequests.load("synthetic", 1);
const badge = app.opportunityCrmServiceRequests.createBadge("OP1");
modal.append(badge);
badge.listeners.pointerenter();
const nodes = walk(panel());
const links = nodes.filter(node => node.tag === "a");
assert.equal(links.length, 2);
for (const link of links) {
assert.equal(link.parent.children[0].textContent, "SR Number:");
assert.equal(link.parent.children[2].className, "opportunities-extension-copy-button");
}
const details = nodes.filter(node => node.className === "opportunities-extension-sr-details");
assert.equal(details.length, 2);
details.forEach(node => assert.doesNotMatch(node.textContent, /SR Number:|SR \/\?&1|SR2/));
assert.equal(links[0].href, "https://eeho.fa.us2.oraclecloud.com/fscmUI/redwood/service/ec/container/sr/edit?srNumber=SR%20%2F%3F%261");
assert.equal(links[0].rel, "noopener noreferrer");
assert.equal(links[0].target, "_blank");
assert.ok(nodes.some(node => node.textContent.includes("<script>synthetic</script>")));
assert.equal(nodes.some(node => node.tag === "script"), false);
const copies = nodes.filter(node => node.className === "opportunities-extension-copy-button");
assert.equal(copies.length, 2);
assert.equal(copies[0].title, "Copy SR number");
copies[0].listeners.click({ preventDefault() {}, stopPropagation() {} });
await Promise.resolve();
assert.deepEqual(copied, ["SR /?&1"]);
assert.equal(panel().style.width, "376px");
app.opportunityCrmServiceRequests.reset();
assert.equal(panel(), undefined);
});
test("SR popover pins on click, closes on Escape/outside/scroll/reset and removes global listeners", async () => {
const { app, modal, document, window, panel } = setup();
await app.opportunityCrmServiceRequests.load("synthetic", 1);
const badge = app.opportunityCrmServiceRequests.createBadge("OP1");
modal.append(badge);
badge.listeners.click({ stopPropagation() {} });
assert.equal(badge.attributes["aria-expanded"], "true");
assert.ok(panel().contains(document.activeElement));
document.listeners.get("keydown")({ key: "Escape", preventDefault() {}, stopPropagation() {} });
assert.equal(panel(), undefined);
assert.equal(document.activeElement, badge);
assert.equal(document.listeners.size, 0);
assert.equal(window.listeners.size, 0);
badge.listeners.pointerenter();
document.listeners.get("pointerdown")({ target: modal });
assert.equal(panel(), undefined);
badge.listeners.pointerenter();
document.listeners.get("scroll")({ target: panel() });
assert.ok(panel(), "scroll inside the tooltip keeps it open");
document.listeners.get("scroll")({ target: modal });
assert.equal(panel(), undefined);
badge.listeners.pointerenter();
app.opportunityCrmServiceRequests.reset();
assert.equal(panel(), undefined);
assert.equal(document.listeners.size, 0);
});

View File

@@ -127,7 +127,7 @@ test("CRM SR badge follows Ramp Months and remains visible while product data is
h.app.productsContentState.opptyProductsByNumber.set("TEST-1", { items: [{ RampMonths_c: 3 }] });
h.app.opportunitiesTable.renderOpportunitiesTable();
const actions = h.cell("name").children[0].children[1];
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "RM 3", "\u2705 SR"]);
assert.deepEqual(actions.children.map(child => child.textContent), ["1", "RM 3", "\u2705 SR", "Act ?"]);
findClass(h.cell("name"), "opportunities-extension-products-count").click();
assert.ok(findClass(h.rows()[1], "opportunities-extension-products-table"));
});

View File

@@ -0,0 +1,109 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadApplication } = require("./support/application.cjs");
function fixture(reduced = false) {
let now = 0, nextId = 0;
const timers = new Map();
const badge = { hidden: false, attributes: {}, setAttribute(key, value) { this.attributes[key] = value; } };
const timestamp = {};
const app = loadApplication("content", {
document: { getElementById: id => id === app.contracts.PRODUCTS_PROGRESS_ID ? badge : timestamp, querySelector: () => badge },
setTimeout: (fn, delay) => { const id = ++nextId; timers.set(id, { fn, at: now + delay }); return id; },
clearTimeout: id => timers.delete(id), matchMedia: () => ({ matches: reduced })
});
const render = state => {
app.productsContentState.productsProgress = { state, completed: 2, total: 2, failed: 0, cacheHits: 0 };
app.productsContentJobs.renderProductsProgress();
};
function advance(ms) {
const end = now + ms;
while (true) {
const entry = [...timers].filter(([, timer]) => timer.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
if (!entry) break;
now = entry[1].at;
timers.delete(entry[0]);
entry[1].fn();
}
now = end;
}
app.productsContentState.currentProductsRequestId = "first";
app.productsContentState.productsLastCachedAt = 1;
return { app, badge, timestamp, render, advance, timers };
}
test("Products ready stays for five seconds then fades for 300ms before hidden", () => {
const h = fixture();
h.render("complete");
h.advance(4999);
assert.equal(h.badge.hidden, false);
assert.equal(h.badge.attributes["data-fading"], "false");
h.render("complete");
h.advance(1);
assert.equal(h.badge.attributes["data-fading"], "true");
assert.equal(h.badge.hidden, false);
h.advance(300);
assert.equal(h.badge.hidden, true);
h.render("complete");
assert.equal(h.badge.hidden, true, "rerenders do not restart the completion timer");
assert.equal(h.timestamp.hidden, false, "Last update remains visible");
});
test("new product jobs cancel a pending fade and errors remain visible", () => {
const h = fixture();
h.render("complete");
h.advance(5000);
h.app.productsContentState.currentProductsRequestId = "second";
h.render("running");
h.advance(10000);
assert.equal(h.badge.hidden, false);
assert.equal(h.badge.attributes["data-fading"], "false");
assert.equal(h.timers.size, 0);
for (const state of ["complete-with-errors", "error"]) {
h.render(state);
h.advance(10000);
assert.equal(h.badge.hidden, false);
}
h.render("complete");
h.advance(5300);
assert.equal(h.badge.hidden, true);
});
test("reduced motion hides at five seconds without an animated delay", () => {
const h = fixture(true);
h.render("complete");
h.advance(5000);
assert.equal(h.badge.hidden, true);
});
test("Authenticated fades after five seconds without restarting on repeated status updates", () => {
const h = fixture();
const update = () => h.app.contentModal.updateAuthBadge(h.app.contracts.AUTH_STATUS.authenticated);
update();
h.advance(4999);
assert.equal(h.badge.hidden, false);
update();
h.advance(1);
assert.equal(h.badge.attributes["data-fading"], "true");
h.advance(300);
assert.equal(h.badge.hidden, true);
update();
assert.equal(h.badge.hidden, true);
h.app.contentModal.updateAuthBadge(h.app.contracts.AUTH_STATUS.unauthenticated);
h.advance(10000);
assert.equal(h.badge.hidden, false);
assert.equal(h.badge.attributes["data-fading"], "false");
update();
h.advance(5000);
h.app.contentModal.updateAuthBadge(h.app.contracts.AUTH_STATUS.pending);
h.advance(300);
assert.equal(h.badge.hidden, false);
assert.equal(h.badge.textContent, "Authenticating");
});
test("Authenticated respects reduced motion", () => {
const h = fixture(true);
h.app.contentModal.updateAuthBadge(h.app.contracts.AUTH_STATUS.authenticated);
h.advance(5000);
assert.equal(h.badge.hidden, true);
});

View File

@@ -0,0 +1,185 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response } = require("./support/characterization.cjs");
const { loadApplication } = require("./support/application.cjs");
const flush = () => new Promise(resolve => setImmediate(resolve));
for (const family of ["chrome", "firefox"]) {
test(`${family}: WWB-TS selection, fixed workload header, first-page GET and masked debug`, async () => {
const calls = [];
const h = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
if (url.endsWith("consumerTokenService")) return response([
{ tokenService: "ACCP-TS", uiaasHeader: { value: "summary" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "consumption" } },
{ tokenService: "WWB-TS", uiaasHeader: { label: "ignored-label", value: "synthetic-wwb" } }
]);
return response({ actions: [{ id: 1, name: "TEST" }], totalCount: 20, hasNext: true });
} });
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
assert.equal(auth.authorizations.workloadActions.value, "synthetic-wwb");
const result = await h.dispatch({ type: "opportunitiesExtension.requestWlActions", revnId: "12345", authorization: auth.authorizations.workloadActions });
assert.equal(result.ok, true);
assert.equal(result.hasNext, true);
assert.equal(result.actions.length, 1);
assert.equal(calls.length, 2, "no pagination");
assert.equal(calls[1].url, "https://spa.oracle.com/oalcrm/web/api/provider-proxy/wwb-provider/service/workbench-proxy/wwb/mgmt/actions?parentType=WORKLOAD&parentId=12345");
assert.equal(calls[1].options.method, "GET");
assert.equal(calls[1].options.headers["Wwb-Provider-Authorization"], "synthetic-wwb");
assert.equal(calls[1].options.headers.Authorization, undefined);
assert.equal(result.debugRequests[0].name, "requestWlActions");
assert.equal(result.debugRequests[0].requestHeaders["Wwb-Provider-Authorization"], "(present)");
});
}
test("workload actions reject missing credentials, invalid ids, HTTP/network/JSON failures", async () => {
for (const fetch of [
async () => { throw new Error("Synthetic network failure"); },
async () => new Response("Denied", { status: 401 }),
async () => new Response("Denied", { status: 403 }),
async () => new Response("<html>Login</html>"),
async () => response({ actions: null, hasNext: false })
]) {
const h = loadBackground({ fetch });
const result = await h.dispatch({ type: "opportunitiesExtension.requestWlActions", revnId: "123", authorization: { value: "synthetic" } });
assert.equal(result.ok, false);
assert.equal(result.debugRequests.length, 1);
}
const h = loadBackground({ fetch: () => assert.fail("Unexpected network") });
for (const fields of [{ revnId: "123" }, { revnId: "bad&id=2", authorization: { value: "synthetic" } }]) {
assert.equal((await h.dispatch({ type: "opportunitiesExtension.requestWlActions", ...fields })).ok, false);
}
});
function contentFixture(send) {
const app = loadApplication("content", { document: { createElement: () => ({ attributes: {}, setAttribute(k, v) { this.attributes[k] = v; } }) } });
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic-wwb" } });
app.authSessionState.principal = "test@example.invalid";
app.platformContentMessages.sendRuntimeMessage = send;
app.debugContentView.renderDebugPanel = () => {};
app.productsContentJobs.queueProductsTableRender = () => {};
return app;
}
test("actions aggregate all products, share RevnId queries across Reference changes and restart on reopen", async () => {
const calls = [];
const app = contentFixture(async message => {
calls.push(message);
return { ok: true, hasNext: false, actions: message.revnId === "1" ? [{ id: 1, owner: "test@example.invalid", name: "<script>safe</script>" }] : [] };
});
const products = { items: [{ RevnId: "1" }, { RevnId: 2 }, { RevnId: "1" }] };
app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products);
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ...");
await flush();
assert.equal(calls.length, 2);
assert.equal(calls[0].authorization.value, "synthetic-wwb");
const badge = app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge.textContent, "\u2705 Act");
assert.match(badge.title, /<script>safe<\/script>/);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
assert.equal(calls.length, 2);
app.productsContentState.opptyProductsByNumber.set("B", { items: [{ RevnId: 2 }] });
assert.equal(app.opportunityWorkloadActions.createBadge("B").textContent, "\u26d4 Act");
app.opportunityWorkloadActions.reset(false);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
assert.equal(calls.length, 2);
app.opportunityWorkloadActions.reopen();
await flush();
assert.equal(calls.length, 4);
assert.equal(app.debugContentView.getRequestCategory({ name: "requestWlActions" }), "actions");
});
test("errors, missing ids and partial empty pages never appear as confirmed absence", async () => {
for (const result of [{ ok: false, error: "Synthetic" }, { ok: true, actions: [], hasNext: true }]) {
const app = contentFixture(async () => result);
const products = { items: [{ RevnId: "1" }] };
app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
app.productsContentState.opptyProductsByNumber.set("A", { items: [{}] });
assert.equal(app.opportunityWorkloadActions.createBadge("A").textContent, "Act ?");
}
});
test("Act requires an owner matching tokenrelay principal while tooltip retains every action", async () => {
const actions = [
{ id: 1, name: "Other action", owner: "other@example.invalid", ownerEmail: "test@example.invalid" },
{ id: 2, name: "My action", owner: "test@example.invalid", ownerEmail: "different@example.invalid" }
];
const app = contentFixture(async () => ({ ok: true, actions, hasNext: false }));
const products = { items: [{ RevnId: "1" }] };
app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
const badge = () => app.opportunityWorkloadActions.createBadge("A");
assert.equal(badge().textContent, "\u2705 Act");
assert.match(badge().title, /Other action/);
assert.match(badge().title, /My action/);
app.authSessionState.principal = "different@example.invalid";
assert.equal(badge().textContent, "\u26d4 Act", "ownerEmail must not determine ownership");
assert.match(badge().title, /Other action/);
assert.match(badge().title, /My action/);
app.authSessionState.principal = "";
assert.equal(badge().textContent, "Act ?");
});
test("general tokenrelay retains principal without changing access-token return or accepting sales-common identity", async () => {
let payload = { access_token: "synthetic-token", principal: "test@example.invalid" };
const app = loadApplication("content", { fetch: async () => response(payload) });
app.authXsrfContent.getXsrfToken = async () => ({ token: "synthetic-xsrf" });
app.debugContentView.renderDebugPanel = () => {};
assert.equal(await app.authTokenRelay.requestTokenRelay(), "synthetic-token");
assert.equal(app.authSessionState.principal, "test@example.invalid");
payload = { access_token: "scoped-token", principal: "other@example.invalid", expires_in: 3600 };
await app.authTokenRelay.requestTokenRelay({ url: `${app.contracts.TOKEN_RELAY_URL}?scopesfq=synthetic`, includeExpiry: true });
assert.equal(app.authSessionState.principal, "test@example.invalid");
payload = { access_token: "synthetic-token" };
await app.authTokenRelay.requestTokenRelay();
assert.equal(app.authSessionState.principal, "");
});
test("actions queue caps concurrency and discards results/queued jobs from closed modals", async () => {
const pending = [];
const app = contentFixture(() => new Promise(resolve => pending.push(resolve)));
const products = { items: Array.from({ length: 8 }, (_, i) => ({ RevnId: String(i + 1) })) };
app.productsContentState.opptyProductsByNumber.set("A", products);
app.opportunityWorkloadActions.loadProducts(products);
await flush();
assert.equal(pending.length, 4);
app.opportunityWorkloadActions.reset(false);
pending.forEach(resolve => resolve({ ok: true, hasNext: false, actions: [{ id: 1 }] }));
await flush();
assert.equal(pending.length, 4);
assert.equal(app.opportunityWorkloadActions.createBadge("A"), null);
});
test("product progress triggers Actions for cached products and ignores stale product jobs", async () => {
let listener;
const calls = [];
const app = loadApplication("content", {
document: { getElementById: () => null },
window: { requestAnimationFrame() {} },
chrome: { runtime: { onMessage: { addListener(fn) { listener = fn; } } } }
});
app.authContentSession.requestConsumerTokenServiceOnce = async () => ({ workloadActions: { value: "synthetic" } });
app.platformContentMessages.sendRuntimeMessage = async message => {
calls.push(message);
return { ok: true, actions: [], hasNext: false };
};
app.productsContentState.currentProductsRequestId = "current";
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
const message = { type: app.messageTypes?.opptyProductsProgress || "opportunitiesExtension.opptyProductsProgress",
requestId: "current", optyNumber: "A", items: [{ RevnId: "123" }], fromCache: true };
listener({ ...message, requestId: "old" });
await flush();
assert.equal(calls.length, 0);
listener(message);
await flush();
assert.equal(calls.length, 1);
assert.equal(calls[0].type, "opportunitiesExtension.requestWlActions");
assert.equal(calls[0].revnId, "123");
});