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

View File

@@ -30,11 +30,26 @@ The build recreates `dist/chromium` and `dist/firefox` from the same source.
Do not edit files under `dist` directly. Both targets use Manifest V3, with a Do not edit files under `dist` directly. Both targets use Manifest V3, with a
Chromium service worker and Firefox background scripts. The build composes classic Chromium service worker and Firefox background scripts. The build composes classic
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
context. It serializes `src/content/styles/redwood.css` at build time. There is no context. It serializes `src/content/styles/redwood.css` followed by the scoped
`src/features/debug/styles.css` at build time. There is no
runtime module loader, browser-side eval, ES-module requirement or new dependency. runtime module loader, browser-side eval, ES-module requirement or new dependency.
## Tests and Maintenance ## Tests and Maintenance
Product HTTP requests appear in Debug Requests (Ctrl+D) as soon as they start.
Each entry identifies the opportunity and page offset, stays `running` while
waiting for headers or the response body, and updates with HTTP status, response,
duration or error. Cache hits do not create HTTP entries. Product diagnostics
mask authorization and sensitive response headers before messaging the source
tab/frame; this change adds no timeout, retry or authentication behavior.
Debug tabs group Authentication, Opportunities, Products, AI and Consumption
requests; unknown request types appear under Other. Only categories with entries
are shown. All preserves the complete timeline. Every tab sorts by request start
time, retaining original request IDs and showing start times in collapsed rows.
Counts and running states update without changing the selected category or
collapsing expanded details. Use arrow keys, Home and End to navigate the tabs.
```bash ```bash
npm test npm test
npm run check npm run check

View File

