713 lines
35 KiB
TypeScript
713 lines
35 KiB
TypeScript
// SPEC: PJ2026-0104010803 Workbench realtime visibility.
|
|
// Responsibility: Typed, isolated Workbench Kafka debug replay validation inside the persistent Web observer.
|
|
|
|
export function nodeWebObserveRunnerKafkaDebugReplaySource(): string {
|
|
return String.raw`
|
|
function parseWorkbenchKafkaDebugReplayConfig(raw) {
|
|
if (!raw) return null;
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch (error) {
|
|
throw new Error("UNIDESK_WEB_OBSERVE_WORKBENCH_KAFKA_DEBUG_REPLAY_JSON is invalid JSON: " + (error instanceof Error ? error.message : String(error)));
|
|
}
|
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
throw new Error("UNIDESK_WEB_OBSERVE_WORKBENCH_KAFKA_DEBUG_REPLAY_JSON must be an object");
|
|
}
|
|
const topic = String(parsed.topic || "").trim();
|
|
const groupPrefix = String(parsed.groupPrefix || "").trim();
|
|
const productEventsPath = String(parsed.productEventsPath || "").trim();
|
|
const debugEventsPath = String(parsed.debugEventsPath || "").trim();
|
|
const completionTimeoutMs = Number(parsed.completionTimeoutMs);
|
|
const requestObservationQuietMs = Number(parsed.requestObservationQuietMs);
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(topic)) throw new Error("Workbench Kafka debug replay topic is missing or invalid in the owning YAML");
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(groupPrefix)) throw new Error("Workbench Kafka debug replay groupPrefix is missing or invalid in the owning YAML");
|
|
if (!productEventsPath.startsWith("/") || productEventsPath.includes("?") || productEventsPath.includes("#")) {
|
|
throw new Error("Workbench Kafka debug replay productEventsPath must be an absolute path without query or fragment in the owning YAML");
|
|
}
|
|
if (!debugEventsPath.startsWith("/") || debugEventsPath.includes("?") || debugEventsPath.includes("#")) {
|
|
throw new Error("Workbench Kafka debug replay debugEventsPath must be an absolute path without query or fragment in the owning YAML");
|
|
}
|
|
if (!Number.isInteger(completionTimeoutMs) || completionTimeoutMs < 1000 || completionTimeoutMs > 120000) {
|
|
throw new Error("Workbench Kafka debug replay completionTimeoutMs must be 1000-120000 in the owning YAML");
|
|
}
|
|
if (!Number.isInteger(requestObservationQuietMs) || requestObservationQuietMs < 100 || requestObservationQuietMs > 30000) {
|
|
throw new Error("Workbench Kafka debug replay requestObservationQuietMs must be 100-30000 in the owning YAML");
|
|
}
|
|
return {
|
|
enabled: parsed.enabled === true,
|
|
topic,
|
|
groupPrefix,
|
|
productEventsPath,
|
|
debugEventsPath,
|
|
completionTimeoutMs,
|
|
requestObservationQuietMs,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function workbenchKafkaDebugReplayCounts(value) {
|
|
const match = String(value || "").match(/(\d+)\s*\/\s*(\d+)\s+applied/iu);
|
|
return match ? { appliedCount: Number(match[1]), receivedCount: Number(match[2]) } : null;
|
|
}
|
|
|
|
function workbenchKafkaDebugReplayNamedCount(value, name) {
|
|
const match = String(value || "").match(new RegExp("(?:^|\\s|·)" + name + "\\s*=\\s*(-|\\d+)", "iu"));
|
|
if (!match) return undefined;
|
|
return match[1] === "-" ? null : Number(match[1]);
|
|
}
|
|
|
|
function workbenchKafkaDebugReplayLayerCounts(value) {
|
|
const text = String(value || "").replace(/\s+/gu, " ").trim();
|
|
if (!/\bserver\b/iu.test(text) || !/\bclient\b/iu.test(text)) return null;
|
|
return {
|
|
server: {
|
|
scanned: workbenchKafkaDebugReplayNamedCount(text, "scanned"),
|
|
matched: workbenchKafkaDebugReplayNamedCount(text, "matched"),
|
|
delivered: workbenchKafkaDebugReplayNamedCount(text, "delivered")
|
|
},
|
|
client: {
|
|
received: workbenchKafkaDebugReplayNamedCount(text, "received"),
|
|
decoded: workbenchKafkaDebugReplayNamedCount(text, "decoded"),
|
|
applied: workbenchKafkaDebugReplayNamedCount(text, "applied")
|
|
},
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function workbenchKafkaDebugReplayRawCounts(value) {
|
|
const text = String(value || "").replace(/\s+/gu, " ").trim();
|
|
if (!/\breceived\s*=/iu.test(text) || !/\bretained\s*=/iu.test(text)) return null;
|
|
return {
|
|
received: workbenchKafkaDebugReplayNamedCount(text, "received"),
|
|
retained: workbenchKafkaDebugReplayNamedCount(text, "retained"),
|
|
decoded: workbenchKafkaDebugReplayNamedCount(text, "decoded"),
|
|
rejected: workbenchKafkaDebugReplayNamedCount(text, "rejected"),
|
|
evicted: workbenchKafkaDebugReplayNamedCount(text, "evicted"),
|
|
bytes: workbenchKafkaDebugReplayNamedCount(text, "bytes"),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function workbenchKafkaDebugReplayTypedOutcome(input) {
|
|
const status = String(input?.status || "unknown").trim() || "unknown";
|
|
const errorText = String(input?.errorText || "").trim();
|
|
const source = input?.layerCounts ? "typed-dom-v2" : "legacy-dom";
|
|
const codeMatch = errorText.match(/\b(consumer_not_ready|source_trace_missing|producer_not_invoked|topic_no_append_for_replay|record_parse_rejected|records_scanned_but_filter_mismatch|sse_write_failed|transport_failed|decoder_rejected|reducer_rejected|terminal_missing)\b/u);
|
|
const phaseByCode = {
|
|
consumer_not_ready: "consumer",
|
|
source_trace_missing: "producer",
|
|
producer_not_invoked: "producer",
|
|
topic_no_append_for_replay: "kafka",
|
|
record_parse_rejected: "server",
|
|
records_scanned_but_filter_mismatch: "server",
|
|
sse_write_failed: "server",
|
|
transport_failed: "client",
|
|
decoder_rejected: "client",
|
|
reducer_rejected: "client",
|
|
terminal_missing: "terminal"
|
|
};
|
|
if (codeMatch) return { phase: phaseByCode[codeMatch[1]], code: codeMatch[1], source, valuesRedacted: true };
|
|
if (status === "completed") return { phase: "terminal", code: "terminal_complete", source, valuesRedacted: true };
|
|
if (status === "incomplete" && /terminal|终态|未观测/iu.test(errorText)) {
|
|
return { phase: "terminal", code: "terminal_missing", source, valuesRedacted: true };
|
|
}
|
|
if (status === "incomplete") return { phase: "ui", code: "replay_incomplete", source, valuesRedacted: true };
|
|
if (status === "error") return { phase: "ui", code: "replay_error", source, valuesRedacted: true };
|
|
if (status === "closed") return { phase: "client", code: "stream_closed", source, valuesRedacted: true };
|
|
return { phase: "ui", code: "replay_" + status.replace(/[^a-z0-9_-]+/giu, "_"), source, valuesRedacted: true };
|
|
}
|
|
|
|
async function validateWorkbenchKafkaDebugReplay(command) {
|
|
const config = workbenchKafkaDebugReplay;
|
|
if (!config || config.enabled !== true) throw new Error("validateWorkbenchKafkaDebugReplay is not enabled by the owning YAML");
|
|
const reportDir = path.join(stateDir, "artifacts", "workbench-kafka-debug-replay", safeId(command.id));
|
|
const report = {
|
|
ok: false,
|
|
contract: "web-probe-workbench-kafka-debug-replay-v2",
|
|
commandId: command.id,
|
|
expected: {
|
|
topic: config.topic,
|
|
groupPrefix: config.groupPrefix,
|
|
productEventsPath: config.productEventsPath,
|
|
debugEventsPath: config.debugEventsPath,
|
|
completionTimeoutMs: config.completionTimeoutMs,
|
|
requestObservationQuietMs: config.requestObservationQuietMs,
|
|
valuesRedacted: true
|
|
},
|
|
origin,
|
|
startedAt: new Date().toISOString(),
|
|
valuesRedacted: true
|
|
};
|
|
const requests = [];
|
|
const productEventSourceRequests = [];
|
|
let requestPage = null;
|
|
let requestPhase = "setup";
|
|
const requestListener = (request) => {
|
|
let url;
|
|
try { url = new URL(request.url()); } catch { return; }
|
|
if (url.pathname === config.productEventsPath) {
|
|
productEventSourceRequests.push({ method: request.method(), path: url.pathname, phase: requestPhase, valuesRedacted: true });
|
|
return;
|
|
}
|
|
if (url.pathname !== config.debugEventsPath) return;
|
|
requests.push({
|
|
method: request.method(),
|
|
path: url.pathname,
|
|
stream: url.searchParams.get("stream"),
|
|
traceId: url.searchParams.get("traceId"),
|
|
fromBeginning: url.searchParams.get("fromBeginning"),
|
|
phase: requestPhase,
|
|
valuesRedacted: true
|
|
});
|
|
};
|
|
try {
|
|
const controlRecovery = await ensureControlPageResponsiveForCommand("validateWorkbenchKafkaDebugReplay");
|
|
const snapshot = await workbenchSessionSnapshot();
|
|
const currentPath = safeUrlPath(currentPageUrl()) || "";
|
|
if (!isWorkbenchPathname(currentPath)) throw new Error("validateWorkbenchKafkaDebugReplay requires the original Workbench page");
|
|
const sessionId = snapshot?.activeSessionId || snapshot?.routeSessionId || routeSessionIdFromUrl(currentPageUrl());
|
|
if (!sessionId) throw new Error("validateWorkbenchKafkaDebugReplay requires an existing Workbench session");
|
|
|
|
requestPage = page;
|
|
requestPage.on("request", requestListener);
|
|
const toggle = page.locator('[data-testid="workbench-kafka-debug-toggle"]').first();
|
|
const toggleVisible = await toggle.waitFor({ state: "visible", timeout: 10000 }).then(() => true).catch(() => false);
|
|
if (!toggleVisible) throw new Error("Workbench Kafka debug toggle is not visible; verify the owning YAML capability and admin session");
|
|
if (await toggle.isDisabled()) throw new Error("Workbench Kafka debug toggle is disabled; verify the YAML capability and admin session");
|
|
const toggleCount = await page.locator('[data-testid="workbench-kafka-debug-toggle"]').count();
|
|
if (toggleCount !== 1) throw new Error("Workbench Kafka debug toggle count is " + toggleCount + ", expected 1");
|
|
const toggleCheckedBefore = await toggle.isChecked();
|
|
if (!toggleCheckedBefore) await toggle.check({ timeout: 10000 });
|
|
|
|
const panel = page.locator('[data-testid="workbench-kafka-debug-panel"]').first();
|
|
const clearButton = page.locator('[data-testid="workbench-kafka-debug-clear"]').first();
|
|
const replayButton = page.locator('[data-testid="workbench-kafka-debug-replay"]').first();
|
|
const countsNode = page.locator('[data-testid="workbench-kafka-debug-counts"]').first();
|
|
await panel.waitFor({ state: "visible", timeout: 10000 });
|
|
await clearButton.waitFor({ state: "visible", timeout: 10000 });
|
|
await replayButton.waitFor({ state: "visible", timeout: 10000 });
|
|
await clearButton.click({ timeout: 10000 });
|
|
const cleared = await workbenchKafkaDebugReplayWaitForCleared(panel, countsNode, 5000);
|
|
const before = await workbenchKafkaDebugReplayPanelSnapshot(panel, countsNode);
|
|
if (!/^trc_[A-Za-z0-9_-]+$/u.test(before.traceId || "")) throw new Error("Workbench Kafka debug replay requires the current traceId");
|
|
if (await replayButton.isDisabled()) throw new Error("Workbench Kafka debug replay button is disabled for traceId " + before.traceId);
|
|
|
|
requestPhase = "debug-replay";
|
|
await replayButton.click({ timeout: 10000 });
|
|
const terminalStatus = await workbenchKafkaDebugReplayWaitForTerminal(panel, config.completionTimeoutMs);
|
|
const after = await workbenchKafkaDebugReplayWaitForStablePanel(
|
|
panel,
|
|
countsNode,
|
|
config.requestObservationQuietMs
|
|
);
|
|
Object.assign(report, {
|
|
sessionId,
|
|
traceId: after.traceId,
|
|
topic: after.topic,
|
|
groupId: after.groupId,
|
|
terminalStatus,
|
|
phase: after.typedOutcome.phase,
|
|
code: after.typedOutcome.code,
|
|
typedOutcome: after.typedOutcome,
|
|
layerCounts: after.layerCounts,
|
|
layerDom: after.layerDom,
|
|
layerDomStabilization: after.layerDomStabilization,
|
|
legacyCounts: after.counts,
|
|
valuesRedacted: true
|
|
});
|
|
const traceTimeline = await workbenchKafkaDebugReplayTraceTimelineSnapshot(panel);
|
|
report.traceTimeline = traceTimeline;
|
|
const rawHwlabEventWindow = await workbenchKafkaDebugReplayInspectRawWindow({
|
|
panel,
|
|
config,
|
|
pathBefore: currentPath,
|
|
productEventSourceRequests,
|
|
setRequestPhase: (value) => { requestPhase = value; }
|
|
});
|
|
report.rawHwlabEventWindow = rawHwlabEventWindow;
|
|
const matchingRequest = requests.find((item) => item.stream === "hwlab-debug" && item.traceId === before.traceId && item.fromBeginning === "true") || null;
|
|
const replayEvidence = {
|
|
sessionId,
|
|
traceId: after.traceId,
|
|
topic: after.topic,
|
|
groupId: after.groupId,
|
|
terminalStatus,
|
|
phase: after.typedOutcome.phase,
|
|
code: after.typedOutcome.code,
|
|
typedOutcome: after.typedOutcome,
|
|
layerCounts: after.layerCounts,
|
|
layerDom: after.layerDom,
|
|
layerDomStabilization: after.layerDomStabilization,
|
|
legacyCounts: after.counts,
|
|
traceTimeline,
|
|
rawHwlabEventWindow,
|
|
productEventSource: {
|
|
path: config.productEventsPath,
|
|
additionalRequestCountDuringTabSwitch: rawHwlabEventWindow.productEventSourceRequestCount,
|
|
requests: productEventSourceRequests.slice(-10),
|
|
valuesRedacted: true
|
|
},
|
|
valuesRedacted: true
|
|
};
|
|
Object.assign(report, replayEvidence);
|
|
|
|
if (terminalStatus !== "completed") throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay ended with status " + terminalStatus, replayEvidence);
|
|
if (!matchingRequest) throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay did not issue the expected trace-scoped fromBeginning request", replayEvidence);
|
|
if (after.traceId !== before.traceId) throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay traceId changed during replay", replayEvidence);
|
|
if (after.topic !== config.topic) throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay topic mismatch: " + (after.topic || "missing"), replayEvidence);
|
|
if (!after.groupId || !after.groupId.startsWith(config.groupPrefix + "-")) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay groupId does not use the YAML group prefix", replayEvidence);
|
|
}
|
|
if (!after.deliveryText.includes("debug-replay") || !after.deliveryText.includes("fromBeginning=true")) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay delivery contract is missing", replayEvidence);
|
|
}
|
|
if (!after.counts || after.counts.receivedCount <= 0 || after.counts.appliedCount <= 0) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay received/applied counts must both be greater than zero", replayEvidence);
|
|
}
|
|
if (after.typedOutcome.phase !== "terminal" || after.typedOutcome.code !== "terminal_complete") {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay typed terminal contract is not terminal_complete", replayEvidence);
|
|
}
|
|
if (after.layerDomStabilization?.stable !== true) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay layer DOM did not stabilize after terminal status", replayEvidence);
|
|
}
|
|
workbenchKafkaDebugReplayValidateLayerCounts(after.layerCounts, replayEvidence);
|
|
workbenchKafkaDebugReplayValidateTraceTimeline(traceTimeline, replayEvidence);
|
|
workbenchKafkaDebugReplayValidateRawWindow(rawHwlabEventWindow, replayEvidence);
|
|
|
|
const screenshot = await captureScreenshot("workbench-kafka-debug-replay-" + safeId(command.id));
|
|
Object.assign(report, {
|
|
ok: true,
|
|
status: "passed",
|
|
completedAt: new Date().toISOString(),
|
|
groupPrefix: config.groupPrefix,
|
|
receivedCount: after.counts.receivedCount,
|
|
appliedCount: after.counts.appliedCount,
|
|
toggle: { available: true, enabled: true, checkedBefore: toggleCheckedBefore, checkedAfter: await toggle.isChecked(), valuesRedacted: true },
|
|
clear: cleared,
|
|
request: matchingRequest,
|
|
screenshot: { path: screenshot.path, sha256: screenshot.sha256, valuesRedacted: true },
|
|
controlRecovery,
|
|
valuesRedacted: true
|
|
});
|
|
const reportArtifact = await workbenchKafkaDebugReplayWriteReport(reportDir, report);
|
|
report.reportPath = reportArtifact.path;
|
|
report.reportSha256 = reportArtifact.sha256;
|
|
await appendJsonl(files.control, eventRecord("workbench-kafka-debug-replay-validated", {
|
|
sessionId,
|
|
traceId: report.traceId,
|
|
topic: report.topic,
|
|
groupId: report.groupId,
|
|
phase: report.phase,
|
|
code: report.code,
|
|
receivedCount: report.receivedCount,
|
|
appliedCount: report.appliedCount,
|
|
layerCounts: report.layerCounts,
|
|
layerDom: report.layerDom,
|
|
layerDomStabilization: report.layerDomStabilization,
|
|
traceTimeline: report.traceTimeline,
|
|
rawHwlabEventWindow: report.rawHwlabEventWindow,
|
|
reportPath: report.reportPath,
|
|
reportSha256: report.reportSha256,
|
|
screenshot: report.screenshot,
|
|
valuesRedacted: true
|
|
}));
|
|
return {
|
|
ok: true,
|
|
status: "passed",
|
|
sessionId,
|
|
traceId: report.traceId,
|
|
topic: report.topic,
|
|
groupId: report.groupId,
|
|
groupPrefix: report.groupPrefix,
|
|
phase: report.phase,
|
|
code: report.code,
|
|
receivedCount: report.receivedCount,
|
|
appliedCount: report.appliedCount,
|
|
layerCounts: report.layerCounts,
|
|
layerDom: report.layerDom,
|
|
layerDomStabilization: report.layerDomStabilization,
|
|
traceTimeline: report.traceTimeline,
|
|
rawHwlabEventWindow: report.rawHwlabEventWindow,
|
|
productEventSource: report.productEventSource,
|
|
terminalStatus,
|
|
reportPath: report.reportPath,
|
|
reportSha256: report.reportSha256,
|
|
screenshot: report.screenshot,
|
|
valuesRedacted: true
|
|
};
|
|
} catch (error) {
|
|
Object.assign(report, {
|
|
ok: false,
|
|
status: "failed",
|
|
completedAt: new Date().toISOString(),
|
|
failure: errorSummary(error),
|
|
requests,
|
|
valuesRedacted: true
|
|
});
|
|
if (!report.screenshot) {
|
|
report.screenshot = await captureScreenshot("workbench-kafka-debug-replay-failed-" + safeId(command.id))
|
|
.then((screenshot) => ({ path: screenshot.path, sha256: screenshot.sha256, valuesRedacted: true }))
|
|
.catch((screenshotError) => ({ available: false, error: errorSummary(screenshotError), valuesRedacted: true }));
|
|
}
|
|
const reportArtifact = await workbenchKafkaDebugReplayWriteReport(reportDir, report).catch(() => null);
|
|
const wrapped = error instanceof Error ? error : new Error(String(error));
|
|
wrapped.details = {
|
|
...(wrapped.details || {}),
|
|
traceId: report.traceId || wrapped.details?.traceId || null,
|
|
topic: report.topic || wrapped.details?.topic || null,
|
|
groupId: report.groupId || wrapped.details?.groupId || null,
|
|
terminalStatus: report.terminalStatus || wrapped.details?.terminalStatus || null,
|
|
receivedCount: report.receivedCount ?? report.legacyCounts?.receivedCount ?? wrapped.details?.receivedCount ?? null,
|
|
appliedCount: report.appliedCount ?? report.legacyCounts?.appliedCount ?? wrapped.details?.appliedCount ?? null,
|
|
phase: report.phase || wrapped.details?.phase || null,
|
|
code: report.code || wrapped.details?.code || null,
|
|
layerCounts: report.layerCounts || wrapped.details?.layerCounts || null,
|
|
layerDom: report.layerDom || wrapped.details?.layerDom || null,
|
|
layerDomStabilization: report.layerDomStabilization || wrapped.details?.layerDomStabilization || null,
|
|
traceTimeline: report.traceTimeline || wrapped.details?.traceTimeline || null,
|
|
rawHwlabEventWindow: report.rawHwlabEventWindow || wrapped.details?.rawHwlabEventWindow || null,
|
|
screenshot: report.screenshot || wrapped.details?.screenshot || null,
|
|
reportPath: reportArtifact?.path || null,
|
|
reportSha256: reportArtifact?.sha256 || null,
|
|
valuesRedacted: true
|
|
};
|
|
throw wrapped;
|
|
} finally {
|
|
if (requestPage) requestPage.off("request", requestListener);
|
|
}
|
|
}
|
|
|
|
async function workbenchKafkaDebugReplayWaitForCleared(panel, countsNode, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() <= deadline) {
|
|
const status = await panel.getAttribute("data-replay-status");
|
|
const counts = workbenchKafkaDebugReplayCounts(await countsNode.textContent());
|
|
if (status === "idle" && counts?.appliedCount === 0 && counts?.receivedCount === 0) {
|
|
return { status, ...counts, valuesRedacted: true };
|
|
}
|
|
await sleep(100);
|
|
}
|
|
throw new Error("Workbench Kafka debug panel did not clear to idle 0/0");
|
|
}
|
|
|
|
async function workbenchKafkaDebugReplayWaitForTerminal(panel, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() <= deadline) {
|
|
const status = await panel.getAttribute("data-replay-status");
|
|
if (status === "completed") return status;
|
|
if (status === "incomplete" || status === "error" || status === "closed") return status;
|
|
await sleep(200);
|
|
}
|
|
throw new Error("Workbench Kafka debug replay did not reach a terminal panel status within " + timeoutMs + "ms");
|
|
}
|
|
|
|
async function workbenchKafkaDebugReplayWaitForTabSelected(tab, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() <= deadline) {
|
|
if (await tab.getAttribute("aria-selected") === "true") return true;
|
|
await sleep(50);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function workbenchKafkaDebugReplayPanelSnapshot(panel, countsNode) {
|
|
const contract = await panel.locator('[data-testid="workbench-isolated-debug-contract"]').evaluate((root) => {
|
|
const values = {};
|
|
for (const span of Array.from(root.querySelectorAll("span"))) {
|
|
const key = span.querySelector("b")?.textContent?.trim() || "";
|
|
const value = span.querySelector("code")?.textContent?.trim() || "";
|
|
if (key) values[key] = value;
|
|
}
|
|
return { values, text: root.textContent?.replace(/\s+/gu, " ").trim() || "" };
|
|
});
|
|
const status = await panel.getAttribute("data-replay-status");
|
|
const layerDom = await workbenchKafkaDebugReplayLayerDomSnapshot(panel);
|
|
const layerText = layerDom.text || "";
|
|
const errorNode = panel.locator(".isolated-debug-error").first();
|
|
const errorText = await errorNode.count() > 0 ? String(await errorNode.textContent() || "").trim() : "";
|
|
const layerCounts = workbenchKafkaDebugReplayLayerCounts(layerText);
|
|
return {
|
|
status,
|
|
traceId: contract.values.traceId || null,
|
|
topic: contract.values.topic || null,
|
|
groupId: contract.values.group || null,
|
|
deliveryText: contract.text,
|
|
counts: workbenchKafkaDebugReplayCounts(await countsNode.textContent()),
|
|
layerCounts,
|
|
layerDom,
|
|
error: errorText ? { textHash: sha256Text(errorText), textPreview: truncate(errorText, 160), textBytes: Buffer.byteLength(errorText), valuesRedacted: true } : null,
|
|
typedOutcome: workbenchKafkaDebugReplayTypedOutcome({ status, errorText, layerCounts }),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async function workbenchKafkaDebugReplayLayerDomSnapshot(panel) {
|
|
const selector = '[data-testid="workbench-kafka-debug-layer-counts"]';
|
|
const nodes = await panel.locator(selector).evaluateAll((elements) => elements.map((element) => {
|
|
const style = getComputedStyle(element);
|
|
const rect = element.getBoundingClientRect();
|
|
const text = element.textContent?.replace(/\s+/gu, " ").trim() || "";
|
|
return {
|
|
connected: element.isConnected,
|
|
visible: style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0,
|
|
text
|
|
};
|
|
}));
|
|
const selectedIndex = nodes.findIndex((node) => node.visible);
|
|
const selected = nodes[selectedIndex >= 0 ? selectedIndex : 0] || null;
|
|
const text = selected?.text || "";
|
|
return {
|
|
selector,
|
|
matchCount: nodes.length,
|
|
visibleCount: nodes.filter((node) => node.visible).length,
|
|
connectedCount: nodes.filter((node) => node.connected).length,
|
|
selectedIndex: selectedIndex >= 0 ? selectedIndex : nodes.length > 0 ? 0 : null,
|
|
text,
|
|
textPresent: text.length > 0,
|
|
textBytes: Buffer.byteLength(text),
|
|
textHash: sha256Text(text),
|
|
textPreview: truncate(text, 240),
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
async function workbenchKafkaDebugReplayWaitForStablePanel(panel, countsNode, timeoutMs) {
|
|
const startedAtMs = Date.now();
|
|
const deadline = startedAtMs + timeoutMs;
|
|
const stableQuietMs = Math.min(500, Math.max(100, Math.floor(timeoutMs / 4)));
|
|
let latest = await workbenchKafkaDebugReplayPanelSnapshot(panel, countsNode);
|
|
let previousSignature = null;
|
|
let stableSinceMs = null;
|
|
let sampleCount = 0;
|
|
while (Date.now() <= deadline) {
|
|
latest = await workbenchKafkaDebugReplayPanelSnapshot(panel, countsNode);
|
|
sampleCount += 1;
|
|
const signature = JSON.stringify({
|
|
status: latest.status,
|
|
counts: latest.counts,
|
|
layerCounts: latest.layerCounts,
|
|
layerTextHash: latest.layerDom?.textHash,
|
|
matchCount: latest.layerDom?.matchCount,
|
|
visibleCount: latest.layerDom?.visibleCount
|
|
});
|
|
const domReady = latest.layerDom?.matchCount === 1
|
|
&& latest.layerDom?.visibleCount === 1
|
|
&& latest.layerDom?.textPresent === true
|
|
&& latest.layerCounts !== null;
|
|
if (domReady && signature === previousSignature) {
|
|
stableSinceMs ??= Date.now();
|
|
if (Date.now() - stableSinceMs >= stableQuietMs) {
|
|
return {
|
|
...latest,
|
|
layerDomStabilization: {
|
|
stable: true,
|
|
timedOut: false,
|
|
sampleCount,
|
|
elapsedMs: Date.now() - startedAtMs,
|
|
stableQuietMs,
|
|
valuesRedacted: true
|
|
}
|
|
};
|
|
}
|
|
} else {
|
|
stableSinceMs = null;
|
|
}
|
|
previousSignature = signature;
|
|
await sleep(100);
|
|
}
|
|
return {
|
|
...latest,
|
|
layerDomStabilization: {
|
|
stable: false,
|
|
timedOut: true,
|
|
sampleCount,
|
|
elapsedMs: Date.now() - startedAtMs,
|
|
stableQuietMs,
|
|
valuesRedacted: true
|
|
}
|
|
};
|
|
}
|
|
|
|
async function workbenchKafkaDebugReplayTraceTimelineSnapshot(panel) {
|
|
return panel.evaluate((root) => {
|
|
const rows = Array.from(root.querySelectorAll('[data-testid="trace-render-row"]'));
|
|
const authorityRows = rows.filter((row) => row.hasAttribute("data-event-seq-authority"));
|
|
const sourceRows = authorityRows.filter((row) => row.getAttribute("data-event-seq-authority") === "source");
|
|
const projectedRows = authorityRows.filter((row) => row.getAttribute("data-event-seq-authority") === "projected");
|
|
const readableSourceRows = sourceRows.filter((row) => Boolean(row.textContent?.replace(/\s+/gu, " ").trim()));
|
|
return {
|
|
contract: authorityRows.length > 0 ? "trace-sequence-authority-v1" : "legacy-ui",
|
|
authorityAvailable: authorityRows.length > 0,
|
|
rowCount: rows.length,
|
|
sourceAuthorityRowCount: sourceRows.length,
|
|
projectedAuthorityRowCount: projectedRows.length,
|
|
readableSourceRowCount: readableSourceRows.length,
|
|
samples: readableSourceRows.slice(0, 8).map((row) => ({
|
|
seq: row.getAttribute("data-event-seq"),
|
|
authority: row.getAttribute("data-event-seq-authority"),
|
|
kind: row.getAttribute("data-event-kind"),
|
|
status: row.getAttribute("data-event-status"),
|
|
terminal: row.getAttribute("data-terminal") === "true",
|
|
textBytes: new TextEncoder().encode(row.textContent?.replace(/\s+/gu, " ").trim() || "").byteLength
|
|
})),
|
|
valuesRedacted: true
|
|
};
|
|
});
|
|
}
|
|
|
|
async function workbenchKafkaDebugReplayInspectRawWindow(input) {
|
|
const rawTabLocator = input.panel.locator('[data-testid="workbench-kafka-debug-tab-raw"]');
|
|
const rawTabCount = await rawTabLocator.count();
|
|
if (rawTabCount === 0) {
|
|
return {
|
|
available: false,
|
|
contract: "legacy-ui",
|
|
reason: "raw-hwlab-event-tab-not-present",
|
|
productEventsPath: input.config.productEventsPath,
|
|
productEventSourceRequestCount: 0,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
if (rawTabCount !== 1) throw new Error("Workbench raw HWLAB Event tab count is " + rawTabCount + ", expected 1");
|
|
const traceTabLocator = input.panel.locator('[data-testid="workbench-kafka-debug-tab-trace"]');
|
|
const traceTabCount = await traceTabLocator.count();
|
|
if (traceTabCount !== 1) throw new Error("Workbench Trace debug tab count is " + traceTabCount + ", expected 1");
|
|
const rawTab = rawTabLocator.first();
|
|
const traceTab = traceTabLocator.first();
|
|
const requestStart = input.productEventSourceRequests.length;
|
|
const pageUrlBefore = currentPageUrl();
|
|
input.setRequestPhase("raw-tab-switch");
|
|
await rawTab.click({ timeout: 10000 });
|
|
if (!await workbenchKafkaDebugReplayWaitForTabSelected(rawTab, 5000)) throw new Error("Workbench raw HWLAB Event tab did not become selected");
|
|
const rawCountsNode = input.panel.locator('[data-testid="workbench-raw-hwlab-counts"]').first();
|
|
const rawTextNode = input.panel.locator('[data-testid="workbench-raw-hwlab-text"]').first();
|
|
const rawContractNode = input.panel.locator('[data-testid="workbench-raw-hwlab-contract"]').first();
|
|
await rawCountsNode.waitFor({ state: "visible", timeout: 5000 });
|
|
await rawTextNode.waitFor({ state: "visible", timeout: 5000 });
|
|
await rawContractNode.waitFor({ state: "visible", timeout: 5000 });
|
|
await sleep(input.config.requestObservationQuietMs);
|
|
const counts = workbenchKafkaDebugReplayRawCounts(await rawCountsNode.textContent());
|
|
const rawText = await rawTextNode.inputValue();
|
|
const contractText = String(await rawContractNode.textContent() || "").replace(/\s+/gu, " ").trim();
|
|
input.setRequestPhase("trace-tab-restore");
|
|
await traceTab.click({ timeout: 10000 });
|
|
if (!await workbenchKafkaDebugReplayWaitForTabSelected(traceTab, 5000)) throw new Error("Workbench Trace debug tab did not restore after raw inspection");
|
|
await sleep(input.config.requestObservationQuietMs);
|
|
input.setRequestPhase("complete");
|
|
const pageUrlAfter = currentPageUrl();
|
|
const productRequests = input.productEventSourceRequests.slice(requestStart);
|
|
return {
|
|
available: true,
|
|
contract: "raw-hwlab-event-window-v1",
|
|
pageId,
|
|
samePage: safeUrlPath(pageUrlBefore) === input.pathBefore && safeUrlPath(pageUrlAfter) === input.pathBefore,
|
|
pathBefore: input.pathBefore,
|
|
pathAfter: safeUrlPath(pageUrlAfter),
|
|
productEventsPath: input.config.productEventsPath,
|
|
productEventSourceRequestCount: productRequests.length,
|
|
productEventSourceRequests: productRequests.slice(-10),
|
|
sourceContractPresent: contractText.includes("EventSource") && contractText.includes("hwlab.event.v1"),
|
|
unfilteredContractPresent: contractText.includes("no client event filter"),
|
|
counts,
|
|
textBytes: Buffer.byteLength(rawText),
|
|
textHash: sha256Text(rawText),
|
|
textPresent: rawText.length > 0,
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function workbenchKafkaDebugReplayValidateLayerCounts(layerCounts, evidence) {
|
|
if (!layerCounts) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay typed server/client count contract is missing", evidence);
|
|
}
|
|
const values = [
|
|
layerCounts.server.scanned,
|
|
layerCounts.server.matched,
|
|
layerCounts.server.delivered,
|
|
layerCounts.client.received,
|
|
layerCounts.client.decoded,
|
|
layerCounts.client.applied
|
|
];
|
|
if (values.some((value) => !Number.isInteger(value) || value <= 0)) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay typed server/client counts must all be greater than zero", evidence);
|
|
}
|
|
if (layerCounts.server.delivered !== layerCounts.client.received) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay server delivered and client received counts differ", evidence);
|
|
}
|
|
if (layerCounts.server.scanned < layerCounts.server.matched || layerCounts.server.matched !== layerCounts.server.delivered) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay server scanned/matched/delivered counts are inconsistent", evidence);
|
|
}
|
|
if (layerCounts.client.received !== layerCounts.client.decoded || layerCounts.client.decoded !== layerCounts.client.applied) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay client received/decoded/applied counts differ", evidence);
|
|
}
|
|
if (evidence?.legacyCounts && (evidence.legacyCounts.receivedCount !== layerCounts.client.received || evidence.legacyCounts.appliedCount !== layerCounts.client.applied)) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Kafka debug replay legacy reducer counts differ from the typed client layer", evidence);
|
|
}
|
|
}
|
|
|
|
function workbenchKafkaDebugReplayValidateTraceTimeline(traceTimeline, evidence) {
|
|
if (!traceTimeline.authorityAvailable) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Trace timeline source-authority contract is missing", evidence);
|
|
}
|
|
if (traceTimeline.sourceAuthorityRowCount <= 0 || traceTimeline.readableSourceRowCount <= 0) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench Trace timeline did not expose a readable source-authority row", evidence);
|
|
}
|
|
}
|
|
|
|
function workbenchKafkaDebugReplayValidateRawWindow(rawWindow, evidence) {
|
|
if (!rawWindow.available) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event window contract is missing", evidence);
|
|
}
|
|
if (!rawWindow.samePage) throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event inspection navigated away from the original page", evidence);
|
|
if (!rawWindow.sourceContractPresent) throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event window does not declare the existing product EventSource contract", evidence);
|
|
if (!rawWindow.unfilteredContractPresent) throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event window does not declare the unfiltered ingress contract", evidence);
|
|
if (!rawWindow.counts) throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event counts are unreadable", evidence);
|
|
const counts = rawWindow.counts;
|
|
if ([counts.received, counts.retained, counts.decoded, counts.rejected, counts.evicted, counts.bytes].some((value) => !Number.isInteger(value) || value < 0)) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event counts are incomplete", evidence);
|
|
}
|
|
if (counts.received !== counts.decoded + counts.rejected || counts.received !== counts.retained + counts.evicted) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event bounded-buffer counts are inconsistent", evidence);
|
|
}
|
|
if (counts.received <= 0 || counts.retained <= 0 || counts.bytes <= 0 || rawWindow.textPresent !== true || rawWindow.textBytes <= 0) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event window did not retain a readable product SSE frame", evidence);
|
|
}
|
|
if (rawWindow.productEventSourceRequestCount !== 0) {
|
|
throw workbenchKafkaDebugReplayEvidenceError("Workbench raw HWLAB Event tab opened an additional product EventSource", evidence);
|
|
}
|
|
}
|
|
|
|
function workbenchKafkaDebugReplayEvidenceError(message, evidence) {
|
|
const error = new Error(message);
|
|
error.details = {
|
|
traceId: evidence?.traceId || null,
|
|
topic: evidence?.topic || null,
|
|
groupId: evidence?.groupId || null,
|
|
terminalStatus: evidence?.terminalStatus || null,
|
|
receivedCount: evidence?.legacyCounts?.receivedCount ?? null,
|
|
appliedCount: evidence?.legacyCounts?.appliedCount ?? null,
|
|
phase: evidence?.phase || null,
|
|
code: evidence?.code || null,
|
|
layerCounts: evidence?.layerCounts || null,
|
|
layerDom: evidence?.layerDom || null,
|
|
layerDomStabilization: evidence?.layerDomStabilization || null,
|
|
traceTimeline: evidence?.traceTimeline || null,
|
|
rawHwlabEventWindow: evidence?.rawHwlabEventWindow || null,
|
|
valuesRedacted: true
|
|
};
|
|
return error;
|
|
}
|
|
|
|
async function workbenchKafkaDebugReplayWriteReport(reportDir, report) {
|
|
await mkdir(reportDir, { recursive: true, mode: 0o700 });
|
|
const file = path.join(reportDir, "report.json");
|
|
await writeFile(file, JSON.stringify(sanitize(report), null, 2) + "\n", { mode: 0o600 });
|
|
const meta = await fileMeta(file);
|
|
const artifact = { kind: "workbench-kafka-debug-replay-report", path: file, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true };
|
|
await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...artifact });
|
|
return artifact;
|
|
}
|
|
`;
|
|
}
|