Adjustment to the request debug view functionality

This commit is contained in:
2026-09-18 16:46:47 -03:00
parent ac817a69d4
commit eed4189a07
20 changed files with 945 additions and 87 deletions

100
tests/debugTabs.test.cjs Normal file
View File

@@ -0,0 +1,100 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const fs = require("node:fs");
const path = require("node:path");
const { loadApplication, composeScript } = require("./support/application.cjs");
const { createDebugDom } = require("./support/debug-dom.cjs");
function fixture() {
const dom = createDebugDom();
const app = loadApplication("content", { document: dom.document });
const add = (id, name, second, status = "success") => app.debugContentLog.appendBackgroundRequestLog([{
debugKey: id, name, status, startedAt: `2026-09-18T12:00:${String(second).padStart(2, "0")}.000Z`,
metadata: name === "requestOpptyProducts" ? { optyNumber: "SYNTH-1", offset: 0 } : {},
requestHeaders: { Authorization: "synthetic-private" }
}]);
const nodes = () => dom.descendants(dom.panel);
const tab = category => nodes().find(node => node.getAttribute("data-debug-category") === category);
const ids = () => nodes().filter(node => node.tag === "details").map(node => node.getAttribute("data-request-id"));
const select = category => { const button = tab(category); button.focus(); button.listeners.click(); };
return { ...dom, app, add, nodes, tab, ids, select };
}
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",
requestConsumerTokenService: "authentication", requestCustomerTokenService: "authentication",
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "consumption",
requestConsumptionServiceWeekly: "consumption", requestConsumptionServiceDaily: "consumption", futureRequest: "other"
})) assert.equal(h.app.debugContentView.getRequestCategory({ name }), category, name);
h.app.debugContentView.renderDebugPanel();
assert.ok(h.tab("all"));
assert.equal(h.tab("products"), undefined);
h.add("future", "futureRequest", 0);
h.select("other");
assert.deepEqual(h.ids(), ["1"]);
});
test("All and category views sort by start time, preserve original ids and leave the log untouched", () => {
const h = fixture();
h.add("p-late", "requestOpptyProducts", 30, "pending");
h.add("auth", "requestTokenRelay", 10);
h.add("p-early", "requestOpptyProducts", 20);
assert.deepEqual(h.ids(), ["2", "3", "1"]);
assert.deepEqual(Array.from(h.app.debugState.requestLog, entry => entry.id), [1, 2, 3]);
h.select("products");
assert.deepEqual(h.ids(), ["3", "1"]);
assert.equal(h.tab("products").textContent, "Products (2) / 1 running");
assert.equal(h.nodes().filter(node => node.tag === "time").length, 2);
h.select("all");
assert.deepEqual(h.ids(), ["2", "3", "1"]);
assert.equal(h.tab("all").textContent, "All (3) / 1 running");
});
test("category, expansion, focus and hidden credentials survive asynchronous updates", () => {
const h = fixture();
h.add("p", "requestOpptyProducts", 10, "pending");
h.add("auth", "requestTokenRelay", 5);
h.select("products");
h.nodes().find(node => node.tag === "details").open = true;
h.select("authentication");
h.add("p", "requestOpptyProducts", 10);
assert.deepEqual(h.ids(), ["2"]);
assert.equal(h.tab("authentication").getAttribute("aria-selected"), "true");
assert.equal(h.document.activeElement, h.tab("authentication"));
h.select("products");
assert.equal(h.nodes().find(node => node.tag === "details").open, true);
assert.equal(h.tab("products").textContent, "Products (1)");
assert.ok(!h.nodes().some(node => node.text.includes("synthetic-private")));
h.nodes().find(node => node.tag === "details").open = false;
h.app.debugContentView.renderDebugPanel();
assert.equal(h.nodes().find(node => node.tag === "details").open, false);
});
test("tabs support arrows, Home and End with a single selected tab and labelled panel", () => {
const h = fixture();
h.add("auth", "requestTokenRelay", 1);
h.add("p", "requestOpptyProducts", 2);
h.select("all");
for (const [key, expected] of [["ArrowRight", "authentication"], ["End", "products"], ["ArrowRight", "all"], ["ArrowLeft", "products"], ["Home", "all"]]) {
let prevented = false;
h.document.activeElement.listeners.keydown({ key, preventDefault() { prevented = true; } });
assert.equal(prevented, true);
const selected = h.nodes().filter(node => node.getAttribute("aria-selected") === "true");
assert.equal(selected.length, 1);
assert.equal(selected[0], h.tab(expected));
assert.equal(h.document.activeElement, selected[0]);
assert.equal(h.nodes().find(node => node.getAttribute("role") === "tabpanel").getAttribute("aria-labelledby"), selected[0].id);
}
});
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"]
.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

