Visual improvements to debug mode for better information visibility

This commit is contained in:
2026-09-23 18:19:35 -03:00
parent 6c5db94aaa
commit 3ef0d2aafd
16 changed files with 862 additions and 107 deletions

View File

@@ -126,6 +126,20 @@ 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 Requests opens as a full-page view inside the extension using Ctrl+D, without
opening a browser tab or entering native browser fullscreen. Close, Escape or Ctrl+D
returns to the underlying view. Header/category navigation remains above the
scrollable timeline; expanded details use two columns on wide screens and one on
narrow screens. Theme-specific colors distinguish success, failure, running,
HTTP methods/statuses, request/response sections and JSON tokens. Highlighting
uses safe text nodes only after the existing sensitive-value masking. Compact JSON,
supported protection prefixes and JSON documents wrapped in strings are normalized
and indented, including while sensitive values are visible. Large JSON responses
are captured as bounded, valid JSON previews with an explicit omission notice,
preserving indentation, highlighting and sensitive-field masking. Already incomplete
or invalid JSON remains plain text. The visibility toggle retains its existing
behavior. Focus, expansion and timeline scroll position survive log updates.
Debug tabs group Authentication, Opportunities, Products, AI, Consumption, Installed Base and Service Requests
requests; unknown request types appear under Other. Only categories with entries
are shown. All preserves the complete timeline. Every tab sorts by request start

View File

