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 mask authorization and sensitive response headers before messaging the source
tab/frame; this change adds no timeout, retry or authentication behavior. 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 Debug tabs group Authentication, Opportunities, Products, AI, Consumption, Installed Base and Service Requests
requests; unknown request types appear under Other. Only categories with entries requests; unknown request types appear under Other. Only categories with entries
are shown. All preserves the complete timeline. Every tab sorts by request start 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 // Source: src/background/contracts.js
function createBackgroundContracts() { function createBackgroundContracts() {
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com"; const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
@@ -742,6 +784,7 @@ function createSpaResponseShapes() {
// Source: src/services/http/background-json.js // Source: src/services/http/background-json.js
function createHttpBackgroundJson({ function createHttpBackgroundJson({
previewDebugBody,
DEBUG_BODY_LIMIT, DEBUG_BODY_LIMIT,
environment environment
}) { }) {
@@ -863,17 +906,7 @@ function createHttpBackgroundJson({
} }
function createDebugBody(text) { function createDebugBody(text) {
if (text.length > DEBUG_BODY_LIMIT) { return previewDebugBody(text, 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
};
} }
function normalizeDebugHeaders(headers) { function normalizeDebugHeaders(headers) {
@@ -1831,6 +1864,7 @@ function createBackgroundApplication(overrides = {}) {
}); });
const spaResponseShapes = createSpaResponseShapes(); const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({ const httpBackgroundJson = createHttpBackgroundJson({
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT, DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment 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 // Source: src/background/contracts.js
function createBackgroundContracts() { function createBackgroundContracts() {
const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com"; const ORACLE_DOMAIN = "eeho.fa.us2.oraclecloud.com";
@@ -742,6 +784,7 @@ function createSpaResponseShapes() {
// Source: src/services/http/background-json.js // Source: src/services/http/background-json.js
function createHttpBackgroundJson({ function createHttpBackgroundJson({
previewDebugBody,
DEBUG_BODY_LIMIT, DEBUG_BODY_LIMIT,
environment environment
}) { }) {
@@ -863,17 +906,7 @@ function createHttpBackgroundJson({
} }
function createDebugBody(text) { function createDebugBody(text) {
if (text.length > DEBUG_BODY_LIMIT) { return previewDebugBody(text, 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
};
} }
function normalizeDebugHeaders(headers) { function normalizeDebugHeaders(headers) {
@@ -1831,6 +1864,7 @@ function createBackgroundApplication(overrides = {}) {
}); });
const spaResponseShapes = createSpaResponseShapes(); const spaResponseShapes = createSpaResponseShapes();
const httpBackgroundJson = createHttpBackgroundJson({ const httpBackgroundJson = createHttpBackgroundJson({
previewDebugBody: createDebugBodyPreview,
DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT, DEBUG_BODY_LIMIT: contracts.DEBUG_BODY_LIMIT,
environment environment
}); });

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
function createDebugContentLog({ function createDebugContentLog({
previewDebugBody,
DEBUG_BODY_LIMIT, DEBUG_BODY_LIMIT,
debugState, debugState,
renderDebugPanel renderDebugPanel
@@ -98,17 +99,7 @@ function createDebugContentLog({
} }
function createDebugBody(text) { function createDebugBody(text) {
if (text.length > DEBUG_BODY_LIMIT) { return previewDebugBody(text, 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
};
} }
function headersToObject(headers) { function headersToObject(headers) {
@@ -137,13 +128,16 @@ function createDebugContentLog({
function formatDebugBody(body) { function formatDebugBody(body) {
const text = body || "(none)"; const text = body || "(none)";
if (debugState.debugSecretsVisible) {
return text;
}
try { 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) { } catch (error) {
return text; return text;
} }

View File

@@ -5,6 +5,7 @@ function createDebugContentView({
formatDebugBody, formatDebugBody,
formatHeaderBlock, formatHeaderBlock,
formatMetadataBlock, formatMetadataBlock,
createCloseIcon,
environment environment
}) { }) {
const { document } = environment; const { document } = environment;
@@ -16,6 +17,7 @@ function createDebugContentView({
]; ];
let selectedCategory = "all"; let selectedCategory = "all";
const expandedIds = new Set(); const expandedIds = new Set();
let returnFocus = null;
function getRequestCategory(entry) { function getRequestCategory(entry) {
if (/^request(?:TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication"; if (/^request(?:TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
@@ -45,8 +47,22 @@ function createDebugContentView({
return; return;
} }
panel.hidden = !panel.hidden; if (!panel.hidden) {
closeDebugPanel();
return;
}
returnFocus = document.activeElement;
panel.hidden = false;
renderDebugPanel(); 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() { function renderDebugPanel() {
@@ -62,14 +78,21 @@ function createDebugContentView({
else expandedIds.delete(id); else expandedIds.delete(id);
} }
const focusedCategory = document.activeElement && document.activeElement.getAttribute("data-debug-category"); 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; const scrollTop = panel.scrollTop;
panel.textContent = ""; 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"); const panelHeader = document.createElement("header");
panelHeader.className = "opportunities-extension-debug-panel-header"; panelHeader.className = "opportunities-extension-debug-panel-header";
const heading = document.createElement("div"); const heading = document.createElement("div");
const title = document.createElement("h2"); const title = document.createElement("h2");
title.id = `${DEBUG_PANEL_ID}-title`;
title.textContent = "Debug Requests"; title.textContent = "Debug Requests";
const summary = document.createElement("p"); const summary = document.createElement("p");
@@ -81,6 +104,8 @@ function createDebugContentView({
const visibilityButton = document.createElement("button"); const visibilityButton = document.createElement("button");
visibilityButton.type = "button"; visibilityButton.type = "button";
visibilityButton.className = "opportunities-extension-icon-button opportunities-extension-debug-visibility-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-pressed", String(debugState.debugSecretsVisible));
visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values"); visibilityButton.setAttribute("aria-label", debugState.debugSecretsVisible ? "Hide sensitive values" : "Show sensitive values");
visibilityButton.title = 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; debugState.debugSecretsVisible = !debugState.debugSecretsVisible;
renderDebugPanel(); 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 entries = getChronologicalRequests();
const availableCategories = categories.filter(([key]) => key === "all" || entries.some(entry => getRequestCategory(entry) === key)); 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("role", "tabpanel");
list.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-tab-${selectedCategory}`); list.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-tab-${selectedCategory}`);
list.tabIndex = 0; list.tabIndex = 0;
list.setAttribute("data-debug-focus", "list");
focusTargets.set("list", list);
const summaries = [];
if (debugState.requestLog.length === 0) { if (debugState.requestLog.length === 0) {
const empty = document.createElement("div"); const empty = document.createElement("div");
@@ -152,6 +192,9 @@ function createDebugContentView({
const heading = document.createElement("summary"); const heading = document.createElement("summary");
heading.className = "opportunities-extension-debug-heading"; 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"); const name = document.createElement("strong");
name.textContent = `${entry.id}. ${entry.name}`; name.textContent = `${entry.id}. ${entry.name}`;
@@ -162,6 +205,7 @@ function createDebugContentView({
const httpStatus = document.createElement("span"); const httpStatus = document.createElement("span");
httpStatus.className = "opportunities-extension-debug-http-status"; httpStatus.className = "opportunities-extension-debug-http-status";
httpStatus.textContent = entry.httpStatus ? String(entry.httpStatus) : "pending"; 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"); const status = document.createElement("span");
status.className = "opportunities-extension-debug-status"; status.className = "opportunities-extension-debug-status";
@@ -173,7 +217,11 @@ function createDebugContentView({
startedTime.className = "opportunities-extension-debug-time"; startedTime.className = "opportunities-extension-debug-time";
startedTime.textContent = formatDebugTime(entry.startedAt); startedTime.textContent = formatDebugTime(entry.startedAt);
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`; 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); heading.append(name, summaryMeta);
@@ -200,7 +248,23 @@ function createDebugContentView({
}); });
panel.append(panelHeader, tabs, list); 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 }); 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; panel.scrollTop = scrollTop;
} }
@@ -240,15 +304,41 @@ function createDebugContentView({
function appendDebugBlock(container, label, value) { function appendDebugBlock(container, label, value) {
const block = document.createElement("section"); 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 heading = document.createElement("h3");
const content = document.createElement("pre"); const content = document.createElement("pre");
heading.textContent = label; heading.textContent = label;
content.textContent = value; appendJsonTokens(content, value);
block.append(heading, content); block.append(heading, content);
container.append(block); 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) { function formatDebugTime(date) {
return date.toLocaleTimeString(undefined, { return date.toLocaleTimeString(undefined, {
hour: "2-digit", 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 { .opportunities-extension-debug-tabs {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -62,6 +188,14 @@
justify-content: flex-end; 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 { .opportunities-extension-modal[data-theme="dark"] .opportunities-extension-debug-tabs {
border-bottom-color: #514c47; border-bottom-color: #514c47;
} }

View File

@@ -1,4 +1,5 @@
function createHttpBackgroundJson({ function createHttpBackgroundJson({
previewDebugBody,
DEBUG_BODY_LIMIT, DEBUG_BODY_LIMIT,
environment environment
}) { }) {
@@ -120,17 +121,7 @@ function createHttpBackgroundJson({
} }
function createDebugBody(text) { function createDebugBody(text) {
if (text.length > DEBUG_BODY_LIMIT) { return previewDebugBody(text, 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
};
} }
function normalizeDebugHeaders(headers) { 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", () => { 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 }; 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", () => { test("request categories cover every current family and unknown requests stay visible", () => {
const h = fixture(); const h = fixture();
for (const [name, category] of Object.entries({ 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", () => { test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
const root = path.join(__dirname, ".."); 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"] 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; }, addEventListener(name, listener) { this.listeners[name] = listener; },
focus() { document.activeElement = this; }, focus() { document.activeElement = this; },
querySelectorAll(selector) { querySelectorAll(selector) {
return descendants(this).filter(node => node.tag === "details" return descendants(this).filter(node => selector.startsWith(".")
&& node.getAttribute("data-request-id") && (!selector.includes("[open]") || node.open)); ? (node.className || "").split(" ").includes(selector.slice(1))
: node.tag === "details" && node.getAttribute("data-request-id") && (!selector.includes("[open]") || node.open));
} }
}; };
} }
const panel = element("aside"); const panel = element("aside");
Object.assign(document, { getElementById: () => panel, createElement: element, createElementNS: (_, tag) => element(tag) }); const modal = element("main");
return { document, panel, descendants }; 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 }; module.exports = { createDebugDom };