@@ -8,6 +8,7 @@ function createMessageTypes() {
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie", refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts", requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress", opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService", requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans", requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend" requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
@@ -757,6 +758,15 @@ function createProductsBackgroundJobs({
let completed = 0; let completed = 0;
let failed = 0; let failed = 0;
let cacheHits = 0; let cacheHits = 0;
let debugDelivery = Promise.resolve();
const reportRequest = entry => {
const message = {
type: messageTypes.opptyProductsRequestDebug,
requestId,
entry: { ...entry, debugKey: JSON.stringify([requestId, entry.metadata.optyNumber, entry.metadata.offset]) }
};
debugDelivery = debugDelivery.catch(() => {}).then(() => sendProductsProgress(sender, message));
};
const runWorker = async () => { const runWorker = async () => {
while (nextIndex < optyNumbers.length) { while (nextIndex < optyNumbers.length) {
@@ -765,7 +775,7 @@ function createProductsBackgroundJobs({
const optyNumber = optyNumbers[index]; const optyNumber = optyNumbers[index];
try { try {
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs); const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, reportRequest);
completed += 1; completed += 1;
if (result.fromCache) { if (result.fromCache) {
@@ -806,6 +816,7 @@ function createProductsBackgroundJobs({
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1)); const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
await Promise.all(Array.from({ length: workerCount }, runWorker)); await Promise.all(Array.from({ length: workerCount }, runWorker));
await debugDelivery.catch(() => {});
const state = failed > 0 ? "complete-with-errors" : "complete"; const state = failed > 0 ? "complete-with-errors" : "complete";
await sendProductsProgress(sender, { await sendProductsProgress(sender, {
@@ -848,7 +859,7 @@ function createProductsCache({
storageLocalGet, storageLocalGet,
storageLocalSet storageLocalSet
}) { }) {
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) { async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
const cache = await getProductsCache(); const cache = await getProductsCache();
const cachedEntry = cache[optyNumber]; const cachedEntry = cache[optyNumber];
@@ -857,7 +868,14 @@ function createProductsCache({
} }
if (!productsCacheState.inFlightProducts.has(optyNumber)) { if (!productsCacheState.inFlightProducts.has(optyNumber)) {
const request = fetchAllOpptyProducts(optyNumber, accessToken) const flight = { listeners: new Set(), entries: new Map(), promise: null };
productsCacheState.inFlightProducts.set(optyNumber, flight);
flight.promise = fetchAllOpptyProducts(optyNumber, accessToken, entry => {
flight.entries.set(entry.metadata.offset, entry);
for (const listener of flight.listeners) {
try { listener(entry); } catch (_) {}
}
})
.then(async (items) => { .then(async (items) => {
const fetchedAt = Date.now(); const fetchedAt = Date.now();
cache[optyNumber] = { cache[optyNumber] = {
@@ -870,15 +888,20 @@ function createProductsCache({
.finally(() => { .finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber); productsCacheState.inFlightProducts.delete(optyNumber);
}); });
productsCacheState.inFlightProducts.set(optyNumber, request);
} }
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber); const flight = productsCacheState.inFlightProducts.get(optyNumber);
return { // A second tab/job shares the fetch but still receives its current diagnostics.
items: fetchedResult.items, flight.listeners.add(onDebug);
cachedAt: fetchedResult.fetchedAt, for (const entry of flight.entries.values()) {
fromCache: false try { onDebug(entry); } catch (_) {}
}; }
try {
const fetchedResult = await flight.promise;
return { items: fetchedResult.items, cachedAt: fetchedResult.fetchedAt, fromCache: false };
} finally {
flight.listeners.delete(onDebug);
}
} }
function normalizeCacheMaxAge(value) { function normalizeCacheMaxAge(value) {
@@ -918,29 +941,67 @@ function createFusionProducts({
OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_PAGE_LIMIT,
ORACLE_DOMAIN, ORACLE_DOMAIN,
createBackgroundDebugEntry,
completeBackgroundDebugEntry,
createDebugBody,
headersToObject,
environment environment
}) { }) {
const { fetch } = environment; const { fetch } = environment;
async function fetchAllOpptyProducts(optyNumber, accessToken) { async function fetchAllOpptyProducts(optyNumber, accessToken, onDebug = () => {}) {
let offset = 0; let offset = 0;
let page = 0; let page = 0;
const items = []; const items = [];
while (page < OPPTY_PRODUCTS_MAX_PAGES) { while (page < OPPTY_PRODUCTS_MAX_PAGES) {
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), { const url = createOpptyProductsUrl(optyNumber, offset);
const options = {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
Authorization: `Bearer ${accessToken}` Authorization: `Bearer ${accessToken}`
} }
}); };
const entry = createBackgroundDebugEntry("requestOpptyProducts", url, {
if (!response.ok) { ...options, headers: { Accept: "application/json", Authorization: "(present)" }
throw new Error(`requestOpptyProducts failed with status ${response.status}.`); }, new Date());
entry.metadata = { executionContext: "background", optyNumber, offset, page: page + 1 };
const report = () => {
// Diagnostics must not block requests or transmit the Bearer credential.
try { onDebug({ ...entry, metadata: { ...entry.metadata } }); } catch (_) {}
};
let response;
let responseBody = { value: "", truncated: false };
let responseData;
report();
try {
response = await fetch(url, options);
entry.httpStatus = response.status;
entry.statusText = response.statusText || "";
entry.responseHeaders = headersToObject(response.headers);
Object.keys(entry.responseHeaders).forEach(key => {
if (/authorization|cookie|token/i.test(key)) entry.responseHeaders[key] = "(present)";
});
entry.metadata.phase = "reading-response";
report();
const text = await response.text();
responseBody = createDebugBody(text);
if (!response.ok) throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
responseData = JSON.parse(text);
if (!responseData || typeof responseData !== "object") throw new Error("requestOpptyProducts returned an invalid response.");
completeBackgroundDebugEntry(entry, response, responseBody, "");
} catch (error) {
completeBackgroundDebugEntry(entry, response, responseBody, error.message || "Unable to load products.");
entry.status = "failed";
throw error;
} finally {
Object.keys(entry.responseHeaders).forEach(key => {
if (/authorization|cookie|token/i.test(key)) entry.responseHeaders[key] = "(present)";
});
entry.metadata.phase = "complete";
report();
} }
const responseData = await response.json();
const pageItems = Array.isArray(responseData.items) ? responseData.items : []; const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
items.push(...pageItems); items.push(...pageItems);
page += 1; page += 1;
@@ -1561,6 +1622,10 @@ function createBackgroundApplication(overrides = {}) {
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args) storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
}); });
const fusionProducts = createFusionProducts({ const fusionProducts = createFusionProducts({
createBackgroundDebugEntry: (...args) => httpBackgroundJson.createBackgroundDebugEntry(...args),
completeBackgroundDebugEntry: (...args) => httpBackgroundJson.completeBackgroundDebugEntry(...args),
createDebugBody: (...args) => httpBackgroundJson.createDebugBody(...args),
headersToObject: (...args) => httpBackgroundJson.headersToObject(...args),
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS, OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@ function createMessageTypes() {
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie", refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts", requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress", opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService", requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans", requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend" requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
@@ -757,6 +758,15 @@ function createProductsBackgroundJobs({
let completed = 0; let completed = 0;
let failed = 0; let failed = 0;
let cacheHits = 0; let cacheHits = 0;
let debugDelivery = Promise.resolve();
const reportRequest = entry => {
const message = {
type: messageTypes.opptyProductsRequestDebug,
requestId,
entry: { ...entry, debugKey: JSON.stringify([requestId, entry.metadata.optyNumber, entry.metadata.offset]) }
};
debugDelivery = debugDelivery.catch(() => {}).then(() => sendProductsProgress(sender, message));
};
const runWorker = async () => { const runWorker = async () => {
while (nextIndex < optyNumbers.length) { while (nextIndex < optyNumbers.length) {
@@ -765,7 +775,7 @@ function createProductsBackgroundJobs({
const optyNumber = optyNumbers[index]; const optyNumber = optyNumbers[index];
try { try {
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs); const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, reportRequest);
completed += 1; completed += 1;
if (result.fromCache) { if (result.fromCache) {
@@ -806,6 +816,7 @@ function createProductsBackgroundJobs({
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1)); const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
await Promise.all(Array.from({ length: workerCount }, runWorker)); await Promise.all(Array.from({ length: workerCount }, runWorker));
await debugDelivery.catch(() => {});
const state = failed > 0 ? "complete-with-errors" : "complete"; const state = failed > 0 ? "complete-with-errors" : "complete";
await sendProductsProgress(sender, { await sendProductsProgress(sender, {
@@ -848,7 +859,7 @@ function createProductsCache({
storageLocalGet, storageLocalGet,
storageLocalSet storageLocalSet
}) { }) {
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) { async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
const cache = await getProductsCache(); const cache = await getProductsCache();
const cachedEntry = cache[optyNumber]; const cachedEntry = cache[optyNumber];
@@ -857,7 +868,14 @@ function createProductsCache({
} }
if (!productsCacheState.inFlightProducts.has(optyNumber)) { if (!productsCacheState.inFlightProducts.has(optyNumber)) {
const request = fetchAllOpptyProducts(optyNumber, accessToken) const flight = { listeners: new Set(), entries: new Map(), promise: null };
productsCacheState.inFlightProducts.set(optyNumber, flight);
flight.promise = fetchAllOpptyProducts(optyNumber, accessToken, entry => {
flight.entries.set(entry.metadata.offset, entry);
for (const listener of flight.listeners) {
try { listener(entry); } catch (_) {}
}
})
.then(async (items) => { .then(async (items) => {
const fetchedAt = Date.now(); const fetchedAt = Date.now();
cache[optyNumber] = { cache[optyNumber] = {
@@ -870,15 +888,20 @@ function createProductsCache({
.finally(() => { .finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber); productsCacheState.inFlightProducts.delete(optyNumber);
}); });
productsCacheState.inFlightProducts.set(optyNumber, request);
} }
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber); const flight = productsCacheState.inFlightProducts.get(optyNumber);
return { // A second tab/job shares the fetch but still receives its current diagnostics.
items: fetchedResult.items, flight.listeners.add(onDebug);
cachedAt: fetchedResult.fetchedAt, for (const entry of flight.entries.values()) {
fromCache: false try { onDebug(entry); } catch (_) {}
}; }
try {
const fetchedResult = await flight.promise;
return { items: fetchedResult.items, cachedAt: fetchedResult.fetchedAt, fromCache: false };
} finally {
flight.listeners.delete(onDebug);
}
} }
function normalizeCacheMaxAge(value) { function normalizeCacheMaxAge(value) {
@@ -918,29 +941,67 @@ function createFusionProducts({
OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_PAGE_LIMIT,
ORACLE_DOMAIN, ORACLE_DOMAIN,
createBackgroundDebugEntry,
completeBackgroundDebugEntry,
createDebugBody,
headersToObject,
environment environment
}) { }) {
const { fetch } = environment; const { fetch } = environment;
async function fetchAllOpptyProducts(optyNumber, accessToken) { async function fetchAllOpptyProducts(optyNumber, accessToken, onDebug = () => {}) {
let offset = 0; let offset = 0;
let page = 0; let page = 0;
const items = []; const items = [];
while (page < OPPTY_PRODUCTS_MAX_PAGES) { while (page < OPPTY_PRODUCTS_MAX_PAGES) {
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), { const url = createOpptyProductsUrl(optyNumber, offset);
const options = {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
Authorization: `Bearer ${accessToken}` Authorization: `Bearer ${accessToken}`
} }
}); };
const entry = createBackgroundDebugEntry("requestOpptyProducts", url, {
if (!response.ok) { ...options, headers: { Accept: "application/json", Authorization: "(present)" }
throw new Error(`requestOpptyProducts failed with status ${response.status}.`); }, new Date());
entry.metadata = { executionContext: "background", optyNumber, offset, page: page + 1 };
const report = () => {
// Diagnostics must not block requests or transmit the Bearer credential.
try { onDebug({ ...entry, metadata: { ...entry.metadata } }); } catch (_) {}
};
let response;
let responseBody = { value: "", truncated: false };
let responseData;
report();
try {
response = await fetch(url, options);
entry.httpStatus = response.status;
entry.statusText = response.statusText || "";
entry.responseHeaders = headersToObject(response.headers);
Object.keys(entry.responseHeaders).forEach(key => {
if (/authorization|cookie|token/i.test(key)) entry.responseHeaders[key] = "(present)";
});
entry.metadata.phase = "reading-response";
report();
const text = await response.text();
responseBody = createDebugBody(text);
if (!response.ok) throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
responseData = JSON.parse(text);
if (!responseData || typeof responseData !== "object") throw new Error("requestOpptyProducts returned an invalid response.");
completeBackgroundDebugEntry(entry, response, responseBody, "");
} catch (error) {
completeBackgroundDebugEntry(entry, response, responseBody, error.message || "Unable to load products.");
entry.status = "failed";
throw error;
} finally {
Object.keys(entry.responseHeaders).forEach(key => {
if (/authorization|cookie|token/i.test(key)) entry.responseHeaders[key] = "(present)";
});
entry.metadata.phase = "complete";
report();
} }
const responseData = await response.json();
const pageItems = Array.isArray(responseData.items) ? responseData.items : []; const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
items.push(...pageItems); items.push(...pageItems);
page += 1; page += 1;
@@ -1561,6 +1622,10 @@ function createBackgroundApplication(overrides = {}) {
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args) storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
}); });
const fusionProducts = createFusionProducts({ const fusionProducts = createFusionProducts({
createBackgroundDebugEntry: (...args) => httpBackgroundJson.createBackgroundDebugEntry(...args),
completeBackgroundDebugEntry: (...args) => httpBackgroundJson.completeBackgroundDebugEntry(...args),
createDebugBody: (...args) => httpBackgroundJson.createDebugBody(...args),
headersToObject: (...args) => httpBackgroundJson.headersToObject(...args),
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS, OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,8 @@ function composeScript(target, { bootstrap = true } = {}) {
return `// Source: ${file}\n${source}`; return `// Source: ${file}\n${source}`;
}); });
if (target === "content") { if (target === "content") {
const css = fs.readFileSync(path.join(root, "src/content/styles/redwood.css"), "utf8"); const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`); parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
} }
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication"; const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication";

View File

@@ -60,6 +60,10 @@ function createBackgroundApplication(overrides = {}) {
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args) storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
}); });
const fusionProducts = createFusionProducts({ const fusionProducts = createFusionProducts({
createBackgroundDebugEntry: (...args) => httpBackgroundJson.createBackgroundDebugEntry(...args),
completeBackgroundDebugEntry: (...args) => httpBackgroundJson.completeBackgroundDebugEntry(...args),
createDebugBody: (...args) => httpBackgroundJson.createDebugBody(...args),
headersToObject: (...args) => httpBackgroundJson.headersToObject(...args),
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS, OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,

View File

@@ -287,6 +287,7 @@ function createContentApplication(overrides = {}) {
environment environment
}); });
const productsContentJobs = createProductsContentJobs({ const productsContentJobs = createProductsContentJobs({
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
messageTypes, messageTypes,
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID, PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY, PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY,

View File

@@ -56,8 +56,11 @@ function createDebugContentLog({
} }
entries.forEach((entry) => { entries.forEach((entry) => {
debugState.requestLog.push({ const existing = entry.debugKey && debugState.requestLog.find(item => item.debugKey === entry.debugKey);
id: debugState.requestLog.length + 1, if (existing && existing.status !== "pending" && entry.status === "pending") return;
const normalized = {
id: existing ? existing.id : debugState.requestLog.length + 1,
debugKey: entry.debugKey,
name: entry.name || "backgroundRequest", name: entry.name || "backgroundRequest",
method: entry.method || "GET", method: entry.method || "GET",
url: entry.url || "", url: entry.url || "",
@@ -75,7 +78,9 @@ function createDebugContentLog({
responseBody: entry.responseBody || "", responseBody: entry.responseBody || "",
responseBodyTruncated: Boolean(entry.responseBodyTruncated), responseBodyTruncated: Boolean(entry.responseBodyTruncated),
error: entry.error || "" error: entry.error || ""
}); };
if (existing) Object.assign(existing, normalized);
else debugState.requestLog.push(normalized);
}); });
renderDebugPanel(); renderDebugPanel();
} }

View File

@@ -8,6 +8,27 @@ function createDebugContentView({
environment environment
}) { }) {
const { document } = environment; const { document } = environment;
const categories = [
["all", "All"], ["authentication", "Authentication"],
["opportunities", "Opportunities"], ["products", "Products"],
["ai", "AI"], ["consumption", "Consumption"], ["other", "Other"]
];
let selectedCategory = "all";
const expandedIds = new Set();
function getRequestCategory(entry) {
if (/^request(?:TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
if (entry.name === "requestOpportunities") return "opportunities";
if (entry.name === "requestOpptyProducts") return "products";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService)/.test(entry.name)) return "consumption";
return "other";
}
function getChronologicalRequests() {
return [...debugState.requestLog].sort((a, b) =>
(new Date(a.startedAt).getTime() || 0) - (new Date(b.startedAt).getTime() || 0) || a.id - b.id);
}
function toggleDebugPanel() { function toggleDebugPanel() {
const modal = document.getElementById(MODAL_ID); const modal = document.getElementById(MODAL_ID);
@@ -32,6 +53,13 @@ function createDebugContentView({
return; return;
} }
for (const item of panel.querySelectorAll("details[data-request-id]")) {
const id = item.getAttribute("data-request-id");
if (item.open) expandedIds.add(id);
else expandedIds.delete(id);
}
const focusedCategory = document.activeElement && document.activeElement.getAttribute("data-debug-category");
const scrollTop = panel.scrollTop;
panel.textContent = ""; panel.textContent = "";
const panelHeader = document.createElement("header"); const panelHeader = document.createElement("header");
@@ -43,6 +71,8 @@ function createDebugContentView({
const summary = document.createElement("p"); const summary = document.createElement("p");
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`; summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
const running = debugState.requestLog.filter(entry => entry.status === "pending").length;
if (running) summary.textContent += ` ${running} running.`;
heading.append(title, summary); heading.append(title, summary);
const visibilityButton = document.createElement("button"); const visibilityButton = document.createElement("button");
@@ -58,8 +88,51 @@ function createDebugContentView({
}); });
panelHeader.append(heading, visibilityButton); panelHeader.append(heading, visibilityButton);
const entries = getChronologicalRequests();
const availableCategories = categories.filter(([key]) => key === "all" || entries.some(entry => getRequestCategory(entry) === key));
if (!availableCategories.some(([key]) => key === selectedCategory)) selectedCategory = "all";
const tabs = document.createElement("div");
tabs.className = "opportunities-extension-debug-tabs";
tabs.setAttribute("role", "tablist");
tabs.setAttribute("aria-label", "Request categories");
const tabButtons = [];
availableCategories.forEach(([key, label], index) => {
const categoryEntries = key === "all" ? entries : entries.filter(entry => getRequestCategory(entry) === key);
const pending = categoryEntries.filter(entry => entry.status === "pending").length;
const button = document.createElement("button");
button.type = "button";
button.id = `${DEBUG_PANEL_ID}-tab-${key}`;
button.setAttribute("role", "tab");
button.setAttribute("data-debug-category", key);
button.setAttribute("aria-selected", String(key === selectedCategory));
button.setAttribute("aria-controls", `${DEBUG_PANEL_ID}-requests`);
button.tabIndex = key === selectedCategory ? 0 : -1;
button.textContent = `${label} (${categoryEntries.length})${pending ? ` / ${pending} running` : ""}`;
button.addEventListener("click", () => {
selectedCategory = key;
renderDebugPanel();
});
button.addEventListener("keydown", event => {
let nextIndex;
if (event.key === "ArrowRight") nextIndex = (index + 1) % availableCategories.length;
else if (event.key === "ArrowLeft") nextIndex = (index + availableCategories.length - 1) % availableCategories.length;
else if (event.key === "Home") nextIndex = 0;
else if (event.key === "End") nextIndex = availableCategories.length - 1;
else return;
event.preventDefault();
selectedCategory = availableCategories[nextIndex][0];
renderDebugPanel();
});
tabs.append(button);
tabButtons.push(button);
});
const list = document.createElement("div"); const list = document.createElement("div");
list.className = "opportunities-extension-debug-list"; list.className = "opportunities-extension-debug-list";
list.id = `${DEBUG_PANEL_ID}-requests`;
list.setAttribute("role", "tabpanel");
list.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-tab-${selectedCategory}`);
list.tabIndex = 0;
if (debugState.requestLog.length === 0) { if (debugState.requestLog.length === 0) {
const empty = document.createElement("div"); const empty = document.createElement("div");
@@ -68,15 +141,20 @@ function createDebugContentView({
list.append(empty); list.append(empty);
} }
debugState.requestLog.forEach((entry) => { entries.filter(entry => selectedCategory === "all" || getRequestCategory(entry) === selectedCategory).forEach((entry) => {
const item = document.createElement("details"); const item = document.createElement("details");
item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`; item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`;
item.setAttribute("data-request-id", String(entry.id));
item.open = expandedIds.has(String(entry.id));
const heading = document.createElement("summary"); const heading = document.createElement("summary");
heading.className = "opportunities-extension-debug-heading"; heading.className = "opportunities-extension-debug-heading";
const name = document.createElement("strong"); const name = document.createElement("strong");
name.textContent = `${entry.id}. ${entry.name}`; name.textContent = `${entry.id}. ${entry.name}`;
if (entry.name === "requestOpptyProducts" && entry.metadata.optyNumber) {
name.textContent += ` (${entry.metadata.optyNumber}, offset ${entry.metadata.offset})`;
}
const httpStatus = document.createElement("span"); const httpStatus = document.createElement("span");
httpStatus.className = "opportunities-extension-debug-http-status"; httpStatus.className = "opportunities-extension-debug-http-status";
@@ -84,11 +162,15 @@ function createDebugContentView({
const status = document.createElement("span"); const status = document.createElement("span");
status.className = "opportunities-extension-debug-status"; status.className = "opportunities-extension-debug-status";
status.textContent = entry.status; status.textContent = entry.status === "pending" ? "running" : entry.status;
const summaryMeta = document.createElement("span"); const summaryMeta = document.createElement("span");
summaryMeta.className = "opportunities-extension-debug-summary-meta"; summaryMeta.className = "opportunities-extension-debug-summary-meta";
summaryMeta.append(httpStatus, status); const startedTime = document.createElement("time");
startedTime.className = "opportunities-extension-debug-time";
startedTime.textContent = formatDebugTime(entry.startedAt);
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`;
summaryMeta.append(startedTime, httpStatus, status);
heading.append(name, summaryMeta); heading.append(name, summaryMeta);
@@ -114,7 +196,9 @@ function createDebugContentView({
list.append(item); list.append(item);
}); });
panel.append(panelHeader, list); panel.append(panelHeader, tabs, list);
if (focusedCategory) tabButtons.find(button => button.getAttribute("data-debug-category") === selectedCategory).focus({ preventScroll: true });
panel.scrollTop = scrollTop;
} }
function createDebugVisibilityIcon(secretsVisible) { function createDebugVisibilityIcon(secretsVisible) {
@@ -170,5 +254,5 @@ function createDebugContentView({
}); });
} }
return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime }; return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime, getRequestCategory, getChronologicalRequests };
} }

View File

@@ -0,0 +1,82 @@
/* Debug navigation only; preserve the extension's existing light/dark theme. */
.opportunities-extension-debug-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #dedbd7;
}
.opportunities-extension-debug-tabs button {
max-width: 100%;
min-height: 32px;
padding: 6px 8px;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
color: inherit;
font: inherit;
font-size: 12px;
line-height: 1.35;
overflow-wrap: anywhere;
cursor: pointer;
}
.opportunities-extension-debug-tabs button:hover {
background: #f5f4f2;
}
.opportunities-extension-debug-tabs button[aria-selected="true"] {
border-bottom-color: #00758f;
background: #e8f4f6;
color: #006b84;
font-weight: 700;
}
.opportunities-extension-debug-tabs button:focus-visible,
.opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
outline: 2px solid #00758f;
outline-offset: -2px;
}
.opportunities-extension-debug-time {
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.opportunities-extension-debug-heading {
flex-wrap: wrap;
}
.opportunities-extension-debug-heading strong {
flex: 1 1 180px;
min-width: 0;
overflow-wrap: anywhere;
}
.opportunities-extension-debug-summary-meta {
flex-wrap: wrap;
justify-content: flex-end;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs {
border-bottom-color: #514c47;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button:hover {
background: #383633;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button[aria-selected="true"] {
background: #173f48;
border-bottom-color: #8cc8d3;
color: #bde7ee;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button:focus-visible,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-list[role="tabpanel"]:focus-visible {
outline-color: #8cc8d3;
}

View File

@@ -23,6 +23,15 @@ function createProductsBackgroundJobs({
let completed = 0; let completed = 0;
let failed = 0; let failed = 0;
let cacheHits = 0; let cacheHits = 0;
let debugDelivery = Promise.resolve();
const reportRequest = entry => {
const message = {
type: messageTypes.opptyProductsRequestDebug,
requestId,
entry: { ...entry, debugKey: JSON.stringify([requestId, entry.metadata.optyNumber, entry.metadata.offset]) }
};
debugDelivery = debugDelivery.catch(() => {}).then(() => sendProductsProgress(sender, message));
};
const runWorker = async () => { const runWorker = async () => {
while (nextIndex < optyNumbers.length) { while (nextIndex < optyNumbers.length) {
@@ -31,7 +40,7 @@ function createProductsBackgroundJobs({
const optyNumber = optyNumbers[index]; const optyNumber = optyNumbers[index];
try { try {
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs); const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, reportRequest);
completed += 1; completed += 1;
if (result.fromCache) { if (result.fromCache) {
@@ -72,6 +81,7 @@ function createProductsBackgroundJobs({
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1)); const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
await Promise.all(Array.from({ length: workerCount }, runWorker)); await Promise.all(Array.from({ length: workerCount }, runWorker));
await debugDelivery.catch(() => {});
const state = failed > 0 ? "complete-with-errors" : "complete"; const state = failed > 0 ? "complete-with-errors" : "complete";
await sendProductsProgress(sender, { await sendProductsProgress(sender, {

View File

@@ -5,7 +5,7 @@ function createProductsCache({
storageLocalGet, storageLocalGet,
storageLocalSet storageLocalSet
}) { }) {
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) { async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
const cache = await getProductsCache(); const cache = await getProductsCache();
const cachedEntry = cache[optyNumber]; const cachedEntry = cache[optyNumber];
@@ -14,7 +14,14 @@ function createProductsCache({
} }
if (!productsCacheState.inFlightProducts.has(optyNumber)) { if (!productsCacheState.inFlightProducts.has(optyNumber)) {
const request = fetchAllOpptyProducts(optyNumber, accessToken) const flight = { listeners: new Set(), entries: new Map(), promise: null };
productsCacheState.inFlightProducts.set(optyNumber, flight);
flight.promise = fetchAllOpptyProducts(optyNumber, accessToken, entry => {
flight.entries.set(entry.metadata.offset, entry);
for (const listener of flight.listeners) {
try { listener(entry); } catch (_) {}
}
})
.then(async (items) => { .then(async (items) => {
const fetchedAt = Date.now(); const fetchedAt = Date.now();
cache[optyNumber] = { cache[optyNumber] = {
@@ -27,15 +34,20 @@ function createProductsCache({
.finally(() => { .finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber); productsCacheState.inFlightProducts.delete(optyNumber);
}); });
productsCacheState.inFlightProducts.set(optyNumber, request);
} }
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber); const flight = productsCacheState.inFlightProducts.get(optyNumber);
return { // A second tab/job shares the fetch but still receives its current diagnostics.
items: fetchedResult.items, flight.listeners.add(onDebug);
cachedAt: fetchedResult.fetchedAt, for (const entry of flight.entries.values()) {
fromCache: false try { onDebug(entry); } catch (_) {}
}; }
try {
const fetchedResult = await flight.promise;
return { items: fetchedResult.items, cachedAt: fetchedResult.fetchedAt, fromCache: false };
} finally {
flight.listeners.delete(onDebug);
}
} }
function normalizeCacheMaxAge(value) { function normalizeCacheMaxAge(value) {

View File

@@ -3,6 +3,7 @@ function createProductsContentJobs({
PRODUCTS_CACHE_TIMESTAMP_ID, PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_MESSAGE_LISTENER_KEY, PRODUCTS_MESSAGE_LISTENER_KEY,
PRODUCTS_PROGRESS_ID, PRODUCTS_PROGRESS_ID,
appendBackgroundRequestLog,
preferencesState, preferencesState,
productsContentState, productsContentState,
renderOpportunitiesTable, renderOpportunitiesTable,
@@ -67,7 +68,14 @@ function createProductsContentJobs({
window[PRODUCTS_MESSAGE_LISTENER_KEY] = true; window[PRODUCTS_MESSAGE_LISTENER_KEY] = true;
runtimeApi.runtime.onMessage.addListener((message) => { runtimeApi.runtime.onMessage.addListener((message) => {
if (!message || message.type !== messageTypes.opptyProductsProgress || message.requestId !== productsContentState.currentProductsRequestId) { if (!message || message.requestId !== productsContentState.currentProductsRequestId) {
return false;
}
if (message.type === messageTypes.opptyProductsRequestDebug) {
if (message.entry && typeof message.entry.debugKey === "string") appendBackgroundRequestLog([message.entry]);
return false;
}
if (message.type !== messageTypes.opptyProductsProgress) {
return false; return false;
} }

View File

@@ -3,29 +3,67 @@ function createFusionProducts({
OPPTY_PRODUCTS_MAX_PAGES, OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT, OPPTY_PRODUCTS_PAGE_LIMIT,
ORACLE_DOMAIN, ORACLE_DOMAIN,
createBackgroundDebugEntry,
completeBackgroundDebugEntry,
createDebugBody,
headersToObject,
environment environment
}) { }) {
const { fetch } = environment; const { fetch } = environment;
async function fetchAllOpptyProducts(optyNumber, accessToken) { async function fetchAllOpptyProducts(optyNumber, accessToken, onDebug = () => {}) {
let offset = 0; let offset = 0;
let page = 0; let page = 0;
const items = []; const items = [];
while (page < OPPTY_PRODUCTS_MAX_PAGES) { while (page < OPPTY_PRODUCTS_MAX_PAGES) {
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), { const url = createOpptyProductsUrl(optyNumber, offset);
const options = {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
headers: { headers: {
Accept: "application/json", Accept: "application/json",
Authorization: `Bearer ${accessToken}` Authorization: `Bearer ${accessToken}`
} }
}); };
const entry = createBackgroundDebugEntry("requestOpptyProducts", url, {
if (!response.ok) { ...options, headers: { Accept: "application/json", Authorization: "(present)" }
throw new Error(`requestOpptyProducts failed with status ${response.status}.`); }, new Date());
entry.metadata = { executionContext: "background", optyNumber, offset, page: page + 1 };
const report = () => {
// Diagnostics must not block requests or transmit the Bearer credential.
try { onDebug({ ...entry, metadata: { ...entry.metadata } }); } catch (_) {}
};
let response;
let responseBody = { value: "", truncated: false };
let responseData;
report();
try {
response = await fetch(url, options);
entry.httpStatus = response.status;
entry.statusText = response.statusText || "";
entry.responseHeaders = headersToObject(response.headers);
Object.keys(entry.responseHeaders).forEach(key => {
if (/authorization|cookie|token/i.test(key)) entry.responseHeaders[key] = "(present)";
});
entry.metadata.phase = "reading-response";
report();
const text = await response.text();
responseBody = createDebugBody(text);
if (!response.ok) throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
responseData = JSON.parse(text);
if (!responseData || typeof responseData !== "object") throw new Error("requestOpptyProducts returned an invalid response.");
completeBackgroundDebugEntry(entry, response, responseBody, "");
} catch (error) {
completeBackgroundDebugEntry(entry, response, responseBody, error.message || "Unable to load products.");
entry.status = "failed";
throw error;
} finally {
Object.keys(entry.responseHeaders).forEach(key => {
if (/authorization|cookie|token/i.test(key)) entry.responseHeaders[key] = "(present)";
});
entry.metadata.phase = "complete";
report();
} }
const responseData = await response.json();
const pageItems = Array.isArray(responseData.items) ? responseData.items : []; const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
items.push(...pageItems); items.push(...pageItems);
page += 1; page += 1;

View File

@@ -5,6 +5,7 @@ function createMessageTypes() {
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie", refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts", requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress", opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService", requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans", requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend" requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"

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 time = clock();
const listeners = []; const listeners = [];
const progress = []; const progress = [];
const requestDebug = [];
const tabs = []; const tabs = [];
const removedTabs = []; const removedTabs = [];
const cookieQueries = []; 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 getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) });
const store = value => Object.assign(storage, plain(value)); const store = value => Object.assign(storage, plain(value));
const openTab = details => { tabs.push(plain(details)); return { id: 91 }; }; 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") { if (family === "firefox") {
api.runtime.getBrowserInfo = async () => ({ name: "Firefox" }); api.runtime.getBrowserInfo = async () => ({ name: "Firefox" });
api.storage = { local: { 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")); } 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 }; 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 };