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" ? "Synthetic login" : "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")); });