@@ -0,0 +1,140 @@
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 { createDebugDom } = require("./support/debug-dom.cjs");
const drain = () => new Promise(resolve => setImmediate(resolve));
const request = (requestId, optyNumbers = ["SYNTH-1"]) => ({
type: "opportunitiesExtension.requestOpptyProducts", requestId, optyNumbers, accessToken: "synthetic-private-bearer"
});
for (const family of ["chrome", "firefox"]) {
test(`${family}: products appear in debug before fetch resolves; pages update in place with safe headers`, async () => {
const calls = [];
const h = loadBackground({ family, fetch: (url, options) => new Promise(resolve => calls.push({ url, options, resolve })) });
const job = h.dispatch(request("pages"));
await drain();
assert.equal(calls.length, 1);
assert.equal(h.progress.length, 0);
const start = h.requestDebug[0];
assert.equal(start.tabId, 7);
assert.deepEqual(start.options, { frameId: 2 });
assert.equal(start.message.entry.name, "requestOpptyProducts");
assert.equal(start.message.entry.status, "pending");
assert.equal(start.message.entry.completedAt, null);
assert.equal(start.message.entry.url, calls[0].url);
assert.equal(start.message.entry.method, "GET");
assert.equal(start.message.entry.metadata.optyNumber, "SYNTH-1");
assert.equal(calls[0].options.headers.Authorization, "Bearer synthetic-private-bearer");
calls[0].resolve(response({ items: [{ RevnNumber: "R1" }], count: 1, offset: 0, hasMore: true }));
await drain();
assert.equal(calls.length, 2);
const second = h.requestDebug.at(-1).message.entry;
assert.equal(second.status, "pending");
assert.equal(second.metadata.offset, 1);
assert.notEqual(second.debugKey, start.message.entry.debugKey);
calls[1].resolve(response({ items: [{ RevnNumber: "R2" }], hasMore: false }));
assert.equal((await job).ok, true);
const ends = h.requestDebug.filter(event => event.message.entry.status === "success");
assert.equal(ends.length, 2);
assert.equal(ends[0].message.entry.debugKey, start.message.entry.debugKey);
assert.equal(ends[0].message.entry.httpStatus, 200);
assert.ok(ends[0].message.entry.completedAt);
assert.match(ends[0].message.entry.responseBody, /R1/);
assert.equal(ends[0].message.entry.responseHeaders["content-type"], "application/json");
assert.doesNotMatch(JSON.stringify(h.requestDebug), /synthetic-private-bearer/);
const recorded = h.requestDebug.length;
assert.equal((await h.dispatch(request("cached"))).cacheHits, 1);
assert.equal(h.requestDebug.length, recorded, "cache hits must not fabricate HTTP requests");
});
test(`${family}: concurrent jobs share products and each receives running diagnostics`, async () => {
let resolveFetch;
let calls = 0;
const h = loadBackground({ family, fetch: () => { calls++; return new Promise(resolve => { resolveFetch = resolve; }); } });
const first = h.dispatch(request("first"));
await drain();
const second = h.dispatch(request("second"));
await drain();
assert.equal(calls, 1);
assert.deepEqual(h.requestDebug.map(event => event.message.requestId), ["first", "second"]);
assert.ok(h.requestDebug.every(event => event.message.entry.status === "pending"));
resolveFetch(response({ items: [], hasMore: false }));
await Promise.all([first, second]);
assert.deepEqual(h.requestDebug.filter(event => event.message.entry.status === "success").map(event => event.message.requestId).sort(), ["first", "second"]);
});
test(`${family}: failed HTTP, network, JSON and response streams finalize pending entries`, async () => {
for (const failure of ["http", "network", "json", "body"]) {
const h = loadBackground({ family, fetch: async () => {
if (failure === "network") throw new Error("Synthetic offline");
if (failure === "body") return { ok: true, status: 200, headers: new Headers(), text: async () => { throw new Error("Synthetic body failure"); } };
return new Response(failure === "json" ? "<html>Synthetic login</html>" : "Denied", { status: failure === "http" ? 403 : 200 });
} });
assert.equal((await h.dispatch(request(failure))).failed, 1);
const entry = h.requestDebug.at(-1).message.entry;
assert.equal(entry.status, "failed", failure);
assert.ok(entry.error, failure);
assert.ok(entry.completedAt, failure);
assert.equal(entry.httpStatus, failure === "network" ? null : failure === "http" ? 403 : 200);
if (failure === "json") assert.match(entry.responseBody, /Synthetic login/);
}
});
}
test("a body still streaming remains running even after HTTP 200 headers arrive", async () => {
let finishBody;
const h = loadBackground({ fetch: async () => ({ ok: true, status: 200, headers: new Headers({ "set-cookie": "synthetic-secret" }),
text: () => new Promise(resolve => { finishBody = resolve; }) }) });
const job = h.dispatch(request("stream"));
await drain();
const pending = h.requestDebug.at(-1).message.entry;
assert.equal(pending.status, "pending");
assert.equal(pending.httpStatus, 200);
assert.equal(pending.metadata.phase, "reading-response");
assert.equal(pending.completedAt, null);
finishBody('{"items":[],"hasMore":false}');
await job;
assert.doesNotMatch(JSON.stringify(h.requestDebug), /synthetic-secret/);
});
test("content upserts each page, ignores stale jobs and never changes product counts for debug events", () => {
let listener;
const app = loadApplication("content", { document: { getElementById: () => null }, window: {},
chrome: { runtime: { onMessage: { addListener: fn => { listener = fn; } } } } });
app.debugContentView.renderDebugPanel = () => {};
app.productsContentState.currentProductsRequestId = "active";
app.productsContentJobs.ensureProductsBackgroundMessageHandler();
const entry = { debugKey: "page-1", name: "requestOpptyProducts", status: "pending", startedAt: new Date().toISOString(),
metadata: { optyNumber: "SYNTH-1", offset: 0 }, requestHeaders: { Authorization: "(present)" } };
const send = (requestId, value) => listener({ type: "opportunitiesExtension.opptyProductsRequestDebug", requestId, entry: value });
send("old", entry);
assert.equal(app.debugState.requestLog.length, 0);
send("active", entry);
assert.equal(app.debugState.requestLog[0].status, "pending");
send("active", { ...entry, status: "success", httpStatus: 200, completedAt: new Date().toISOString(), responseBody: '{"items":[]}' });
send("active", entry);
assert.equal(app.debugState.requestLog.length, 1);
assert.equal(app.debugState.requestLog[0].status, "success");
assert.equal(app.debugState.requestLog[0].id, 1);
assert.equal(app.productsContentState.opptyProductsByNumber.size, 0);
assert.equal(app.productsContentState.productsProgress.completed, 0);
});
test("debug shows running opportunity identity and retains expanded details on completion", () => {
const { document, panel, descendants } = createDebugDom();
const app = loadApplication("content", { document });
const entry = { debugKey: "page-view", name: "requestOpptyProducts", status: "pending",
startedAt: new Date().toISOString(), metadata: { optyNumber: "SYNTH-1", offset: 15 } };
app.debugContentLog.appendBackgroundRequestLog([entry]);
assert.ok(descendants(panel).some(node => node.text.includes("SYNTH-1, offset 15")));
assert.ok(descendants(panel).some(node => node.text === "running"));
descendants(panel).find(node => node.tag === "details").open = true;
panel.scrollTop = 80;
app.debugContentLog.appendBackgroundRequestLog([{ ...entry, status: "success", httpStatus: 200, completedAt: new Date().toISOString() }]);
assert.equal(descendants(panel).filter(node => node.tag === "details").length, 1);
assert.equal(descendants(panel).find(node => node.tag === "details").open, true);
assert.equal(panel.scrollTop, 80);
assert.ok(descendants(panel).some(node => node.text === "success"));
});