@@ -19,6 +19,48 @@ function createMessageTypes() {
});
}
// Source: src/shared/debug-body.js
function createDebugBodyPreview(text, limit) {
if (text.length <= limit) return { value: text || "(empty response body)", truncated: false };
try {
const normalized = text.replace(/^\uFEFF/, "").trimStart()
.replace(/^\)\]\}',?\s*/, "").replace(/^while\s*\(1\);\s*/, "");
let value = JSON.parse(normalized);
for (let depth = 0; depth < 2 && typeof value === "string" && /^\s*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; }
}
// Bound retained data without cutting JSON syntax or losing sensitive field names.
let remaining = Math.max(0, limit - 256);
const visit = (node, depth = 0) => {
if (remaining <= 64 || depth >= 30) return "[omitted from debug preview]";
if (node === null || typeof node !== "object") {
const serialized = JSON.stringify(node);
if (serialized.length > remaining) {
remaining = 0;
return "[omitted from debug preview]";
}
remaining -= serialized.length + 1;
return node;
}
const result = Array.isArray(node) ? [] : Object.create(null);
remaining -= 2;
for (const key of Object.keys(node)) {
const cost = Array.isArray(node) ? 1 : JSON.stringify(key).length + 2;
if (remaining < cost + 64) break;
remaining -= cost;
result[key] = visit(node[key], depth + 1);
}
return result;
};
return {
value: JSON.stringify({ debugNotice: "Partial response preview: remaining fields/items omitted due to the debug size limit.", preview: visit(value) }),
truncated: true
};
} catch {
return { value: `${text.slice(0, limit)}\n... truncated ${text.length - limit} characters`, truncated: true };
}
}
// Source: src/background/contracts.js
function createBackgroundContracts() {
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
@@ -742,6 +784,7 @@ function createSpaResponseShapes() {
// Source: src/services/http/background-json.js
function createHttpBackgroundJson({
previewDebugBody,
DEBUG_BODY_LIMIT,
environment
}) {
@@ -863,17 +906,7 @@ function createHttpBackgroundJson({
}
function createDebugBody(text) {
if (text.length > DEBUG_BODY_LIMIT) {
return {
value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`,
truncated: true
};
}
return {
value: text || "(empty response body)",
truncated: false
};
return previewDebugBody(text, DEBUG_BODY_LIMIT);
}
function normalizeDebugHeaders(headers) {
@@ -1831,6 +1864,7 @@ function createBackgroundApplication(overrides = {}) {
});
const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment
});

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,48 @@ function createMessageTypes() {
});
}
// Source: src/shared/debug-body.js
function createDebugBodyPreview(text, limit) {
if (text.length <= limit) return { value: text || "(empty response body)", truncated: false };
try {
const normalized = text.replace(/^\uFEFF/, "").trimStart()
.replace(/^\)\]\}',?\s*/, "").replace(/^while\s*\(1\);\s*/, "");
let value = JSON.parse(normalized);
for (let depth = 0; depth < 2 && typeof value === "string" && /^\s*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; }
}
// Bound retained data without cutting JSON syntax or losing sensitive field names.
let remaining = Math.max(0, limit - 256);
const visit = (node, depth = 0) => {
if (remaining <= 64 || depth >= 30) return "[omitted from debug preview]";
if (node === null || typeof node !== "object") {
const serialized = JSON.stringify(node);
if (serialized.length > remaining) {
remaining = 0;
return "[omitted from debug preview]";
}
remaining -= serialized.length + 1;
return node;
}
const result = Array.isArray(node) ? [] : Object.create(null);
remaining -= 2;
for (const key of Object.keys(node)) {
const cost = Array.isArray(node) ? 1 : JSON.stringify(key).length + 2;
if (remaining < cost + 64) break;
remaining -= cost;
result[key] = visit(node[key], depth + 1);
}
return result;
};
return {
value: JSON.stringify({ debugNotice: "Partial response preview: remaining fields/items omitted due to the debug size limit.", preview: visit(value) }),
truncated: true
};
} catch {
return { value: `${text.slice(0, limit)}\n... truncated ${text.length - limit} characters`, truncated: true };
}
}
// Source: src/background/contracts.js
function createBackgroundContracts() {
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
@@ -742,6 +784,7 @@ function createSpaResponseShapes() {
// Source: src/services/http/background-json.js
function createHttpBackgroundJson({
previewDebugBody,
DEBUG_BODY_LIMIT,
environment
}) {
@@ -863,17 +906,7 @@ function createHttpBackgroundJson({
}
function createDebugBody(text) {
if (text.length > DEBUG_BODY_LIMIT) {
return {
value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`,
truncated: true
};
}
return {
value: text || "(empty response body)",
truncated: false
};
return previewDebugBody(text, DEBUG_BODY_LIMIT);
}
function normalizeDebugHeaders(headers) {
@@ -1831,6 +1864,7 @@ function createBackgroundApplication(overrides = {}) {
});
const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment
});

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
module.exports = {
"content": [
"src/shared/messages.js",
"src/shared/debug-body.js",
"src/content/contracts.js",
"src/auth/session-state.js",
"src/auth/sales-common-state.js",
@@ -62,6 +63,7 @@ module.exports = {
],
"background": [
"src/shared/messages.js",
"src/shared/debug-body.js",
"src/background/contracts.js",
"src/features/products/cache-state.js",
"src/auth/consumer-session.js",

View File

@@ -54,6 +54,7 @@ function createBackgroundApplication(overrides = {}) {
});
const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment
});

View File

@@ -447,11 +447,13 @@ function createContentApplication(overrides = {}) {
});
const debugContentLog = createDebugContentLog({
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel: (...args) => debugContentView.renderDebugPanel(...args)
});
const debugContentView = createDebugContentView({
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
DEBUG_PANEL_ID: contracts.DEBUG_PANEL_ID,
MODAL_ID: contracts.MODAL_ID,
debugState,

View File

@@ -1,4 +1,5 @@
function createDebugContentLog({
previewDebugBody,
DEBUG_BODY_LIMIT,
debugState,
renderDebugPanel
@@ -98,17 +99,7 @@ function createDebugContentLog({
}
function createDebugBody(text) {
if (text.length > DEBUG_BODY_LIMIT) {
return {
value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`,
truncated: true
};
}
return {
value: text || "(empty response body)",
truncated: false
};
return previewDebugBody(text, DEBUG_BODY_LIMIT);
}
function headersToObject(headers) {
@@ -137,13 +128,16 @@ function createDebugContentLog({
function formatDebugBody(body) {
const text = body || "(none)";
if (debugState.debugSecretsVisible) {
return text;
}
try {
return JSON.stringify(maskSensitiveDebugValue(JSON.parse(text)), null, 2);
const normalized = String(text).replace(/^\uFEFF/, "").trimStart()
.replace(/^\)\]\}',?\s*/, "")
.replace(/^while\s*\(1\);\s*/, "");
let value = JSON.parse(normalized);
// Some services serialize their JSON document inside a JSON string.
for (let depth = 0; depth < 2 && typeof value === "string" && /^[\s]*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; }
}
return JSON.stringify(debugState.debugSecretsVisible ? value : maskSensitiveDebugValue(value), null, 2);
} catch (error) {
return text;
}

View File

@@ -5,6 +5,7 @@ function createDebugContentView({
formatDebugBody,
formatHeaderBlock,
formatMetadataBlock,
createCloseIcon,
environment
}) {
const { document } = environment;
@@ -16,6 +17,7 @@ function createDebugContentView({
];
let selectedCategory = "all";
const expandedIds = new Set();
let returnFocus = null;
function getRequestCategory(entry) {
if (/^request(?:TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
@@ -45,8 +47,22 @@ function createDebugContentView({
return;
}
panel.hidden = !panel.hidden;
if (!panel.hidden) {
closeDebugPanel();
return;
}
returnFocus = document.activeElement;
panel.hidden = false;
renderDebugPanel();
panel.querySelectorAll(".opportunities-extension-debug-close-button")[0]?.focus({ preventScroll: true });
}
function closeDebugPanel() {
const panel = document.getElementById(DEBUG_PANEL_ID);
if (!panel) return;
panel.hidden = true;
if (returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
returnFocus = null;
}
function renderDebugPanel() {
@@ -62,14 +78,21 @@ function createDebugContentView({
else expandedIds.delete(id);
}
const focusedCategory = document.activeElement && document.activeElement.getAttribute("data-debug-category");
const focusedControl = document.activeElement && document.activeElement.getAttribute("data-debug-focus");
const listScrollTop = panel.querySelectorAll(".opportunities-extension-debug-list")[0]?.scrollTop || 0;
const scrollTop = panel.scrollTop;
panel.textContent = "";
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-title`);
const focusTargets = new Map();
const panelHeader = document.createElement("header");
panelHeader.className = "opportunities-extension-debug-panel-header";
const heading = document.createElement("div");
const title = document.createElement("h2");
title.id = `${DEBUG_PANEL_ID}-title`;
title.textContent = "Debug Requests";
const summary = document.createElement("p");
@@ -81,6 +104,8 @@ function createDebugContentView({
const visibilityButton = document.createElement("button");
visibilityButton.type = "button";
visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-button";
visibilityButton.setAttribute("data-debug-focus", "visibility");
focusTargets.set("visibility", visibilityButton);
visibilityButton.setAttribute("aria-pressed", String(debugState.debugSecretsVisible));
visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values");
visibilityButton.title = debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values";
@@ -89,7 +114,19 @@ function createDebugContentView({
debugState.debugSecretsVisible = !debugState.debugSecretsVisible;
renderDebugPanel();
});
panelHeader.append(heading, visibilityButton);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "opportunities-extension-icon-button opportunities-extension-debug-close-button";
closeButton.title = "Close debug";
closeButton.setAttribute("aria-label", "Close debug");
closeButton.setAttribute("data-debug-focus", "close");
closeButton.append(createCloseIcon());
closeButton.addEventListener("click", closeDebugPanel);
focusTargets.set("close", closeButton);
const actions = document.createElement("div");
actions.className = "opportunities-extension-debug-actions";
actions.append(visibilityButton, closeButton);
panelHeader.append(heading, actions);
const entries = getChronologicalRequests();
const availableCategories = categories.filter(([key]) => key === "all" || entries.some(entry => getRequestCategory(entry) === key));
@@ -136,6 +173,9 @@ function createDebugContentView({
list.setAttribute("role", "tabpanel");
list.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-tab-${selectedCategory}`);
list.tabIndex = 0;
list.setAttribute("data-debug-focus", "list");
focusTargets.set("list", list);
const summaries = [];
if (debugState.requestLog.length === 0) {
const empty = document.createElement("div");
@@ -152,6 +192,9 @@ function createDebugContentView({
const heading = document.createElement("summary");
heading.className = "opportunities-extension-debug-heading";
heading.setAttribute("data-debug-focus", `request-${entry.id}`);
focusTargets.set(`request-${entry.id}`, heading);
summaries.push(heading);
const name = document.createElement("strong");
name.textContent = `${entry.id}. ${entry.name}`;
@@ -162,6 +205,7 @@ function createDebugContentView({
const httpStatus = document.createElement("span");
httpStatus.className = "opportunities-extension-debug-http-status";
httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending";
httpStatus.setAttribute("data-http-class", entry.httpStatus ? String(Math.floor(entry.httpStatus / 100)) : "pending");
const status = document.createElement("span");
status.className = "opportunities-extension-debug-status";
@@ -173,7 +217,11 @@ function createDebugContentView({
startedTime.className = "opportunities-extension-debug-time";
startedTime.textContent = formatDebugTime(entry.startedAt);
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`;
summaryMeta.append(startedTime, httpStatus, status);
const method = document.createElement("span");
method.className = "opportunities-extension-debug-method";
method.textContent = entry.method || "GET";
method.setAttribute("data-method", method.textContent.toUpperCase());
summaryMeta.append(startedTime, method, httpStatus, status);
heading.append(name, summaryMeta);
@@ -200,7 +248,23 @@ function createDebugContentView({
});
panel.append(panelHeader, tabs, list);
panel.onkeydown = event => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closeDebugPanel();
} else if (event.key === "Tab") {
const controls = [visibilityButton, closeButton, ...tabButtons.filter(button => button.tabIndex === 0), list, ...summaries];
const index = controls.indexOf(document.activeElement);
if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === controls.length - 1)) {
event.preventDefault();
controls[event.shiftKey ? controls.length - 1 : 0].focus({ preventScroll: true });
}
}
};
if (focusedCategory) tabButtons.find(button => button.getAttribute("data-debug-category") === selectedCategory).focus({ preventScroll: true });
else if (focusedControl) focusTargets.get(focusedControl)?.focus({ preventScroll: true });
list.scrollTop = listScrollTop;
panel.scrollTop = scrollTop;
}
@@ -240,15 +304,41 @@ function createDebugContentView({
function appendDebugBlock(container, label, value) {
const block = document.createElement("section");
block.className = "opportunities-extension-debug-block";
block.setAttribute("data-direction", label.startsWith("Response") ? "response" : "request");
const heading = document.createElement("h3");
const content = document.createElement("pre");
heading.textContent = label;
content.textContent = value;
appendJsonTokens(content, value);
block.append(heading, content);
container.append(block);
}
function appendJsonTokens(content, value) {
const text = String(value ?? "");
try { JSON.parse(text); } catch { content.textContent = text; return; }
// Highlight only already-masked display text; never interpret response content as HTML.
const tokens = [...text.matchAll(/"(?:[^"\\]|\\.)*"\s*:|"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b/g)];
let offset = 0;
const append = (textValue, kind) => {
if (!textValue) return;
const span = document.createElement("span");
span.textContent = textValue;
if (kind) span.className = `opportunities-extension-debug-json-${kind}`;
content.append(span);
};
tokens.forEach(token => {
append(text.slice(offset, token.index));
const value = token[0];
const kind = value.startsWith('"') ? value.endsWith(":") ? "key" : "string"
: /^(true|false|null)$/.test(value) ? "literal" : "number";
append(value, kind);
offset = token.index + value.length;
});
append(text.slice(offset));
}
function formatDebugTime(date) {
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
@@ -257,5 +347,5 @@ function createDebugContentView({
});
}
return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime, getRequestCategory, getChronologicalRequests };
return { toggleDebugPanel, closeDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime, getRequestCategory, getChronologicalRequests };
}

View File

@@ -1,4 +1,130 @@
/* Debug navigation only; preserve the extension's existing light/dark theme. */
/* Full-page diagnostics, scoped to the extension and its current theme. */
.opportunities-extension-modal .opportunities-extension-debug-panel {
--debug-surface: #fff;
--debug-code: #f5f6f7;
--debug-text: #312d2a;
--debug-muted: #5f5a55;
--debug-border: #dedbd7;
--debug-success: #22613b;
--debug-success-bg: #e1f1e5;
--debug-failed: #a12721;
--debug-failed-bg: #fce8e6;
--debug-pending: #805000;
--debug-pending-bg: #fff1ce;
--debug-info: #075e87;
--debug-info-bg: #e5f1fa;
--debug-key: #075e87;
--debug-string: #22613b;
--debug-number: #865000;
--debug-literal: #73429b;
position: fixed;
inset: 0;
z-index: 50;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: auto;
height: auto;
max-height: none;
min-width: 0;
box-sizing: border-box;
overflow: hidden;
margin: 0;
padding: 20px;
border: 0;
border-radius: 0;
background: var(--debug-surface);
color: var(--debug-text);
color-scheme: light;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-panel {
--debug-surface: #292827;
--debug-code: #222120;
--debug-text: #f6f4f2;
--debug-muted: #c9c5c1;
--debug-border: #514c47;
--debug-success: #a9e3bd;
--debug-success-bg: #214d34;
--debug-failed: #ffb4ab;
--debug-failed-bg: #582c2a;
--debug-pending: #ffe099;
--debug-pending-bg: #59441e;
--debug-info: #a8d9ff;
--debug-info-bg: #233f52;
--debug-key: #8ed6f0;
--debug-string: #a9e3bd;
--debug-number: #ffe099;
--debug-literal: #d6b7f1;
color: #f6f4f2;
color-scheme: dark;
}
.opportunities-extension-debug-actions { display: flex; gap: 8px; flex: 0 0 auto; }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-list {
overflow: auto;
min-height: 0;
min-width: 0;
align-content: start;
scrollbar-gutter: stable;
padding: 2px 8px 12px 2px;
}
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item {
--debug-state: var(--debug-info);
--debug-state-bg: var(--debug-info-bg);
border-color: var(--debug-border);
border-left-color: var(--debug-state);
background: var(--debug-surface);
min-width: 0;
}
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item-success { --debug-state: var(--debug-success); --debug-state-bg: var(--debug-success-bg); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item-failed { --debug-state: var(--debug-failed); --debug-state-bg: var(--debug-failed-bg); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item-pending { --debug-state: var(--debug-pending); --debug-state-bg: var(--debug-pending-bg); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-status {
color: var(--debug-state);
background: var(--debug-state-bg);
}
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status,
.opportunities-extension-debug-method {
color: var(--debug-info);
background: var(--debug-info-bg);
}
.opportunities-extension-debug-method {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 4px;
font: 700 11px/1.2 Consolas, monospace;
}
.opportunities-extension-debug-method[data-method="POST"] { color: var(--debug-literal); background: var(--debug-code); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="2"] { color: var(--debug-success); background: var(--debug-success-bg); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="4"],
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="5"] { color: var(--debug-failed); background: var(--debug-failed-bg); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-http-status[data-http-class="pending"] { color: var(--debug-pending); background: var(--debug-pending-bg); }
.opportunities-extension-debug-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; padding: 0 14px 16px; }
.opportunities-extension-debug-block { min-width: 0; border-left: 2px solid var(--debug-info); padding-left: 10px; }
.opportunities-extension-debug-block[data-direction="response"] { border-left-color: var(--debug-success); }
.opportunities-extension-modal .opportunities-extension-debug-block h3 { margin: 0 0 8px; color: var(--debug-info); font-size: 12px; }
.opportunities-extension-modal .opportunities-extension-debug-block[data-direction="response"] h3 { color: var(--debug-success); }
.opportunities-extension-modal .opportunities-extension-debug-panel .opportunities-extension-debug-item pre {
min-width: 0;
margin: 0;
padding: 12px;
max-height: 380px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--debug-text);
background: var(--debug-code);
line-height: 1.6;
tab-size: 2;
}
.opportunities-extension-debug-json-key { color: var(--debug-key); }
.opportunities-extension-debug-json-string { color: var(--debug-string); }
.opportunities-extension-debug-json-number { color: var(--debug-number); }
.opportunities-extension-debug-json-literal { color: var(--debug-literal); }
@media (max-width: 760px) {
.opportunities-extension-modal .opportunities-extension-debug-panel { padding: 12px; }
.opportunities-extension-debug-details { grid-template-columns: minmax(0, 1fr); }
}
.opportunities-extension-debug-tabs {
display: flex;
flex-wrap: wrap;
@@ -62,6 +188,14 @@
justify-content: flex-end;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs button {
color: #f6f4f2;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-heading::before {
color: #c9c5c1;
}
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs {
border-bottom-color: #514c47;
}

View File

@@ -1,4 +1,5 @@
function createHttpBackgroundJson({
previewDebugBody,
DEBUG_BODY_LIMIT,
environment
}) {
@@ -120,17 +121,7 @@ function createHttpBackgroundJson({
}
function createDebugBody(text) {
if (text.length > DEBUG_BODY_LIMIT) {
return {
value: `${text.slice(0, DEBUG_BODY_LIMIT)}\n... truncated ${text.length - DEBUG_BODY_LIMIT} characters`,
truncated: true
};
}
return {
value: text || "(empty response body)",
truncated: false
};
return previewDebugBody(text, DEBUG_BODY_LIMIT);
}
function normalizeDebugHeaders(headers) {

40
src/shared/debug-body.js Normal file
View File

@@ -0,0 +1,40 @@
function createDebugBodyPreview(text, limit) {
if (text.length <= limit) return { value: text || "(empty response body)", truncated: false };
try {
const normalized = text.replace(/^\uFEFF/, "").trimStart()
.replace(/^\)\]\}',?\s*/, "").replace(/^while\s*\(1\);\s*/, "");
let value = JSON.parse(normalized);
for (let depth = 0; depth < 2 && typeof value === "string" && /^\s*[\[{]/.test(value); depth += 1) {
try { value = JSON.parse(value); } catch { break; }
}
// Bound retained data without cutting JSON syntax or losing sensitive field names.
let remaining = Math.max(0, limit - 256);
const visit = (node, depth = 0) => {
if (remaining <= 64 || depth >= 30) return "[omitted from debug preview]";
if (node === null || typeof node !== "object") {
const serialized = JSON.stringify(node);
if (serialized.length > remaining) {
remaining = 0;
return "[omitted from debug preview]";
}
remaining -= serialized.length + 1;
return node;
}
const result = Array.isArray(node) ? [] : Object.create(null);
remaining -= 2;
for (const key of Object.keys(node)) {
const cost = Array.isArray(node) ? 1 : JSON.stringify(key).length + 2;
if (remaining < cost + 64) break;
remaining -= cost;
result[key] = visit(node[key], depth + 1);
}
return result;
};
return {
value: JSON.stringify({ debugNotice: "Partial response preview: remaining fields/items omitted due to the debug size limit.", preview: visit(value) }),
truncated: true
};
} catch {
return { value: `${text.slice(0, limit)}\n... truncated ${text.length - limit} characters`, truncated: true };
}
}

View File

@@ -31,7 +31,7 @@ test("every source belongs to an explicit runtime composition, with no duplicate
}
}
}
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/messages.js"]);
assert.deepEqual(sources.content.filter(file => sources.background.includes(file)), ["src/shared/messages.js", "src/shared/debug-body.js"]);
});
test("Redwood cascade is byte-identical to the characterized original", () => {

View File

@@ -20,6 +20,35 @@ function fixture() {
return { ...dom, app, add, nodes, tab, ids, select };
}
test("large response previews stay valid, formatted, masked and colored in both runtimes", () => {
const h = fixture();
const background = loadApplication("background");
const body = JSON.stringify({ items: Array.from({ length: 2000 }, (_, index) => ({
id: index, name: "Synthetic opportunity", access_token: "synthetic-secret",
uiaasHeader: { value: "synthetic-header-secret" }, owner: { name: "Synthetic owner" }
})) });
for (const [logger, limit] of [[h.app.debugContentLog, 12000], [background.httpBackgroundJson, 100000]]) {
const preview = logger.createDebugBody(body);
assert.equal(preview.truncated, true);
assert.ok(preview.value.length <= limit);
const parsed = JSON.parse(preview.value);
assert.match(parsed.debugNotice, /Partial response/);
assert.ok(parsed.preview.items.length > 0);
assert.ok(parsed.preview.items.length < 2000);
const formatted = h.app.debugContentLog.formatDebugBody(preview.value);
assert.match(formatted, /\n "debugNotice"/);
assert.doesNotMatch(formatted, /synthetic-secret|synthetic-header-secret/);
const container = h.document.createElement("div");
h.app.debugContentView.appendDebugBlock(container, "Response Body", formatted);
assert.ok(h.descendants(container).some(node => node.className === "opportunities-extension-debug-json-key"));
h.app.debugState.debugSecretsVisible = true;
assert.match(h.app.debugContentLog.formatDebugBody(preview.value), /synthetic-secret/);
h.app.debugState.debugSecretsVisible = false;
assert.equal(logger.createDebugBody('{"ok":true}').value, '{"ok":true}');
assert.match(logger.createDebugBody("<html>" + "x".repeat(limit + 1000)).value, /truncated/);
}
});
test("request categories cover every current family and unknown requests stay visible", () => {
const h = fixture();
for (const [name, category] of Object.entries({
@@ -94,6 +123,137 @@ test("tabs support arrows, Home and End with a single selected tab and labelled
}
});
test("dark debug provides explicit readable text for inactive tabs and disclosure arrows", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/debug/styles.css"), "utf8");
const rules = [...css.matchAll(/([^{}]+)\{([^{}]+)\}/g)];
const prefix = '.opportunities-extension-modal[data-theme="dark"] ';
const rule = suffix => rules.find(([, selector]) => selector.trim() === prefix + suffix)?.[2];
assert.match(rule(".opportunities-extension-debug-panel"), /color: #f6f4f2/);
assert.match(rule(".opportunities-extension-debug-panel"), /color-scheme: dark/);
assert.match(rule(".opportunities-extension-debug-tabs button"), /color: #f6f4f2/);
assert.match(rule(".opportunities-extension-debug-heading::before"), /color: #c9c5c1/);
assert.match(rule('.opportunities-extension-debug-tabs button[aria-selected="true"]'), /color: #bde7ee/);
assert.match(rule(".opportunities-extension-debug-tabs button:hover"), /background: #383633/);
});
test("full-page debug opens with close focus, closes via Escape/button/shortcut and retains request state", () => {
const h = fixture();
const trigger = h.document.createElement("button");
trigger.isConnected = true;
trigger.focus();
h.panel.hidden = true;
h.app.debugContentView.toggleDebugPanel();
assert.equal(h.panel.hidden, false);
assert.equal(h.panel.getAttribute("role"), "dialog");
assert.equal(h.document.activeElement.getAttribute("aria-label"), "Close debug");
h.add("p", "requestOpptyProducts", 1, "pending");
h.nodes().find(node => node.tag === "details").open = true;
const list = h.nodes().find(node => node.getAttribute("role") === "tabpanel");
list.scrollTop = 320;
h.add("p", "requestOpptyProducts", 1, "success");
assert.equal(h.nodes().find(node => node.getAttribute("role") === "tabpanel").scrollTop, 320);
assert.equal(h.document.activeElement.getAttribute("aria-label"), "Close debug");
let stopped = false;
h.panel.onkeydown({ key: "Escape", preventDefault() {}, stopPropagation() { stopped = true; } });
assert.equal(stopped, true);
assert.equal(h.panel.hidden, true);
assert.equal(h.modal.hidden, false, "Escape never closes the main modal");
assert.equal(h.document.activeElement, trigger);
h.app.debugContentView.toggleDebugPanel();
assert.equal(h.nodes().find(node => node.tag === "details").open, true);
const eye = h.nodes().find(node => node.getAttribute("data-debug-focus") === "visibility");
const last = h.nodes().filter(node => node.tag === "summary").at(-1);
eye.focus();
h.panel.onkeydown({ key: "Tab", shiftKey: true, preventDefault() {} });
assert.equal(h.document.activeElement, last);
h.panel.onkeydown({ key: "Tab", preventDefault() {} });
assert.equal(h.document.activeElement, eye);
h.nodes().find(node => node.getAttribute("data-debug-focus") === "close").listeners.click();
assert.equal(h.panel.hidden, true);
h.app.debugContentView.toggleDebugPanel();
h.app.debugContentView.toggleDebugPanel();
assert.equal(h.panel.hidden, true);
});
test("colored JSON preserves exact masked text, safe strings, eye toggle and HTTP families", () => {
const h = fixture();
const response = JSON.stringify({ access_token: "synthetic-private", name: '<img src=x onerror="bad()">', count: 12.5, enabled: false, missing: null });
h.app.debugContentLog.appendBackgroundRequestLog([{
name: "requestTokenRelay", method: "POST", startedAt: new Date().toISOString(),
httpStatus: 401, status: "failed", responseBody: response, requestHeaders: { Authorization: "synthetic-private" }
}]);
const text = node => node.text + node.children.map(text).join("");
const responseBlock = () => h.nodes().find(node => node.tag === "section" && node.children[0]?.textContent === "Response Body").children[1];
assert.equal(text(responseBlock()), h.app.debugContentLog.formatDebugBody(response));
assert.doesNotMatch(text(h.panel), /synthetic-private/);
for (const kind of ["key", "string", "number", "literal"]) {
assert.ok(h.nodes().some(node => node.className === `opportunities-extension-debug-json-${kind}`));
}
assert.equal(h.nodes().filter(node => node.tag === "img").length, 0);
assert.equal(h.nodes().find(node => node.className === "opportunities-extension-debug-http-status").getAttribute("data-http-class"), "4");
assert.equal(h.nodes().find(node => node.className === "opportunities-extension-debug-method").textContent, "POST");
const eye = h.nodes().find(node => node.getAttribute("data-debug-focus") === "visibility");
eye.focus(); eye.listeners.click();
assert.equal(text(responseBlock()), JSON.stringify(JSON.parse(response), null, 2));
assert.equal(h.document.activeElement.getAttribute("data-debug-focus"), "visibility");
h.document.activeElement.listeners.click();
assert.doesNotMatch(text(h.panel), /synthetic-private/);
const container = h.document.createElement("div");
h.app.debugContentView.appendDebugBlock(container, "Response Body", "<html>Not JSON</html>");
assert.equal(container.children[0].children[1].textContent, "<html>Not JSON</html>");
assert.equal(container.children[0].children[1].children.length, 0);
});
test("compact, protected and string-encoded JSON are formatted and colored with secrets both hidden and visible", () => {
const h = fixture();
const value = { items: [{ id: 1, name: "Synthetic", active: true }], access_token: "synthetic-private" };
const compact = JSON.stringify(value);
const text = node => node.text + node.children.map(text).join("");
for (const body of [compact, `\uFEFF${compact}`, `)]}',\n${compact}`, `while(1);${compact}`, JSON.stringify(compact)]) {
for (const visible of [false, true]) {
h.app.debugState.debugSecretsVisible = visible;
const formatted = h.app.debugContentLog.formatDebugBody(body);
assert.equal(formatted, JSON.stringify(visible ? value : h.app.debugContentLog.maskSensitiveDebugValue(value), null, 2));
const container = h.document.createElement("div");
h.app.debugContentView.appendDebugBlock(container, "Response Body", formatted);
assert.equal(text(container.children[0].children[1]), formatted);
assert.ok(h.descendants(container).some(node => node.className === "opportunities-extension-debug-json-key"));
if (!visible) assert.doesNotMatch(text(container), /synthetic-private/);
}
}
for (const text of ["<html>Login</html>", '{"items":[\n... truncated 100 characters', '"ordinary string"']) {
assert.equal(h.app.debugContentLog.formatDebugBody(text), text);
}
const dense = JSON.stringify(Array.from({ length: 3100 }, () => 1));
const container = h.document.createElement("div");
const formatted = h.app.debugContentLog.formatDebugBody(dense);
h.app.debugContentView.appendDebugBlock(container, "Response Body", formatted);
assert.equal(text(container.children[0].children[1]), formatted);
assert.equal(h.descendants(container).filter(node => node.className === "opportunities-extension-debug-json-number").length, 3100);
});
test("full-page debug CSS defines responsive bounds and readable semantic colors in both themes", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/debug/styles.css"), "utf8");
const rule = css.match(/\.opportunities-extension-modal \.opportunities-extension-debug-panel \{([^}]+)\}/)[1];
assert.match(rule, /inset: 0/);
assert.match(rule, /max-height: none/);
assert.match(rule, /grid-template-rows: auto auto minmax\(0, 1fr\)/);
assert.match(css, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/);
assert.match(css, /grid-template-columns: minmax\(0, 1fr\)/);
const dark = css.match(/\.opportunities-extension-modal\[data-theme="dark"\] \.opportunities-extension-debug-panel \{([^}]+)\}/)[1];
const luminance = hex => hex.slice(1).match(/../g).map(value => parseInt(value, 16) / 255)
.map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4)
.reduce((sum, value, index) => sum + value * [.2126, .7152, .0722][index], 0);
for (const theme of [rule, dark]) {
const vars = Object.fromEntries([...theme.matchAll(/--debug-([\w-]+): (#[a-f0-9]{6})/g)].map(match => [match[1], match[2]]));
for (const [foreground, background] of [["success", "success-bg"], ["failed", "failed-bg"], ["pending", "pending-bg"], ["info", "info-bg"],
...["text", "key", "string", "number", "literal"].map(key => [key, "code"])]) {
const values = [luminance(vars[foreground]), luminance(vars[background])].sort((a, b) => b - a);
assert.ok((values[0] + .05) / (values[1] + .05) >= 4.5, `${foreground} on ${background}`);
}
}
});
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
const root = path.join(__dirname, "..");
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css", "src/features/consumption/plan-detail.css", "src/features/ratecard/styles.css", "src/features/opportunities/customer-table-search.css"]

View File

@@ -12,14 +12,17 @@ function createDebugDom() {
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));
return descendants(this).filter(node => selector.startsWith(".")
? (node.className || "").split(" ").includes(selector.slice(1))
: 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 };
const modal = element("main");
modal.append(panel);
Object.assign(document, { getElementById: id => id === "opportunities-extension-modal" ? modal : panel, createElement: element, createElementNS: (_, tag) => element(tag) });
return { document, panel, modal, descendants };
}
module.exports = { createDebugDom };