Adjustment to the request debug view functionality
This commit is contained in:
17
README.md
17
README.md
@@ -30,11 +30,26 @@ The build recreates `dist/chromium` and `dist/firefox` from the same source.
|
|||||||
Do not edit files under `dist` directly. Both targets use Manifest V3, with a
|
Do not edit files under `dist` directly. Both targets use Manifest V3, with a
|
||||||
Chromium service worker and Firefox background scripts. The build composes classic
|
Chromium service worker and Firefox background scripts. The build composes classic
|
||||||
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
|
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
|
||||||
context. It serializes `src/content/styles/redwood.css` at build time. There is no
|
context. It serializes `src/content/styles/redwood.css` followed by the scoped
|
||||||
|
`src/features/debug/styles.css` at build time. There is no
|
||||||
runtime module loader, browser-side eval, ES-module requirement or new dependency.
|
runtime module loader, browser-side eval, ES-module requirement or new dependency.
|
||||||
|
|
||||||
## Tests and Maintenance
|
## Tests and Maintenance
|
||||||
|
|
||||||
|
Product HTTP requests appear in Debug Requests (Ctrl+D) as soon as they start.
|
||||||
|
Each entry identifies the opportunity and page offset, stays `running` while
|
||||||
|
waiting for headers or the response body, and updates with HTTP status, response,
|
||||||
|
duration or error. Cache hits do not create HTTP entries. Product diagnostics
|
||||||
|
mask authorization and sensitive response headers before messaging the source
|
||||||
|
tab/frame; this change adds no timeout, retry or authentication behavior.
|
||||||
|
|
||||||
|
Debug tabs group Authentication, Opportunities, Products, AI and Consumption
|
||||||
|
requests; unknown request types appear under Other. Only categories with entries
|
||||||
|
are shown. All preserves the complete timeline. Every tab sorts by request start
|
||||||
|
time, retaining original request IDs and showing start times in collapsed rows.
|
||||||
|
Counts and running states update without changing the selected category or
|
||||||
|
collapsing expanded details. Use arrow keys, Home and End to navigate the tabs.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
npm run check
|
npm run check
|
||||||
|
|||||||
99
dist/chromium/background.js
vendored
99
dist/chromium/background.js
vendored
@@ -8,6 +8,7 @@ function createMessageTypes() {
|
|||||||
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
||||||
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
||||||
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
|
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
|
||||||
|
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
|
||||||
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
||||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
||||||
@@ -757,6 +758,15 @@ function createProductsBackgroundJobs({
|
|||||||
let completed = 0;
|
let completed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
let cacheHits = 0;
|
let cacheHits = 0;
|
||||||
|
let debugDelivery = Promise.resolve();
|
||||||
|
const reportRequest = entry => {
|
||||||
|
const message = {
|
||||||
|
type: messageTypes.opptyProductsRequestDebug,
|
||||||
|
requestId,
|
||||||
|
entry: { ...entry, debugKey: JSON.stringify([requestId, entry.metadata.optyNumber, entry.metadata.offset]) }
|
||||||
|
};
|
||||||
|
debugDelivery = debugDelivery.catch(() => {}).then(() => sendProductsProgress(sender, message));
|
||||||
|
};
|
||||||
|
|
||||||
const runWorker = async () => {
|
const runWorker = async () => {
|
||||||
while (nextIndex < optyNumbers.length) {
|
while (nextIndex < optyNumbers.length) {
|
||||||
@@ -765,7 +775,7 @@ function createProductsBackgroundJobs({
|
|||||||
const optyNumber = optyNumbers[index];
|
const optyNumber = optyNumbers[index];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs);
|
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, reportRequest);
|
||||||
completed += 1;
|
completed += 1;
|
||||||
|
|
||||||
if (result.fromCache) {
|
if (result.fromCache) {
|
||||||
@@ -806,6 +816,7 @@ function createProductsBackgroundJobs({
|
|||||||
|
|
||||||
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
|
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
|
||||||
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
||||||
|
await debugDelivery.catch(() => {});
|
||||||
|
|
||||||
const state = failed > 0 ? "complete-with-errors" : "complete";
|
const state = failed > 0 ? "complete-with-errors" : "complete";
|
||||||
await sendProductsProgress(sender, {
|
await sendProductsProgress(sender, {
|
||||||
@@ -848,7 +859,7 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
storageLocalSet
|
||||||
}) {
|
}) {
|
||||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
@@ -857,7 +868,14 @@ function createProductsCache({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!productsCacheState.inFlightProducts.has(optyNumber)) {
|
if (!productsCacheState.inFlightProducts.has(optyNumber)) {
|
||||||
const request = fetchAllOpptyProducts(optyNumber, accessToken)
|
const flight = { listeners: new Set(), entries: new Map(), promise: null };
|
||||||
|
productsCacheState.inFlightProducts.set(optyNumber, flight);
|
||||||
|
flight.promise = fetchAllOpptyProducts(optyNumber, accessToken, entry => {
|
||||||
|
flight.entries.set(entry.metadata.offset, entry);
|
||||||
|
for (const listener of flight.listeners) {
|
||||||
|
try { listener(entry); } catch (_) {}
|
||||||
|
}
|
||||||
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
cache[optyNumber] = {
|
||||||
@@ -870,15 +888,20 @@ function createProductsCache({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
productsCacheState.inFlightProducts.delete(optyNumber);
|
||||||
});
|
});
|
||||||
productsCacheState.inFlightProducts.set(optyNumber, request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber);
|
const flight = productsCacheState.inFlightProducts.get(optyNumber);
|
||||||
return {
|
// A second tab/job shares the fetch but still receives its current diagnostics.
|
||||||
items: fetchedResult.items,
|
flight.listeners.add(onDebug);
|
||||||
cachedAt: fetchedResult.fetchedAt,
|
for (const entry of flight.entries.values()) {
|
||||||
fromCache: false
|
try { onDebug(entry); } catch (_) {}
|
||||||
};
|
}
|
||||||
|
try {
|
||||||
|
const fetchedResult = await flight.promise;
|
||||||
|
return { items: fetchedResult.items, cachedAt: fetchedResult.fetchedAt, fromCache: false };
|
||||||
|
} finally {
|
||||||
|
flight.listeners.delete(onDebug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCacheMaxAge(value) {
|
function normalizeCacheMaxAge(value) {
|
||||||
@@ -918,29 +941,67 @@ function createFusionProducts({
|
|||||||
OPPTY_PRODUCTS_MAX_PAGES,
|
OPPTY_PRODUCTS_MAX_PAGES,
|
||||||
OPPTY_PRODUCTS_PAGE_LIMIT,
|
OPPTY_PRODUCTS_PAGE_LIMIT,
|
||||||
ORACLE_DOMAIN,
|
ORACLE_DOMAIN,
|
||||||
|
createBackgroundDebugEntry,
|
||||||
|
completeBackgroundDebugEntry,
|
||||||
|
createDebugBody,
|
||||||
|
headersToObject,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
async function fetchAllOpptyProducts(optyNumber, accessToken) {
|
async function fetchAllOpptyProducts(optyNumber, accessToken, onDebug = () => {}) {
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let page = 0;
|
let page = 0;
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
|
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
|
||||||
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), {
|
const url = createOpptyProductsUrl(optyNumber, offset);
|
||||||
|
const options = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: `Bearer ${accessToken}`
|
Authorization: `Bearer ${accessToken}`
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const entry = createBackgroundDebugEntry("requestOpptyProducts", url, {
|
||||||
|
...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)";
|
||||||
});
|
});
|
||||||
|
entry.metadata.phase = "reading-response";
|
||||||
if (!response.ok) {
|
report();
|
||||||
throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
|
const text = await response.text();
|
||||||
|
responseBody = createDebugBody(text);
|
||||||
|
if (!response.ok) throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
|
||||||
|
responseData = JSON.parse(text);
|
||||||
|
if (!responseData || typeof responseData !== "object") throw new Error("requestOpptyProducts returned an invalid response.");
|
||||||
|
completeBackgroundDebugEntry(entry, response, responseBody, "");
|
||||||
|
} catch (error) {
|
||||||
|
completeBackgroundDebugEntry(entry, response, responseBody, error.message || "Unable to load products.");
|
||||||
|
entry.status = "failed";
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
Object.keys(entry.responseHeaders).forEach(key => {
|
||||||
|
if (/authorization|cookie|token/i.test(key)) entry.responseHeaders[key] = "(present)";
|
||||||
|
});
|
||||||
|
entry.metadata.phase = "complete";
|
||||||
|
report();
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
|
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
|
||||||
items.push(...pageItems);
|
items.push(...pageItems);
|
||||||
page += 1;
|
page += 1;
|
||||||
@@ -1561,6 +1622,10 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
||||||
});
|
});
|
||||||
const fusionProducts = createFusionProducts({
|
const fusionProducts = createFusionProducts({
|
||||||
|
createBackgroundDebugEntry: (...args) => httpBackgroundJson.createBackgroundDebugEntry(...args),
|
||||||
|
completeBackgroundDebugEntry: (...args) => httpBackgroundJson.completeBackgroundDebugEntry(...args),
|
||||||
|
createDebugBody: (...args) => httpBackgroundJson.createDebugBody(...args),
|
||||||
|
headersToObject: (...args) => httpBackgroundJson.headersToObject(...args),
|
||||||
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
|
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
|
||||||
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
|
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
|
||||||
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,
|
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,
|
||||||
|
|||||||
119
dist/chromium/content.js
vendored
119
dist/chromium/content.js
vendored
File diff suppressed because one or more lines are too long
99
dist/firefox/background.js
vendored
99
dist/firefox/background.js
vendored
@@ -8,6 +8,7 @@ function createMessageTypes() {
|
|||||||
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
||||||
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
||||||
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
|
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
|
||||||
|
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
|
||||||
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
||||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
||||||
@@ -757,6 +758,15 @@ function createProductsBackgroundJobs({
|
|||||||
let completed = 0;
|
let completed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
let cacheHits = 0;
|
let cacheHits = 0;
|
||||||
|
let debugDelivery = Promise.resolve();
|
||||||
|
const reportRequest = entry => {
|
||||||
|
const message = {
|
||||||
|
type: messageTypes.opptyProductsRequestDebug,
|
||||||
|
requestId,
|
||||||
|
entry: { ...entry, debugKey: JSON.stringify([requestId, entry.metadata.optyNumber, entry.metadata.offset]) }
|
||||||
|
};
|
||||||
|
debugDelivery = debugDelivery.catch(() => {}).then(() => sendProductsProgress(sender, message));
|
||||||
|
};
|
||||||
|
|
||||||
const runWorker = async () => {
|
const runWorker = async () => {
|
||||||
while (nextIndex < optyNumbers.length) {
|
while (nextIndex < optyNumbers.length) {
|
||||||
@@ -765,7 +775,7 @@ function createProductsBackgroundJobs({
|
|||||||
const optyNumber = optyNumbers[index];
|
const optyNumber = optyNumbers[index];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs);
|
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, reportRequest);
|
||||||
completed += 1;
|
completed += 1;
|
||||||
|
|
||||||
if (result.fromCache) {
|
if (result.fromCache) {
|
||||||
@@ -806,6 +816,7 @@ function createProductsBackgroundJobs({
|
|||||||
|
|
||||||
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
|
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
|
||||||
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
||||||
|
await debugDelivery.catch(() => {});
|
||||||
|
|
||||||
const state = failed > 0 ? "complete-with-errors" : "complete";
|
const state = failed > 0 ? "complete-with-errors" : "complete";
|
||||||
await sendProductsProgress(sender, {
|
await sendProductsProgress(sender, {
|
||||||
@@ -848,7 +859,7 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
storageLocalSet
|
||||||
}) {
|
}) {
|
||||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
@@ -857,7 +868,14 @@ function createProductsCache({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!productsCacheState.inFlightProducts.has(optyNumber)) {
|
if (!productsCacheState.inFlightProducts.has(optyNumber)) {
|
||||||
const request = fetchAllOpptyProducts(optyNumber, accessToken)
|
const flight = { listeners: new Set(), entries: new Map(), promise: null };
|
||||||
|
productsCacheState.inFlightProducts.set(optyNumber, flight);
|
||||||
|
flight.promise = fetchAllOpptyProducts(optyNumber, accessToken, entry => {
|
||||||
|
flight.entries.set(entry.metadata.offset, entry);
|
||||||
|
for (const listener of flight.listeners) {
|
||||||
|
try { listener(entry); } catch (_) {}
|
||||||
|
}
|
||||||
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
cache[optyNumber] = {
|
||||||
@@ -870,15 +888,20 @@ function createProductsCache({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
productsCacheState.inFlightProducts.delete(optyNumber);
|
||||||
});
|
});
|
||||||
productsCacheState.inFlightProducts.set(optyNumber, request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber);
|
const flight = productsCacheState.inFlightProducts.get(optyNumber);
|
||||||
return {
|
// A second tab/job shares the fetch but still receives its current diagnostics.
|
||||||
items: fetchedResult.items,
|
flight.listeners.add(onDebug);
|
||||||
cachedAt: fetchedResult.fetchedAt,
|
for (const entry of flight.entries.values()) {
|
||||||
fromCache: false
|
try { onDebug(entry); } catch (_) {}
|
||||||
};
|
}
|
||||||
|
try {
|
||||||
|
const fetchedResult = await flight.promise;
|
||||||
|
return { items: fetchedResult.items, cachedAt: fetchedResult.fetchedAt, fromCache: false };
|
||||||
|
} finally {
|
||||||
|
flight.listeners.delete(onDebug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCacheMaxAge(value) {
|
function normalizeCacheMaxAge(value) {
|
||||||
@@ -918,29 +941,67 @@ function createFusionProducts({
|
|||||||
OPPTY_PRODUCTS_MAX_PAGES,
|
OPPTY_PRODUCTS_MAX_PAGES,
|
||||||
OPPTY_PRODUCTS_PAGE_LIMIT,
|
OPPTY_PRODUCTS_PAGE_LIMIT,
|
||||||
ORACLE_DOMAIN,
|
ORACLE_DOMAIN,
|
||||||
|
createBackgroundDebugEntry,
|
||||||
|
completeBackgroundDebugEntry,
|
||||||
|
createDebugBody,
|
||||||
|
headersToObject,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
async function fetchAllOpptyProducts(optyNumber, accessToken) {
|
async function fetchAllOpptyProducts(optyNumber, accessToken, onDebug = () => {}) {
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let page = 0;
|
let page = 0;
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
|
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
|
||||||
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), {
|
const url = createOpptyProductsUrl(optyNumber, offset);
|
||||||
|
const options = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: `Bearer ${accessToken}`
|
Authorization: `Bearer ${accessToken}`
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const entry = createBackgroundDebugEntry("requestOpptyProducts", url, {
|
||||||
|
...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)";
|
||||||
});
|
});
|
||||||
|
entry.metadata.phase = "reading-response";
|
||||||
if (!response.ok) {
|
report();
|
||||||
throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
|
const text = await response.text();
|
||||||
|
responseBody = createDebugBody(text);
|
||||||
|
if (!response.ok) throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
|
||||||
|
responseData = JSON.parse(text);
|
||||||
|
if (!responseData || typeof responseData !== "object") throw new Error("requestOpptyProducts returned an invalid response.");
|
||||||
|
completeBackgroundDebugEntry(entry, response, responseBody, "");
|
||||||
|
} catch (error) {
|
||||||
|
completeBackgroundDebugEntry(entry, response, responseBody, error.message || "Unable to load products.");
|
||||||
|
entry.status = "failed";
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
Object.keys(entry.responseHeaders).forEach(key => {
|
||||||
|
if (/authorization|cookie|token/i.test(key)) entry.responseHeaders[key] = "(present)";
|
||||||
|
});
|
||||||
|
entry.metadata.phase = "complete";
|
||||||
|
report();
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
|
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
|
||||||
items.push(...pageItems);
|
items.push(...pageItems);
|
||||||
page += 1;
|
page += 1;
|
||||||
@@ -1561,6 +1622,10 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
||||||
});
|
});
|
||||||
const fusionProducts = createFusionProducts({
|
const fusionProducts = createFusionProducts({
|
||||||
|
createBackgroundDebugEntry: (...args) => httpBackgroundJson.createBackgroundDebugEntry(...args),
|
||||||
|
completeBackgroundDebugEntry: (...args) => httpBackgroundJson.completeBackgroundDebugEntry(...args),
|
||||||
|
createDebugBody: (...args) => httpBackgroundJson.createDebugBody(...args),
|
||||||
|
headersToObject: (...args) => httpBackgroundJson.headersToObject(...args),
|
||||||
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
|
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
|
||||||
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
|
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
|
||||||
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,
|
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,
|
||||||
|
|||||||
119
dist/firefox/content.js
vendored
119
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
@@ -16,7 +16,8 @@ function composeScript(target, { bootstrap = true } = {}) {
|
|||||||
return `// Source: ${file}\n${source}`;
|
return `// Source: ${file}\n${source}`;
|
||||||
});
|
});
|
||||||
if (target === "content") {
|
if (target === "content") {
|
||||||
const css = fs.readFileSync(path.join(root, "src/content/styles/redwood.css"), "utf8");
|
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css"]
|
||||||
|
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
|
||||||
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
|
||||||
}
|
}
|
||||||
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication";
|
const factory = target === "content" ? "createContentApplication" : "createBackgroundApplication";
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ function createBackgroundApplication(overrides = {}) {
|
|||||||
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
storageLocalSet: (...args) => platformBackgroundApi.storageLocalSet(...args)
|
||||||
});
|
});
|
||||||
const fusionProducts = createFusionProducts({
|
const fusionProducts = createFusionProducts({
|
||||||
|
createBackgroundDebugEntry: (...args) => httpBackgroundJson.createBackgroundDebugEntry(...args),
|
||||||
|
completeBackgroundDebugEntry: (...args) => httpBackgroundJson.completeBackgroundDebugEntry(...args),
|
||||||
|
createDebugBody: (...args) => httpBackgroundJson.createDebugBody(...args),
|
||||||
|
headersToObject: (...args) => httpBackgroundJson.headersToObject(...args),
|
||||||
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
|
OPPTY_PRODUCTS_FIELDS: contracts.OPPTY_PRODUCTS_FIELDS,
|
||||||
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
|
OPPTY_PRODUCTS_MAX_PAGES: contracts.OPPTY_PRODUCTS_MAX_PAGES,
|
||||||
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,
|
OPPTY_PRODUCTS_PAGE_LIMIT: contracts.OPPTY_PRODUCTS_PAGE_LIMIT,
|
||||||
|
|||||||
@@ -287,6 +287,7 @@ function createContentApplication(overrides = {}) {
|
|||||||
environment
|
environment
|
||||||
});
|
});
|
||||||
const productsContentJobs = createProductsContentJobs({
|
const productsContentJobs = createProductsContentJobs({
|
||||||
|
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
|
||||||
messageTypes,
|
messageTypes,
|
||||||
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
PRODUCTS_CACHE_TIMESTAMP_ID: contracts.PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||||
PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY,
|
PRODUCTS_MESSAGE_LISTENER_KEY: contracts.PRODUCTS_MESSAGE_LISTENER_KEY,
|
||||||
|
|||||||
@@ -56,8 +56,11 @@ function createDebugContentLog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
debugState.requestLog.push({
|
const existing = entry.debugKey && debugState.requestLog.find(item => item.debugKey === entry.debugKey);
|
||||||
id: debugState.requestLog.length + 1,
|
if (existing && existing.status !== "pending" && entry.status === "pending") return;
|
||||||
|
const normalized = {
|
||||||
|
id: existing ? existing.id : debugState.requestLog.length + 1,
|
||||||
|
debugKey: entry.debugKey,
|
||||||
name: entry.name || "backgroundRequest",
|
name: entry.name || "backgroundRequest",
|
||||||
method: entry.method || "GET",
|
method: entry.method || "GET",
|
||||||
url: entry.url || "",
|
url: entry.url || "",
|
||||||
@@ -75,7 +78,9 @@ function createDebugContentLog({
|
|||||||
responseBody: entry.responseBody || "",
|
responseBody: entry.responseBody || "",
|
||||||
responseBodyTruncated: Boolean(entry.responseBodyTruncated),
|
responseBodyTruncated: Boolean(entry.responseBodyTruncated),
|
||||||
error: entry.error || ""
|
error: entry.error || ""
|
||||||
});
|
};
|
||||||
|
if (existing) Object.assign(existing, normalized);
|
||||||
|
else debugState.requestLog.push(normalized);
|
||||||
});
|
});
|
||||||
renderDebugPanel();
|
renderDebugPanel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,27 @@ function createDebugContentView({
|
|||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { document } = environment;
|
const { document } = environment;
|
||||||
|
const categories = [
|
||||||
|
["all", "All"], ["authentication", "Authentication"],
|
||||||
|
["opportunities", "Opportunities"], ["products", "Products"],
|
||||||
|
["ai", "AI"], ["consumption", "Consumption"], ["other", "Other"]
|
||||||
|
];
|
||||||
|
let selectedCategory = "all";
|
||||||
|
const expandedIds = new Set();
|
||||||
|
|
||||||
|
function getRequestCategory(entry) {
|
||||||
|
if (/^request(?:TokenRelay|ConsumerTokenService|CustomerTokenService)/.test(entry.name)) return "authentication";
|
||||||
|
if (entry.name === "requestOpportunities") return "opportunities";
|
||||||
|
if (entry.name === "requestOpptyProducts") return "products";
|
||||||
|
if (entry.name === "requestGenerateAiSummary") return "ai";
|
||||||
|
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService)/.test(entry.name)) return "consumption";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChronologicalRequests() {
|
||||||
|
return [...debugState.requestLog].sort((a, b) =>
|
||||||
|
(new Date(a.startedAt).getTime() || 0) - (new Date(b.startedAt).getTime() || 0) || a.id - b.id);
|
||||||
|
}
|
||||||
function toggleDebugPanel() {
|
function toggleDebugPanel() {
|
||||||
const modal = document.getElementById(MODAL_ID);
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
|
||||||
@@ -32,6 +53,13 @@ function createDebugContentView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const item of panel.querySelectorAll("details[data-request-id]")) {
|
||||||
|
const id = item.getAttribute("data-request-id");
|
||||||
|
if (item.open) expandedIds.add(id);
|
||||||
|
else expandedIds.delete(id);
|
||||||
|
}
|
||||||
|
const focusedCategory = document.activeElement && document.activeElement.getAttribute("data-debug-category");
|
||||||
|
const scrollTop = panel.scrollTop;
|
||||||
panel.textContent = "";
|
panel.textContent = "";
|
||||||
|
|
||||||
const panelHeader = document.createElement("header");
|
const panelHeader = document.createElement("header");
|
||||||
@@ -43,6 +71,8 @@ function createDebugContentView({
|
|||||||
|
|
||||||
const summary = document.createElement("p");
|
const summary = document.createElement("p");
|
||||||
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
|
summary.textContent = `${debugState.requestLog.length} request${debugState.requestLog.length === 1 ? "" : "s"} since modal opened.`;
|
||||||
|
const running = debugState.requestLog.filter(entry => entry.status === "pending").length;
|
||||||
|
if (running) summary.textContent += ` ${running} running.`;
|
||||||
heading.append(title, summary);
|
heading.append(title, summary);
|
||||||
|
|
||||||
const visibilityButton = document.createElement("button");
|
const visibilityButton = document.createElement("button");
|
||||||
@@ -58,8 +88,51 @@ function createDebugContentView({
|
|||||||
});
|
});
|
||||||
panelHeader.append(heading, visibilityButton);
|
panelHeader.append(heading, visibilityButton);
|
||||||
|
|
||||||
|
const entries = getChronologicalRequests();
|
||||||
|
const availableCategories = categories.filter(([key]) => key === "all" || entries.some(entry => getRequestCategory(entry) === key));
|
||||||
|
if (!availableCategories.some(([key]) => key === selectedCategory)) selectedCategory = "all";
|
||||||
|
const tabs = document.createElement("div");
|
||||||
|
tabs.className = "opportunities-extension-debug-tabs";
|
||||||
|
tabs.setAttribute("role", "tablist");
|
||||||
|
tabs.setAttribute("aria-label", "Request categories");
|
||||||
|
const tabButtons = [];
|
||||||
|
availableCategories.forEach(([key, label], index) => {
|
||||||
|
const categoryEntries = key === "all" ? entries : entries.filter(entry => getRequestCategory(entry) === key);
|
||||||
|
const pending = categoryEntries.filter(entry => entry.status === "pending").length;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.id = `${DEBUG_PANEL_ID}-tab-${key}`;
|
||||||
|
button.setAttribute("role", "tab");
|
||||||
|
button.setAttribute("data-debug-category", key);
|
||||||
|
button.setAttribute("aria-selected", String(key === selectedCategory));
|
||||||
|
button.setAttribute("aria-controls", `${DEBUG_PANEL_ID}-requests`);
|
||||||
|
button.tabIndex = key === selectedCategory ? 0 : -1;
|
||||||
|
button.textContent = `${label} (${categoryEntries.length})${pending ? ` / ${pending} running` : ""}`;
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
selectedCategory = key;
|
||||||
|
renderDebugPanel();
|
||||||
|
});
|
||||||
|
button.addEventListener("keydown", event => {
|
||||||
|
let nextIndex;
|
||||||
|
if (event.key === "ArrowRight") nextIndex = (index + 1) % availableCategories.length;
|
||||||
|
else if (event.key === "ArrowLeft") nextIndex = (index + availableCategories.length - 1) % availableCategories.length;
|
||||||
|
else if (event.key === "Home") nextIndex = 0;
|
||||||
|
else if (event.key === "End") nextIndex = availableCategories.length - 1;
|
||||||
|
else return;
|
||||||
|
event.preventDefault();
|
||||||
|
selectedCategory = availableCategories[nextIndex][0];
|
||||||
|
renderDebugPanel();
|
||||||
|
});
|
||||||
|
tabs.append(button);
|
||||||
|
tabButtons.push(button);
|
||||||
|
});
|
||||||
|
|
||||||
const list = document.createElement("div");
|
const list = document.createElement("div");
|
||||||
list.className = "opportunities-extension-debug-list";
|
list.className = "opportunities-extension-debug-list";
|
||||||
|
list.id = `${DEBUG_PANEL_ID}-requests`;
|
||||||
|
list.setAttribute("role", "tabpanel");
|
||||||
|
list.setAttribute("aria-labelledby", `${DEBUG_PANEL_ID}-tab-${selectedCategory}`);
|
||||||
|
list.tabIndex = 0;
|
||||||
|
|
||||||
if (debugState.requestLog.length === 0) {
|
if (debugState.requestLog.length === 0) {
|
||||||
const empty = document.createElement("div");
|
const empty = document.createElement("div");
|
||||||
@@ -68,15 +141,20 @@ function createDebugContentView({
|
|||||||
list.append(empty);
|
list.append(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugState.requestLog.forEach((entry) => {
|
entries.filter(entry => selectedCategory === "all" || getRequestCategory(entry) === selectedCategory).forEach((entry) => {
|
||||||
const item = document.createElement("details");
|
const item = document.createElement("details");
|
||||||
item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`;
|
item.className = `opportunities-extension-debug-item opportunities-extension-debug-item-${entry.status}`;
|
||||||
|
item.setAttribute("data-request-id", String(entry.id));
|
||||||
|
item.open = expandedIds.has(String(entry.id));
|
||||||
|
|
||||||
const heading = document.createElement("summary");
|
const heading = document.createElement("summary");
|
||||||
heading.className = "opportunities-extension-debug-heading";
|
heading.className = "opportunities-extension-debug-heading";
|
||||||
|
|
||||||
const name = document.createElement("strong");
|
const name = document.createElement("strong");
|
||||||
name.textContent = `${entry.id}. ${entry.name}`;
|
name.textContent = `${entry.id}. ${entry.name}`;
|
||||||
|
if (entry.name === "requestOpptyProducts" && entry.metadata.optyNumber) {
|
||||||
|
name.textContent += ` (${entry.metadata.optyNumber}, offset ${entry.metadata.offset})`;
|
||||||
|
}
|
||||||
|
|
||||||
const httpStatus = document.createElement("span");
|
const httpStatus = document.createElement("span");
|
||||||
httpStatus.className = "opportunities-extension-debug-http-status";
|
httpStatus.className = "opportunities-extension-debug-http-status";
|
||||||
@@ -84,11 +162,15 @@ function createDebugContentView({
|
|||||||
|
|
||||||
const status = document.createElement("span");
|
const status = document.createElement("span");
|
||||||
status.className = "opportunities-extension-debug-status";
|
status.className = "opportunities-extension-debug-status";
|
||||||
status.textContent = entry.status;
|
status.textContent = entry.status === "pending" ? "running" : entry.status;
|
||||||
|
|
||||||
const summaryMeta = document.createElement("span");
|
const summaryMeta = document.createElement("span");
|
||||||
summaryMeta.className = "opportunities-extension-debug-summary-meta";
|
summaryMeta.className = "opportunities-extension-debug-summary-meta";
|
||||||
summaryMeta.append(httpStatus, status);
|
const startedTime = document.createElement("time");
|
||||||
|
startedTime.className = "opportunities-extension-debug-time";
|
||||||
|
startedTime.textContent = formatDebugTime(entry.startedAt);
|
||||||
|
startedTime.title = `Started: ${entry.startedAt.toLocaleString()}`;
|
||||||
|
summaryMeta.append(startedTime, httpStatus, status);
|
||||||
|
|
||||||
heading.append(name, summaryMeta);
|
heading.append(name, summaryMeta);
|
||||||
|
|
||||||
@@ -114,7 +196,9 @@ function createDebugContentView({
|
|||||||
list.append(item);
|
list.append(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
panel.append(panelHeader, list);
|
panel.append(panelHeader, tabs, list);
|
||||||
|
if (focusedCategory) tabButtons.find(button => button.getAttribute("data-debug-category") === selectedCategory).focus({ preventScroll: true });
|
||||||
|
panel.scrollTop = scrollTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDebugVisibilityIcon(secretsVisible) {
|
function createDebugVisibilityIcon(secretsVisible) {
|
||||||
@@ -170,5 +254,5 @@ function createDebugContentView({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime };
|
return { toggleDebugPanel, renderDebugPanel, createDebugVisibilityIcon, appendDebugBlock, formatDebugTime, getRequestCategory, getChronologicalRequests };
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/features/debug/styles.css
Normal file
82
src/features/debug/styles.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -23,6 +23,15 @@ function createProductsBackgroundJobs({
|
|||||||
let completed = 0;
|
let completed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
let cacheHits = 0;
|
let cacheHits = 0;
|
||||||
|
let debugDelivery = Promise.resolve();
|
||||||
|
const reportRequest = entry => {
|
||||||
|
const message = {
|
||||||
|
type: messageTypes.opptyProductsRequestDebug,
|
||||||
|
requestId,
|
||||||
|
entry: { ...entry, debugKey: JSON.stringify([requestId, entry.metadata.optyNumber, entry.metadata.offset]) }
|
||||||
|
};
|
||||||
|
debugDelivery = debugDelivery.catch(() => {}).then(() => sendProductsProgress(sender, message));
|
||||||
|
};
|
||||||
|
|
||||||
const runWorker = async () => {
|
const runWorker = async () => {
|
||||||
while (nextIndex < optyNumbers.length) {
|
while (nextIndex < optyNumbers.length) {
|
||||||
@@ -31,7 +40,7 @@ function createProductsBackgroundJobs({
|
|||||||
const optyNumber = optyNumbers[index];
|
const optyNumber = optyNumbers[index];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs);
|
const result = await getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, reportRequest);
|
||||||
completed += 1;
|
completed += 1;
|
||||||
|
|
||||||
if (result.fromCache) {
|
if (result.fromCache) {
|
||||||
@@ -72,6 +81,7 @@ function createProductsBackgroundJobs({
|
|||||||
|
|
||||||
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
|
const workerCount = Math.min(OPPTY_PRODUCTS_CONCURRENCY, Math.max(optyNumbers.length, 1));
|
||||||
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
await Promise.all(Array.from({ length: workerCount }, runWorker));
|
||||||
|
await debugDelivery.catch(() => {});
|
||||||
|
|
||||||
const state = failed > 0 ? "complete-with-errors" : "complete";
|
const state = failed > 0 ? "complete-with-errors" : "complete";
|
||||||
await sendProductsProgress(sender, {
|
await sendProductsProgress(sender, {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ function createProductsCache({
|
|||||||
storageLocalGet,
|
storageLocalGet,
|
||||||
storageLocalSet
|
storageLocalSet
|
||||||
}) {
|
}) {
|
||||||
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs) {
|
async function getOpptyProducts(optyNumber, accessToken, cacheMaxAgeMs, onDebug = () => {}) {
|
||||||
const cache = await getProductsCache();
|
const cache = await getProductsCache();
|
||||||
const cachedEntry = cache[optyNumber];
|
const cachedEntry = cache[optyNumber];
|
||||||
|
|
||||||
@@ -14,7 +14,14 @@ function createProductsCache({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!productsCacheState.inFlightProducts.has(optyNumber)) {
|
if (!productsCacheState.inFlightProducts.has(optyNumber)) {
|
||||||
const request = fetchAllOpptyProducts(optyNumber, accessToken)
|
const flight = { listeners: new Set(), entries: new Map(), promise: null };
|
||||||
|
productsCacheState.inFlightProducts.set(optyNumber, flight);
|
||||||
|
flight.promise = fetchAllOpptyProducts(optyNumber, accessToken, entry => {
|
||||||
|
flight.entries.set(entry.metadata.offset, entry);
|
||||||
|
for (const listener of flight.listeners) {
|
||||||
|
try { listener(entry); } catch (_) {}
|
||||||
|
}
|
||||||
|
})
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const fetchedAt = Date.now();
|
const fetchedAt = Date.now();
|
||||||
cache[optyNumber] = {
|
cache[optyNumber] = {
|
||||||
@@ -27,15 +34,20 @@ function createProductsCache({
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
productsCacheState.inFlightProducts.delete(optyNumber);
|
productsCacheState.inFlightProducts.delete(optyNumber);
|
||||||
});
|
});
|
||||||
productsCacheState.inFlightProducts.set(optyNumber, request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchedResult = await productsCacheState.inFlightProducts.get(optyNumber);
|
const flight = productsCacheState.inFlightProducts.get(optyNumber);
|
||||||
return {
|
// A second tab/job shares the fetch but still receives its current diagnostics.
|
||||||
items: fetchedResult.items,
|
flight.listeners.add(onDebug);
|
||||||
cachedAt: fetchedResult.fetchedAt,
|
for (const entry of flight.entries.values()) {
|
||||||
fromCache: false
|
try { onDebug(entry); } catch (_) {}
|
||||||
};
|
}
|
||||||
|
try {
|
||||||
|
const fetchedResult = await flight.promise;
|
||||||
|
return { items: fetchedResult.items, cachedAt: fetchedResult.fetchedAt, fromCache: false };
|
||||||
|
} finally {
|
||||||
|
flight.listeners.delete(onDebug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCacheMaxAge(value) {
|
function normalizeCacheMaxAge(value) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ function createProductsContentJobs({
|
|||||||
PRODUCTS_CACHE_TIMESTAMP_ID,
|
PRODUCTS_CACHE_TIMESTAMP_ID,
|
||||||
PRODUCTS_MESSAGE_LISTENER_KEY,
|
PRODUCTS_MESSAGE_LISTENER_KEY,
|
||||||
PRODUCTS_PROGRESS_ID,
|
PRODUCTS_PROGRESS_ID,
|
||||||
|
appendBackgroundRequestLog,
|
||||||
preferencesState,
|
preferencesState,
|
||||||
productsContentState,
|
productsContentState,
|
||||||
renderOpportunitiesTable,
|
renderOpportunitiesTable,
|
||||||
@@ -67,7 +68,14 @@ function createProductsContentJobs({
|
|||||||
|
|
||||||
window[PRODUCTS_MESSAGE_LISTENER_KEY] = true;
|
window[PRODUCTS_MESSAGE_LISTENER_KEY] = true;
|
||||||
runtimeApi.runtime.onMessage.addListener((message) => {
|
runtimeApi.runtime.onMessage.addListener((message) => {
|
||||||
if (!message || message.type !== messageTypes.opptyProductsProgress || message.requestId !== productsContentState.currentProductsRequestId) {
|
if (!message || message.requestId !== productsContentState.currentProductsRequestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message.type === messageTypes.opptyProductsRequestDebug) {
|
||||||
|
if (message.entry && typeof message.entry.debugKey === "string") appendBackgroundRequestLog([message.entry]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (message.type !== messageTypes.opptyProductsProgress) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,29 +3,67 @@ function createFusionProducts({
|
|||||||
OPPTY_PRODUCTS_MAX_PAGES,
|
OPPTY_PRODUCTS_MAX_PAGES,
|
||||||
OPPTY_PRODUCTS_PAGE_LIMIT,
|
OPPTY_PRODUCTS_PAGE_LIMIT,
|
||||||
ORACLE_DOMAIN,
|
ORACLE_DOMAIN,
|
||||||
|
createBackgroundDebugEntry,
|
||||||
|
completeBackgroundDebugEntry,
|
||||||
|
createDebugBody,
|
||||||
|
headersToObject,
|
||||||
environment
|
environment
|
||||||
}) {
|
}) {
|
||||||
const { fetch } = environment;
|
const { fetch } = environment;
|
||||||
async function fetchAllOpptyProducts(optyNumber, accessToken) {
|
async function fetchAllOpptyProducts(optyNumber, accessToken, onDebug = () => {}) {
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let page = 0;
|
let page = 0;
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
|
while (page < OPPTY_PRODUCTS_MAX_PAGES) {
|
||||||
const response = await fetch(createOpptyProductsUrl(optyNumber, offset), {
|
const url = createOpptyProductsUrl(optyNumber, offset);
|
||||||
|
const options = {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
Authorization: `Bearer ${accessToken}`
|
Authorization: `Bearer ${accessToken}`
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const entry = createBackgroundDebugEntry("requestOpptyProducts", url, {
|
||||||
|
...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)";
|
||||||
});
|
});
|
||||||
|
entry.metadata.phase = "reading-response";
|
||||||
if (!response.ok) {
|
report();
|
||||||
throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
|
const text = await response.text();
|
||||||
|
responseBody = createDebugBody(text);
|
||||||
|
if (!response.ok) throw new Error(`requestOpptyProducts failed with status ${response.status}.`);
|
||||||
|
responseData = JSON.parse(text);
|
||||||
|
if (!responseData || typeof responseData !== "object") throw new Error("requestOpptyProducts returned an invalid response.");
|
||||||
|
completeBackgroundDebugEntry(entry, response, responseBody, "");
|
||||||
|
} catch (error) {
|
||||||
|
completeBackgroundDebugEntry(entry, response, responseBody, error.message || "Unable to load products.");
|
||||||
|
entry.status = "failed";
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
Object.keys(entry.responseHeaders).forEach(key => {
|
||||||
|
if (/authorization|cookie|token/i.test(key)) entry.responseHeaders[key] = "(present)";
|
||||||
|
});
|
||||||
|
entry.metadata.phase = "complete";
|
||||||
|
report();
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
|
const pageItems = Array.isArray(responseData.items) ? responseData.items : [];
|
||||||
items.push(...pageItems);
|
items.push(...pageItems);
|
||||||
page += 1;
|
page += 1;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ function createMessageTypes() {
|
|||||||
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
refreshXsrfCookie: "opportunitiesExtension.refreshXsrfCookie",
|
||||||
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
requestOpptyProducts: "opportunitiesExtension.requestOpptyProducts",
|
||||||
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
|
opptyProductsProgress: "opportunitiesExtension.opptyProductsProgress",
|
||||||
|
opptyProductsRequestDebug: "opportunitiesExtension.opptyProductsRequestDebug",
|
||||||
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
|
||||||
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
|
||||||
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
|
||||||
|
|||||||
100
tests/debugTabs.test.cjs
Normal file
100
tests/debugTabs.test.cjs
Normal 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"));
|
||||||
|
});
|
||||||
140
tests/productsDebug.test.cjs
Normal file
140
tests/productsDebug.test.cjs
Normal 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"));
|
||||||
|
});
|
||||||
@@ -61,6 +61,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
|
|||||||
const time = clock();
|
const time = clock();
|
||||||
const listeners = [];
|
const listeners = [];
|
||||||
const progress = [];
|
const progress = [];
|
||||||
|
const requestDebug = [];
|
||||||
const tabs = [];
|
const tabs = [];
|
||||||
const removedTabs = [];
|
const removedTabs = [];
|
||||||
const cookieQueries = [];
|
const cookieQueries = [];
|
||||||
@@ -69,7 +70,10 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
|
|||||||
const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) });
|
const getStored = key => ({ [key]: storage[key] === undefined ? undefined : plain(storage[key]) });
|
||||||
const store = value => Object.assign(storage, plain(value));
|
const store = value => Object.assign(storage, plain(value));
|
||||||
const openTab = details => { tabs.push(plain(details)); return { id: 91 }; };
|
const openTab = details => { tabs.push(plain(details)); return { id: 91 }; };
|
||||||
const send = (tabId, message, options) => progress.push({ tabId, message: plain(message), options: plain(options || {}) });
|
const send = (tabId, message, options) => {
|
||||||
|
const events = message.type === "opportunitiesExtension.opptyProductsRequestDebug" ? requestDebug : progress;
|
||||||
|
events.push({ tabId, message: plain(message), options: plain(options || {}) });
|
||||||
|
};
|
||||||
if (family === "firefox") {
|
if (family === "firefox") {
|
||||||
api.runtime.getBrowserInfo = async () => ({ name: "Firefox" });
|
api.runtime.getBrowserInfo = async () => ({ name: "Firefox" });
|
||||||
api.storage = { local: {
|
api.storage = { local: {
|
||||||
@@ -128,7 +132,7 @@ function loadBackground({ family = "chrome", fetch = unexpectedFetch, storage =
|
|||||||
if (pending !== true) { clearTimeout(timer); reject(new Error("Expected asynchronous response contract")); }
|
if (pending !== true) { clearTimeout(timer); reject(new Error("Expected asynchronous response contract")); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { dispatch, listener: listeners[0], progress, tabs, removedTabs, cookieQueries, storage, advance: time.advance };
|
return { dispatch, listener: listeners[0], progress, requestDebug, tabs, removedTabs, cookieQueries, storage, advance: time.advance };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { root, plain, response, loadContent, loadBackground };
|
module.exports = { root, plain, response, loadContent, loadBackground };
|
||||||
|
|||||||
25
tests/support/debug-dom.cjs
Normal file
25
tests/support/debug-dom.cjs
Normal 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 };
|
||||||
Reference in New Issue
Block a user