View File

@@ -61,6 +61,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
const time = clock();
const listeners = [];
const progress = [];
const requestDebug = [];
const tabs = [];
const removedTabs = [];
const cookieQueries = [];
@@ -69,7 +70,10 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) });
const store = value => Object.assign(storage, plain(value));
const openTab = details => { tabs.push(plain(details)); return { id: 91 }; };
const send = (tabId, message, options) => progress.push({ tabId, message: plain(message), options: plain(options || {}) });
const send = (tabId, message, options) => {
const events = message.type === "opportunitiesExtension.opptyProductsRequestDebug" ? requestDebug : progress;
events.push({ tabId, message: plain(message), options: plain(options || {}) });
};
if (family === "firefox") {
api.runtime.getBrowserInfo = async () => ({ name: "Firefox" });
api.storage = { local: {
@@ -128,7 +132,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
if (pending !== true) { clearTimeout(timer); reject(new Error("Expected asynchronous response contract")); }
});
}
return { dispatch, listener: listeners[0], progress, tabs, removedTabs, cookieQueries, storage, advance: time.advance };
return { dispatch, listener: listeners[0], progress, requestDebug, tabs, removedTabs, cookieQueries, storage, advance: time.advance };
}
module.exports = { root, plain, response, loadContent, loadBackground };

View File

@@ -0,0 +1,25 @@
function createDebugDom() {
const descendants = node => [node, ...node.children.flatMap(descendants)];
const document = { activeElement: null };
function element(tag) {
return {
tag, children: [], attributes: {}, listeners: {}, scrollTop: 0, hidden: false, text: "",
set textContent(value) { this.text = value; this.children = []; },
get textContent() { return this.text; },
append(...nodes) { this.children.push(...nodes); },
setAttribute(key, value) { this.attributes[key] = value; },
getAttribute(key) { return this.attributes[key]; },
addEventListener(name, listener) { this.listeners[name] = listener; },
focus() { document.activeElement = this; },
querySelectorAll(selector) {
return descendants(this).filter(node => node.tag === "details"
&& node.getAttribute("data-request-id") && (!selector.includes("[open]") || node.open));
}
};
}
const panel = element("aside");
Object.assign(document, { getElementById: () => panel, createElement: element, createElementNS: (_, tag) => element(tag) });
return { document, panel, descendants };
}
module.exports = { createDebugDom };