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
Chromium service worker and Firefox background scripts. The build composes classic
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.
## 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
npm test
npm run check

View File

@@ -8,6 +8,7 @@ function createMessageTypes() {
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
@@ -757,6 +758,15 @@ function createProductsBackgroundJobs({
let completed = 0;
let failed = 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 () => {
while (nextIndex < optyNumbers.length) {
@@ -765,7 +775,7 @@ function createProductsBackgroundJobs({
const optyNumber = optyNumbers[index];
try {
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs);
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, reportRequest);
completed += 1;
if (result.fromCache) {
@@ -806,6 +816,7 @@ function createProductsBackgroundJobs({
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
await Promise.all(Array.from({ length: workerCount }, runWorker));
await debugDelivery.catch(() => {});
const state = failed > 0 ? "complete-with-errors" : "complete";
await sendProductsProgress(sender, {
@@ -848,7 +859,7 @@ function createProductsCache({
storageLocalGet,
storageLocalSet
}) {
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) {
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
const cache = await getProductsCache();
const cachedEntry = cache[optyNumber];
@@ -857,7 +868,14 @@ function createProductsCache({
}
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) => {
const fetchedAt = Date.now();
cache[optyNumber] = {
@@ -870,15 +888,20 @@ function createProductsCache({
.finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber);
});
productsCacheState.inFlightProducts.set(optyNumber, request);
}
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber);
return {
items: fetchedResult.items,
cachedAt: fetchedResult.fetchedAt,
fromCache: false
};
const flight = productsCacheState.inFlightProducts.get(optyNumber);
// A second tab/job shares the fetch but still receives its current diagnostics.
flight.listeners.add(onDebug);
for (const entry of flight.entries.values()) {
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) {
@@ -918,29 +941,67 @@ function createFusionProducts({
OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT,
ORACLE_DOMAIN,
createBackgroundDebugEntry,
completeBackgroundDebugEntry,
createDebugBody,
headersToObject,
environment
}) {
const { fetch } = environment;
async function fetchAllOpptyProducts(optyNumber, accessToken) {
async function fetchAllOpptyProducts(optyNumber, accessToken, onDebug = () => {}) {
let offset = 0;
let page = 0;
const items = [];
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), {
const url = createOpptyProductsUrl(optyNumber, offset);
const options = {
method: "GET",
credentials: "include",
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`
}
};
const entry = createBackgroundDebugEntry("requestOpptyProducts", url, {
...options, headers: { Accept: "application/json", Authorization: "(present)" }
}, 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)";
});
if (!response.ok) {
throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
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 : [];
items.push(...pageItems);
page += 1;
@@ -1561,6 +1622,10 @@ function createBackgroundApplication(overrides = {}) {
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
});
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_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
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",
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
@@ -757,6 +758,15 @@ function createProductsBackgroundJobs({
let completed = 0;
let failed = 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 () => {
while (nextIndex < optyNumbers.length) {
@@ -765,7 +775,7 @@ function createProductsBackgroundJobs({
const optyNumber = optyNumbers[index];
try {
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs);
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, reportRequest);
completed += 1;
if (result.fromCache) {
@@ -806,6 +816,7 @@ function createProductsBackgroundJobs({
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
await Promise.all(Array.from({ length: workerCount }, runWorker));
await debugDelivery.catch(() => {});
const state = failed > 0 ? "complete-with-errors" : "complete";
await sendProductsProgress(sender, {
@@ -848,7 +859,7 @@ function createProductsCache({
storageLocalGet,
storageLocalSet
}) {
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) {
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
const cache = await getProductsCache();
const cachedEntry = cache[optyNumber];
@@ -857,7 +868,14 @@ function createProductsCache({
}
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) => {
const fetchedAt = Date.now();
cache[optyNumber] = {
@@ -870,15 +888,20 @@ function createProductsCache({
.finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber);
});
productsCacheState.inFlightProducts.set(optyNumber, request);
}
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber);
return {
items: fetchedResult.items,
cachedAt: fetchedResult.fetchedAt,
fromCache: false
};
const flight = productsCacheState.inFlightProducts.get(optyNumber);
// A second tab/job shares the fetch but still receives its current diagnostics.
flight.listeners.add(onDebug);
for (const entry of flight.entries.values()) {
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) {
@@ -918,29 +941,67 @@ function createFusionProducts({
OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT,
ORACLE_DOMAIN,
createBackgroundDebugEntry,
completeBackgroundDebugEntry,
createDebugBody,
headersToObject,
environment
}) {
const { fetch } = environment;
async function fetchAllOpptyProducts(optyNumber, accessToken) {
async function fetchAllOpptyProducts(optyNumber, accessToken, onDebug = () => {}) {
let offset = 0;
let page = 0;
const items = [];
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), {
const url = createOpptyProductsUrl(optyNumber, offset);
const options = {
method: "GET",
credentials: "include",
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`
}
};
const entry = createBackgroundDebugEntry("requestOpptyProducts", url, {
...options, headers: { Accept: "application/json", Authorization: "(present)" }
}, 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)";
});
if (!response.ok) {
throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
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 : [];
items.push(...pageItems);
page += 1;
@@ -1561,6 +1622,10 @@ function createBackgroundApplication(overrides = {}) {
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
});
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_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
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}`;
});
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)};`);
}
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication";

View File

@@ -60,6 +60,10 @@ function createBackgroundApplication(overrides = {}) {
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
});
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_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,

View File

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

View File

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

View File

@@ -8,6 +8,27 @@ function createDebugContentView({
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() {
const modal = document.getElementById(MODAL_ID);
@@ -32,6 +53,13 @@ function createDebugContentView({
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 = "";
const panelHeader = document.createElement("header");
@@ -43,6 +71,8 @@ function createDebugContentView({
const summary = document.createElement("p");
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);
const visibilityButton = document.createElement("button");
@@ -58,8 +88,51 @@ function createDebugContentView({
});
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");
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) {
const empty = document.createElement("div");
@@ -68,15 +141,20 @@ function createDebugContentView({
list.append(empty);
}
debugState.requestLog.forEach((entry) => {
entries.filter(entry => selectedCategory === "all" || getRequestCategory(entry) === selectedCategory).forEach((entry) => {
const item = document.createElement("details");
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");
heading.className = "opportunities-extension-debug-heading";
const name = document.createElement("strong");
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");
httpStatus.className = "opportunities-extension-debug-http-status";
@@ -84,11 +162,15 @@ function createDebugContentView({
const status = document.createElement("span");
status.className = "opportunities-extension-debug-status";
status.textContent = entry.status;
status.textContent = entry.status === "pending" ? "running" : entry.status;
const summaryMeta = document.createElement("span");
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);
@@ -114,7 +196,9 @@ function createDebugContentView({
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) {
@@ -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 failed = 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 () => {
while (nextIndex < optyNumbers.length) {
@@ -31,7 +40,7 @@ function createProductsBackgroundJobs({
const optyNumber = optyNumbers[index];
try {
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs);
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, reportRequest);
completed += 1;
if (result.fromCache) {
@@ -72,6 +81,7 @@ function createProductsBackgroundJobs({
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
await Promise.all(Array.from({ length: workerCount }, runWorker));
await debugDelivery.catch(() => {});
const state = failed > 0 ? "complete-with-errors" : "complete";
await sendProductsProgress(sender, {

View File

@@ -5,7 +5,7 @@ function createProductsCache({
storageLocalGet,
storageLocalSet
}) {
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) {
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
const cache = await getProductsCache();
const cachedEntry = cache[optyNumber];
@@ -14,7 +14,14 @@ function createProductsCache({
}
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) => {
const fetchedAt = Date.now();
cache[optyNumber] = {
@@ -27,15 +34,20 @@ function createProductsCache({
.finally(() => {
productsCacheState.inFlightProducts.delete(optyNumber);
});
productsCacheState.inFlightProducts.set(optyNumber, request);
}
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber);
return {
items: fetchedResult.items,
cachedAt: fetchedResult.fetchedAt,
fromCache: false
};
const flight = productsCacheState.inFlightProducts.get(optyNumber);
// A second tab/job shares the fetch but still receives its current diagnostics.
flight.listeners.add(onDebug);
for (const entry of flight.entries.values()) {
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) {

View File

@@ -3,6 +3,7 @@ function createProductsContentJobs({
PRODUCTS_CACHE_TIMESTAMP_ID,
PRODUCTS_MESSAGE_LISTENER_KEY,
PRODUCTS_PROGRESS_ID,
appendBackgroundRequestLog,
preferencesState,
productsContentState,
renderOpportunitiesTable,
@@ -67,7 +68,14 @@ function createProductsContentJobs({
window[PRODUCTS_MESSAGE_LISTENER_KEY] = true;
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;
}

View File

@@ -3,29 +3,67 @@ function createFusionProducts({
OPPTY_PRODUCTS_MAX_PAGES,
OPPTY_PRODUCTS_PAGE_LIMIT,
ORACLE_DOMAIN,
createBackgroundDebugEntry,
completeBackgroundDebugEntry,
createDebugBody,
headersToObject,
environment
}) {
const { fetch } = environment;
async function fetchAllOpptyProducts(optyNumber, accessToken) {
async function fetchAllOpptyProducts(optyNumber, accessToken, onDebug = () => {}) {
let offset = 0;
let page = 0;
const items = [];
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), {
const url = createOpptyProductsUrl(optyNumber, offset);
const options = {
method: "GET",
credentials: "include",
headers: {
Accept: "application/json",
Authorization: `Bearer ${accessToken}`
}
};
const entry = createBackgroundDebugEntry("requestOpptyProducts", url, {
...options, headers: { Accept: "application/json", Authorization: "(present)" }
}, 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)";
});
if (!response.ok) {
throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
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 : [];
items.push(...pageItems);
page += 1;

View File

@@ -5,6 +5,7 @@ function createMessageTypes() {
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
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 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 };