From e7d1c82294d21ea1c52c2c24e7ee264750e2d157 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 16:10:03 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=97=A2?= =?UTF-8?q?=E6=9C=89=E4=BC=9A=E8=AF=9D=E5=88=B7=E6=96=B0=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/hwlab-node-help.ts | 2 + ...-node-web-observe-runner-control-source.ts | 1 + ...web-observe-runner-realtime-source.test.ts | 24 ++ ...node-web-observe-runner-realtime-source.ts | 318 +++++++++++++++++- scripts/src/hwlab-node/entry.ts | 1 + .../hwlab-node/web-probe-observe-actions.ts | 6 + .../web-probe-observe-collect.test.ts | 13 + .../hwlab-node/web-probe-observe-collect.ts | 2 +- .../web-probe-observe-options.test.ts | 11 + scripts/src/hwlab-node/web-probe-observe.ts | 9 +- 10 files changed, 380 insertions(+), 7 deletions(-) diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts index b3c7cfdf..6537b607 100644 --- a/scripts/src/hwlab-node-help.ts +++ b/scripts/src/hwlab-node-help.ts @@ -95,6 +95,7 @@ export function hwlabNodeWebProbeHelp(): Record { "bun scripts/cli.ts web-probe observe start --node --lane --origin public --target-path /projects/mdtodo --sample-interval-ms 5000", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type sendPrompt --text 'ping'", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateRealtimeFanout --profile pure-kafka-live --provider gpt.pika --text '' --second-text ''", + "bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateExistingSessionRefresh --profile pure-kafka-live --session-id ses_existing", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateWorkbenchKafkaDebugReplay", "bun scripts/cli.ts web-probe observe command webobs-xxxx --type validateWorkbenchTraceReadability", "bun scripts/cli.ts web-probe observe status webobs-xxxx --command-id cmd-xxxx", @@ -147,6 +148,7 @@ export function hwlabNodeWebProbeHelp(): Record { "After observe start, prefer observe status|command|stop|collect|analyze instead of repeating --node/--lane/--state-dir.", "Typed observe commands inherit the semantic origin selected by observe start and must not embed a replacement URL or IP.", "validateRealtimeFanout is a YAML-profiled background command: it gates submit on two live product subscribers plus three Kafka debug consumers, validates two turns and disconnect/reconnect without replay, and is polled by exact command id.", + "validateExistingSessionRefresh is a YAML-profiled background command: it submits no prompt and proves an existing session keeps DOM identity while Kafka retention reaches the shared live SSE phase.", "validateWorkbenchKafkaDebugReplay 复用 observer 当前 Workbench session,以 owning YAML 中的 topic、group prefix 和超时验证隔离 Kafka 重放,并通过精确 command id 查询结果。", "validateWorkbenchTraceReadability 只检查当前 Workbench 会话中的产品 Trace 卡片。", "该命令排除隔离调试面板,并分别验证运行中默认展开、source authority 与终态保留。", diff --git a/scripts/src/hwlab-node-web-observe-runner-control-source.ts b/scripts/src/hwlab-node-web-observe-runner-control-source.ts index 2b0d0525..655a5f04 100644 --- a/scripts/src/hwlab-node-web-observe-runner-control-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-control-source.ts @@ -102,6 +102,7 @@ async function processCommand(command) { expectedActionWaitMs: command.expectedActionWaitMs, }), "sendPrompt"); case "validateRealtimeFanout": return validateRealtimeFanout(command); + case "validateExistingSessionRefresh": return validateExistingSessionRefresh(command); case "validateWorkbenchKafkaDebugReplay": return withObserverSync(await validateWorkbenchKafkaDebugReplay(command), "validateWorkbenchKafkaDebugReplay"); case "validateWorkbenchTraceReadability": return withObserverSync(await validateWorkbenchTraceReadability(command), "validateWorkbenchTraceReadability"); case "steer": return withObserverSync(await sendPrompt(String(command.text || ""), { expectedAction: "steer", responsePath: "/v1/agent/chat/steer", noActiveReason: "steer-no-active-turn", expectedActionWaitMs: command.expectedActionWaitMs }), "steer"); diff --git a/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts b/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts index 88203959..85e3ce9d 100644 --- a/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts +++ b/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts @@ -172,6 +172,30 @@ test("realtime fanout validates YAML-owned refresh and legacy live-only connecte }, sessionId, "live", liveProfile)); }); +test("existing session refresh rejects duplicate or reordered Workbench identities", () => { + const source = nodeWebObserveRunnerRealtimeSource(); + const build = new Function(`${source}\nreturn realtimeAssertSameWorkbenchDom;`) as () => ( + before: Record, after: Record, + ) => void; + const assertSame = build(); + const before = { messageCount: 2, identities: ["user:m1:", "agent:m2:t1"], missingIdentityRows: [], duplicateIdentities: [] }; + assert.doesNotThrow(() => assertSame(before, { ...before })); + assert.throws(() => assertSame(before, { ...before, identities: [...before.identities].reverse() }), /identity or order changed/u); + assert.throws(() => assertSame(before, { ...before, duplicateIdentities: ["agent:m2:t1"] }), /duplicate message identities/u); + assert.throws(() => assertSame(before, { ...before, missingIdentityRows: [1] }), /without a stable message identity/u); +}); + +test("existing session refresh failures preserve typed phase and code", () => { + const source = nodeWebObserveRunnerRealtimeSource(); + const build = new Function(`function errorSummary(error) { return { message: error.message }; }\n${source}\nreturn realtimeExistingRefreshFailure;`) as () => ( + phase: string, error: Error, + ) => Record; + const failure = build()("retention-barrier", new Error("connected evidence timed out")); + assert.equal(failure.phase, "retention-barrier"); + assert.equal(failure.code, "retention_barrier_failed"); + assert.deepEqual(failure.valuesRedacted, true); +}); + test("realtime fanout proves one formal user event through AgentRun, HWLAB Kafka, and product SSE", () => { const source = nodeWebObserveRunnerRealtimeSource(); const build = new Function(`${source}\nreturn realtimeValidateTurnEvidence;`) as () => (input: Record) => void; diff --git a/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts b/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts index ab6265aa..fb635630 100644 --- a/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts @@ -327,6 +327,314 @@ async function validateRealtimeFanout(command) { } } +async function validateExistingSessionRefresh(command) { + const profileId = String(command.profile || "").trim(); + const profile = realtimeFanoutProfiles[profileId]; + if (!profile || typeof profile !== "object") throw new Error("validateExistingSessionRefresh profile is not declared by the owning YAML: " + profileId); + const effective = realtimeFanoutEffectiveProfile(profile, command.durationMs); + if (effective.expectedProductSse.refreshHandoff !== true) throw new Error("validateExistingSessionRefresh requires a YAML profile with refresh handoff enabled"); + const reportDir = path.join(stateDir, "artifacts", "existing-session-refresh", safeId(command.id)); + await mkdir(reportDir, { recursive: true, mode: 0o700 }); + const network = []; + const requestListener = (request) => { + let url; + try { url = new URL(request.url()); } catch { return; } + if (url.origin !== new URL(baseUrl).origin) return; + network.push({ + at: Date.now(), + method: request.method(), + path: url.pathname, + sessionId: url.searchParams.get("sessionId"), + traceId: url.searchParams.get("traceId"), + afterSeq: url.searchParams.get("afterSeq"), + lastEventId: request.headers()["last-event-id"] || null, + valuesRedacted: true, + }); + if (network.length > 1000) network.splice(0, network.length - 1000); + }; + const report = { + ok: false, + contract: "web-probe-existing-session-refresh-v1", + commandId: command.id, + profile: profileId, + origin, + startedAt: new Date().toISOString(), + valuesRedacted: true, + }; + let phase = "session-selection"; + let sessionId = null; + let refreshReplay = null; + let screenshot = null; + let requestPage = null; + try { + const controlRecovery = await ensureControlPageResponsiveForCommand("validateExistingSessionRefresh"); + const initial = await workbenchSessionSnapshot(); + sessionId = String(command.sessionId || initial?.activeSessionId || initial?.routeSessionId || "").trim(); + if (!sessionId) throw new Error("validateExistingSessionRefresh requires --session-id or an active Workbench session"); + if (!/^ses_[A-Za-z0-9_-]+$/u.test(sessionId)) throw new Error("validateExistingSessionRefresh session id is invalid"); + Object.assign(report, { sessionId, controlRecovery }); + if (initial?.activeSessionId !== sessionId || initial?.routeSessionId !== sessionId) { + await gotoTarget("/workbench/sessions/" + encodeURIComponent(sessionId)); + const selected = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: effective.barrierTimeoutMs }); + if (selected.ok !== true) throw new Error("existing session did not hydrate before refresh validation"); + } + const before = await realtimeWorkbenchDomIdentity(); + if (before.messageCount < 1) throw new Error("existing session has no rendered messages to validate across refresh"); + report.before = before; + + phase = "refresh-navigation"; + requestPage = page; + requestPage.on("request", requestListener); + await realtimeArmExistingSessionRefreshProbe(requestPage, effective); + await requestPage.reload({ waitUntil: "domcontentloaded", timeout: effective.barrierTimeoutMs }); + const settled = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: effective.barrierTimeoutMs }); + if (settled.ok !== true) throw new Error("existing session did not hydrate after refresh"); + phase = "retention-barrier"; + const connected = await realtimePoll(async () => page.evaluate(() => window.__unideskExistingSessionRefresh?.connected?.at(-1) || null), effective.barrierTimeoutMs, "existing session refresh connected evidence"); + realtimeAssertConnectedContract(connected, sessionId, "existing-session-refresh", effective, { requireRetainedEvents: true }); + refreshReplay = realtimeConnectedSummary(connected).refreshReplay; + phase = "live-handoff"; + await realtimeWaitForSameWorkbenchDom(before, effective.barrierTimeoutMs); + await sleep(effective.reconnectQuietMs); + const after = await realtimeWorkbenchDomIdentity(); + realtimeAssertSameWorkbenchDom(before, after); + const browserEvidence = await page.evaluate(() => JSON.parse(JSON.stringify(window.__unideskExistingSessionRefresh || {}))); + if (browserEvidence.sources.length !== 1) throw new Error("main Workbench product EventSource count is " + browserEvidence.sources.length + ", expected 1"); + if (browserEvidence.sources[0]?.opened !== 1) throw new Error("main Workbench product EventSource open count is not 1"); + if (browserEvidence.connected.length !== 1) throw new Error("main Workbench connected event count is " + browserEvidence.connected.length + ", expected 1"); + if (browserEvidence.errors.length !== 0) throw new Error("main Workbench product EventSource observed an error"); + const mainStreams = network.filter((item) => item.path === effective.productEventsPath && item.sessionId === sessionId); + const forbidden = network.filter((item) => realtimeForbiddenRequest(item, effective.forbiddenRequestPaths)); + if (mainStreams.length !== 1) throw new Error("main Workbench session SSE request count is " + mainStreams.length + ", expected 1"); + if (mainStreams[0].traceId !== null || mainStreams[0].afterSeq !== null || mainStreams[0].lastEventId !== null) throw new Error("main Workbench product SSE used a trace or cursor scope"); + if (forbidden.length > 0) throw new Error("forbidden recovery or polling requests observed: " + forbidden.map((item) => item.path).join(",")); + phase = "evidence-write"; + const screenshotFile = path.join(reportDir, "existing-session-refresh.png"); + await page.screenshot({ path: screenshotFile, fullPage: true, animations: "disabled", timeout: screenshotCaptureTimeoutMs }); + const screenshotMeta = await fileMeta(screenshotFile); + screenshot = { kind: "existing-session-refresh-screenshot", path: screenshotFile, ...screenshotMeta, commandId: activeCommandId, valuesRedacted: true }; + await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...screenshot }); + const productSse = { + requestCount: mainStreams.length, + browserSourceCount: browserEvidence.sources.length, + openCount: browserEvidence.sources[0]?.opened || 0, + connectedEventCount: browserEvidence.connected.length, + businessEventCount: browserEvidence.businessEventCount, + livePhase: refreshReplay?.phase === "live", + valuesRedacted: true, + }; + Object.assign(report, { + ok: true, + status: "passed", + phase: "live", + code: "refresh_live_handoff_validated", + completedAt: new Date().toISOString(), + after, + connected: realtimeConnectedSummary(connected), + refreshReplay, + productSse, + forbiddenRequests: { count: 0, rows: [], valuesRedacted: true }, + screenshot, + valuesRedacted: true, + }); + const artifacts = await realtimeWriteEvidenceArtifacts(reportDir, network, [], "existing-session-refresh"); + report.artifacts = artifacts; + const reportArtifact = await realtimeWriteReport(reportDir, report, "existing-session-refresh-report"); + const observer = await syncObserverPageToControlSession("validateExistingSessionRefresh", sessionId); + await appendJsonl(files.control, eventRecord("existing-session-refresh-validated", { + profile: profileId, + sessionId, + traceIds: after.traceIds, + phase: "live", + code: "refresh_live_handoff_validated", + reportPath: reportArtifact.path, + reportSha256: reportArtifact.sha256, + screenshotSha256: screenshot.sha256, + valuesRedacted: true, + })); + return { + ok: true, + status: "passed", + profile: profileId, + sessionId, + traceIds: after.traceIds, + phase: "live", + code: "refresh_live_handoff_validated", + before: realtimeWorkbenchDomSummary(before), + after: realtimeWorkbenchDomSummary(after), + refreshReplay, + productSse, + forbiddenRequestCount: 0, + reportPath: reportArtifact.path, + reportSha256: reportArtifact.sha256, + screenshot, + observer, + valuesRedacted: true, + }; + } catch (error) { + const failure = realtimeExistingRefreshFailure(phase, error); + if (!screenshot) { + screenshot = await captureScreenshot("existing-session-refresh-failed-" + safeId(command.id)) + .then((artifact) => ({ path: artifact.path, sha256: artifact.sha256, valuesRedacted: true })) + .catch((screenshotError) => ({ available: false, error: errorSummary(screenshotError), valuesRedacted: true })); + } + Object.assign(report, { + ok: false, + status: "failed", + completedAt: new Date().toISOString(), + phase: failure.phase, + code: failure.code, + refreshReplay, + failure, + screenshot, + valuesRedacted: true, + }); + const artifacts = await realtimeWriteEvidenceArtifacts(reportDir, network, [], "existing-session-refresh").catch(() => null); + if (artifacts) report.artifacts = artifacts; + const reportArtifact = await realtimeWriteReport(reportDir, report, "existing-session-refresh-report").catch(() => null); + const wrapped = error instanceof Error ? error : new Error(String(error)); + wrapped.details = { + ...(wrapped.details || {}), + profile: profileId, + sessionId, + traceIds: report.after?.traceIds || report.before?.traceIds || [], + phase: failure.phase, + code: failure.code, + before: report.before ? realtimeWorkbenchDomSummary(report.before) : null, + after: report.after ? realtimeWorkbenchDomSummary(report.after) : null, + refreshReplay, + screenshot, + reportPath: reportArtifact?.path || null, + reportSha256: reportArtifact?.sha256 || null, + valuesRedacted: true, + }; + throw wrapped; + } finally { + if (requestPage) requestPage.off("request", requestListener); + await page.evaluate(() => window.sessionStorage.removeItem("__unideskExistingSessionRefreshCapture")).catch(() => {}); + } +} + +function realtimeExistingRefreshFailure(phase, error) { + const codes = { + "session-selection": "session_selection_failed", + "refresh-navigation": "session_hydration_failed", + "retention-barrier": "retention_barrier_failed", + "live-handoff": "live_handoff_failed", + "evidence-write": "evidence_write_failed", + }; + return { + phase, + code: codes[phase] || "existing_session_refresh_failed", + error: errorSummary(error), + valuesRedacted: true, + }; +} + +async function realtimeArmExistingSessionRefreshProbe(targetPage, profile) { + await targetPage.addInitScript(() => { + const markerKey = "__unideskExistingSessionRefreshCapture"; + const raw = window.sessionStorage.getItem(markerKey); + if (!raw) return; + window.sessionStorage.removeItem(markerKey); + let config; + try { config = JSON.parse(raw); } catch { return; } + const NativeEventSource = window.EventSource; + const state = { sources: [], connected: [], businessEventCount: 0, errors: [] }; + window.__unideskExistingSessionRefresh = state; + if (typeof NativeEventSource !== "function") { + state.errors.push({ kind: "eventsource-unavailable" }); + return; + } + class ObservedEventSource extends NativeEventSource { + constructor(url, options) { + super(url, options); + let pathname = ""; + try { pathname = new URL(String(url), window.location.href).pathname; } catch {} + if (pathname !== config.productEventsPath) return; + state.sources.push({ url: String(url), opened: 0 }); + const source = state.sources[state.sources.length - 1]; + this.addEventListener("open", () => { source.opened += 1; }); + this.addEventListener(config.productReadyEvent, (event) => { + try { state.connected.push(JSON.parse(event.data)); } catch { state.errors.push({ kind: "connected-parse" }); } + }); + this.addEventListener(config.businessEvent, () => { state.businessEventCount += 1; }); + this.addEventListener("error", () => { state.errors.push({ kind: "eventsource-error" }); }); + } + } + Object.defineProperty(ObservedEventSource, "CONNECTING", { value: NativeEventSource.CONNECTING }); + Object.defineProperty(ObservedEventSource, "OPEN", { value: NativeEventSource.OPEN }); + Object.defineProperty(ObservedEventSource, "CLOSED", { value: NativeEventSource.CLOSED }); + window.EventSource = ObservedEventSource; + }); + await targetPage.evaluate((config) => { + window.sessionStorage.setItem("__unideskExistingSessionRefreshCapture", JSON.stringify(config)); + }, { + productEventsPath: profile.productEventsPath, + productReadyEvent: profile.productReadyEvent, + businessEvent: profile.businessEvent, + }); +} + +async function realtimeWorkbenchDomIdentity() { + return page.evaluate(() => { + const cards = Array.from(document.querySelectorAll("#conversation-list > .message-card")); + const rows = cards.map((element, index) => ({ + index, + role: element.getAttribute("data-role") || null, + messageId: element.getAttribute("data-message-id") || null, + traceId: element.getAttribute("data-trace-id") || null, + status: element.getAttribute("data-status") || null, + })); + const identity = (row) => [row.role || "", row.messageId || "", row.traceId || ""].join(":"); + const identities = rows.map(identity); + return { + messageCount: rows.length, + identities, + missingIdentityRows: rows.filter((row) => !row.role || (!row.messageId && !row.traceId)).map((row) => row.index), + duplicateIdentities: [...new Set(identities.filter((value, index, all) => all.indexOf(value) !== index))], + traceIds: [...new Set(rows.map((row) => row.traceId).filter(Boolean))], + rows, + valuesRedacted: true, + }; + }); +} + +function realtimeAssertSameWorkbenchDom(before, after) { + if (before.missingIdentityRows.length > 0 || after.missingIdentityRows.length > 0) throw new Error("Workbench DOM contains rows without a stable message identity"); + if (before.duplicateIdentities.length > 0 || after.duplicateIdentities.length > 0) throw new Error("Workbench DOM contains duplicate message identities"); + if (before.messageCount !== after.messageCount) throw new Error("Workbench message count changed across refresh: " + before.messageCount + " -> " + after.messageCount); + if (JSON.stringify(before.identities) !== JSON.stringify(after.identities)) throw new Error("Workbench message identity or order changed across refresh"); +} + +async function realtimeWaitForSameWorkbenchDom(before, timeoutMs) { + let latest = null; + return realtimePoll(async () => { + latest = await realtimeWorkbenchDomIdentity(); + try { + realtimeAssertSameWorkbenchDom(before, latest); + return latest; + } catch { + return null; + } + }, timeoutMs, "existing session Workbench DOM identity restoration").catch((error) => { + const wrapped = error instanceof Error ? error : new Error(String(error)); + wrapped.details = { ...(wrapped.details || {}), before: realtimeWorkbenchDomSummary(before), after: realtimeWorkbenchDomSummary(latest), valuesRedacted: true }; + throw wrapped; + }); +} + +function realtimeWorkbenchDomSummary(value) { + return value ? { + messageCount: value.messageCount, + identities: Array.isArray(value.identities) ? value.identities.slice(0, 80) : [], + missingIdentityRows: Array.isArray(value.missingIdentityRows) ? value.missingIdentityRows.slice(0, 20) : [], + duplicateIdentities: Array.isArray(value.duplicateIdentities) ? value.duplicateIdentities.slice(0, 20) : [], + traceIds: Array.isArray(value.traceIds) ? value.traceIds.slice(0, 20) : [], + valuesRedacted: true, + } : null; +} + function realtimeFanoutEffectiveProfile(profile, durationMs) { const debugStreams = Array.isArray(profile.debugStreams) ? profile.debugStreams.map(String) : []; if (Number(profile.subscriberCount) !== 2) throw new Error("realtime fanout profile subscriberCount must be 2"); @@ -1041,7 +1349,7 @@ function realtimeDebugSnapshotSummary(snapshot) { }; } -async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots) { +async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots, artifactPrefix = "realtime-fanout") { const requestsFile = path.join(reportDir, "request-ledger.jsonl"); const eventsFile = path.join(reportDir, "events.jsonl"); await writeFile(requestsFile, network.map((item) => JSON.stringify(item)).join("\n") + "\n", { mode: 0o600 }); @@ -1059,16 +1367,16 @@ async function realtimeWriteEvidenceArtifacts(reportDir, network, debugSnapshots events: { path: eventsFile, ...eventMeta, count: eventRows.length, valuesRedacted: true }, valuesRedacted: true, }; - await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: "realtime-fanout-request-ledger", commandId: activeCommandId, ...artifacts.requestLedger }); - await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: "realtime-fanout-events", commandId: activeCommandId, ...artifacts.events }); + await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: artifactPrefix + "-request-ledger", commandId: activeCommandId, ...artifacts.requestLedger }); + await appendJsonl(files.artifacts, { ts: new Date().toISOString(), kind: artifactPrefix + "-events", commandId: activeCommandId, ...artifacts.events }); return artifacts; } -async function realtimeWriteReport(reportDir, report) { +async function realtimeWriteReport(reportDir, report, artifactKind = "realtime-fanout-report") { const reportFile = path.join(reportDir, "report.json"); await writeFile(reportFile, JSON.stringify(sanitize(report), null, 2) + "\n", { mode: 0o600 }); const meta = await fileMeta(reportFile); - const artifact = { kind: "realtime-fanout-report", path: reportFile, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true }; + const artifact = { kind: artifactKind, path: reportFile, byteCount: meta.byteCount, sha256: meta.sha256, commandId: activeCommandId, valuesRedacted: true }; await appendJsonl(files.artifacts, { ts: new Date().toISOString(), ...artifact }); return artifact; } diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts index 97930ee7..be413a64 100644 --- a/scripts/src/hwlab-node/entry.ts +++ b/scripts/src/hwlab-node/entry.ts @@ -141,6 +141,7 @@ export type NodeWebProbeObserveCommandType = | "newSession" | "sendPrompt" | "validateRealtimeFanout" + | "validateExistingSessionRefresh" | "validateWorkbenchKafkaDebugReplay" | "validateWorkbenchTraceReadability" | "steer" diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.ts b/scripts/src/hwlab-node/web-probe-observe-actions.ts index 9c29c723..d4a81b6e 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.ts @@ -753,6 +753,12 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption if (!options.commandText?.trim()) throw new Error("validateRealtimeFanout requires --text or --text-stdin for the first turn"); if (!options.commandSecondText?.trim()) throw new Error("validateRealtimeFanout requires --second-text for the follow-up turn"); } + if (type === "validateExistingSessionRefresh") { + if (!options.commandProfile?.trim()) throw new Error("validateExistingSessionRefresh requires --profile"); + if (spec.webProbe?.realtimeFanoutProfiles?.[options.commandProfile.trim()] === undefined) { + throw new Error(`validateExistingSessionRefresh profile is not declared by the owning YAML: ${options.commandProfile.trim()}`); + } + } if (type === "validateWorkbenchKafkaDebugReplay" && spec.webProbe?.workbenchKafkaDebugReplay?.enabled !== true) { throw new Error("validateWorkbenchKafkaDebugReplay is not enabled by the owning YAML"); } diff --git a/scripts/src/hwlab-node/web-probe-observe-collect.test.ts b/scripts/src/hwlab-node/web-probe-observe-collect.test.ts index 1c882939..17bdce2d 100644 --- a/scripts/src/hwlab-node/web-probe-observe-collect.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-collect.test.ts @@ -171,6 +171,19 @@ test("accepts the standard file collect envelope without requiring a summary vie expect(collect.file.byteCount).toBe(21885); }); +test("accepts the structured files listing envelope when --file is absent", () => { + const options = collectOptions(); + options.collectView = "files"; + const payload = buildNodeWebProbeObserveCollectPayload(options, { workspace: "/workspace" }, { + command: ["trans", "NC01:/workspace", "sh"], cwd: "/repo", exitCode: 0, + stdout: JSON.stringify({ ok: true, command: "web-probe-observe collect", stateDir: "/remote/state", mode: "list", files: [], valuesRedacted: true }), + stderr: "", signal: null, timedOut: false, + } satisfies CommandResult); + expect(payload.ok).toBe(true); + expect(payload.status).toBe("collected"); + expect((payload.collect as Record).mode).toBe("list"); +}); + function collectOptions(): NodeWebProbeObserveOptions { return { action: "observe", diff --git a/scripts/src/hwlab-node/web-probe-observe-collect.ts b/scripts/src/hwlab-node/web-probe-observe-collect.ts index 7c2f108b..b3f297d8 100644 --- a/scripts/src/hwlab-node/web-probe-observe-collect.ts +++ b/scripts/src/hwlab-node/web-probe-observe-collect.ts @@ -99,7 +99,7 @@ export function isWebObserveCollectJsonContract(value: Record, || stringOrNull(value.error) !== null; } return command === "web-probe-observe collect" - && (view !== null || mode === "file") + && (view !== null || mode === "file" || mode === "list") && stateDir !== null; } diff --git a/scripts/src/hwlab-node/web-probe-observe-options.test.ts b/scripts/src/hwlab-node/web-probe-observe-options.test.ts index a3864091..18d9f737 100644 --- a/scripts/src/hwlab-node/web-probe-observe-options.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-options.test.ts @@ -69,6 +69,17 @@ test("validateRealtimeFanout rejects profiles absent from the owning YAML", () = ); }); +test("validateExistingSessionRefresh parses an existing session without prompts", () => { + const options = parseNodeWebProbeObserveOptions( + ["command", "--type", "validateExistingSessionRefresh", "--profile", "pure-kafka-live", "--session-id", "ses_existing"], + "NC01", "v03", spec, "webobs-fixture", null, + ); + assert.equal(options.commandType, "validateExistingSessionRefresh"); + assert.equal(options.commandProfile, "pure-kafka-live"); + assert.equal(options.commandSessionId, "ses_existing"); + assert.equal(options.commandText, null); +}); + test("validateWorkbenchKafkaDebugReplay is a YAML-enabled argument-free typed command", () => { const options = parseNodeWebProbeObserveOptions( ["command", "--type", "validateWorkbenchKafkaDebugReplay"], diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts index 8933986f..50a66028 100644 --- a/scripts/src/hwlab-node/web-probe-observe.ts +++ b/scripts/src/hwlab-node/web-probe-observe.ts @@ -619,6 +619,12 @@ export function parseNodeWebProbeObserveOptions( if (!commandText?.trim()) throw new Error("validateRealtimeFanout requires --text or --text-stdin for the first turn"); if (!commandSecondText?.trim()) throw new Error("validateRealtimeFanout requires --second-text for the follow-up turn"); } + if (observeActionRaw === "command" && commandType === "validateExistingSessionRefresh") { + if (!commandProfile?.trim()) throw new Error("validateExistingSessionRefresh requires --profile"); + if (spec.webProbe?.realtimeFanoutProfiles?.[commandProfile.trim()] === undefined) { + throw new Error(`validateExistingSessionRefresh profile is not declared by the owning YAML: ${commandProfile.trim()}`); + } + } if (observeActionRaw === "command" && commandType === "validateWorkbenchKafkaDebugReplay") { if (spec.webProbe?.workbenchKafkaDebugReplay?.enabled !== true) { throw new Error("validateWorkbenchKafkaDebugReplay is not enabled by the owning YAML"); @@ -724,6 +730,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe || value === "newSession" || value === "sendPrompt" || value === "validateRealtimeFanout" + || value === "validateExistingSessionRefresh" || value === "validateWorkbenchKafkaDebugReplay" || value === "validateWorkbenchTraceReadability" || value === "steer" @@ -761,7 +768,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe || value === "mark" || value === "stop" ) return value; - throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, validateRealtimeFanout, validateWorkbenchKafkaDebugReplay, validateWorkbenchTraceReadability, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`); + throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, validateRealtimeFanout, validateExistingSessionRefresh, validateWorkbenchKafkaDebugReplay, validateWorkbenchTraceReadability, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, closeMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, performanceCapture, screenshot, mark, or stop; got ${value}`); } export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode { From 57348581c7f31b87a03effe3fbb4b12a1a69ca1b Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 17:06:07 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E6=97=A2=E6=9C=89?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=88=B7=E6=96=B0=E8=AF=81=E6=8D=AE=E5=90=88?= =?UTF-8?q?=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/hwlab-node-web-observe-render.ts | 79 ++++++++++++++++ ...web-observe-runner-realtime-source.test.ts | 66 ++++++++++++- ...node-web-observe-runner-realtime-source.ts | 87 +++++++++++++++++- .../src/hwlab-node/web-observe-render.test.ts | 92 +++++++++++++++++++ .../hwlab-node/web-observe-scripts.test.ts | 85 +++++++++++++++++ scripts/src/hwlab-node/web-observe-scripts.ts | 21 ++++- .../web-probe-observe-collect.test.ts | 16 +++- 7 files changed, 432 insertions(+), 14 deletions(-) diff --git a/scripts/src/hwlab-node-web-observe-render.ts b/scripts/src/hwlab-node-web-observe-render.ts index 31f8b7fc..8b825f8e 100644 --- a/scripts/src/hwlab-node-web-observe-render.ts +++ b/scripts/src/hwlab-node-web-observe-render.ts @@ -176,6 +176,14 @@ function renderWebObserveStatusTable(value: Record): string { ]]), "", ] : []), + ...(exactCommand.type === "validateExistingSessionRefresh" && exactReplayEvidence !== null ? [ + ...renderExistingSessionRefreshEvidence( + exactReplayEvidence, + exactResult?.reportSha256 ?? exactErrorDetails?.reportSha256, + record(exactResult?.screenshot ?? exactErrorDetails?.screenshot)?.sha256, + ), + "", + ] : []), ...(exactCommand.type === "validateWorkbenchKafkaDebugReplay" && exactReplayEvidence !== null ? [ ...renderWorkbenchKafkaDebugReplayEvidence( exactReplayEvidence, @@ -236,6 +244,69 @@ function renderWebObserveStatusTable(value: Record): string { return lines.join("\n"); } +function renderExistingSessionRefreshEvidence( + evidence: Record, + reportSha: unknown, + screenshotSha: unknown, +): string[] { + const before = record(evidence.before); + const after = record(evidence.after); + const replay = record(evidence.refreshReplay); + const counts = record(replay?.counts); + const product = record(evidence.productSse); + const failure = nullableRecord(evidence.failure); + const traceIds = Array.isArray(evidence.traceIds) ? evidence.traceIds.slice(0, 4).join(",") : ""; + const partitions = Array.isArray(replay?.topicPartitions) ? replay.topicPartitions.slice(0, 8).join(",") : ""; + const barrier = Array.isArray(replay?.barrier) + ? replay.barrier.slice(0, 8).map((item) => { + const row = record(item); + return `${webObserveText(row.partition)}:${webObserveText(row.endOffset)}`; + }).join(",") + : ""; + return [ + "Workbench 既有 Session 刷新:", + webObserveTable(["SESSION", "TRACE_IDS", "PHASE", "CODE", "MSG_BEFORE", "MSG_AFTER", "SOURCES", "OPEN", "CONNECTED", "BUSINESS", "FAILURES", "FORBIDDEN", "REPORT_SHA", "SCREENSHOT_SHA"], [[ + webObserveShort(webObserveText(evidence.sessionId), 32), + webObserveShort(webObserveText(traceIds), 40), + evidence.phase, + evidence.code, + before?.messageCount, + after?.messageCount, + product?.browserSourceCount, + product?.openCount, + product?.connectedEventCount, + product?.businessEventCount, + product?.typedFailureCount, + evidence.forbiddenRequestCount, + webObserveShort(webObserveText(reportSha), 32), + webObserveShort(webObserveText(screenshotSha), 32), + ]]), + "Kafka refresh handoff:", + webObserveTable(["TOPIC", "PARTITIONS", "BARRIER", "MATCHED", "REPLAYED", "BUFFERED", "BUFFERED_DELIVERED", "LIVE_DELIVERED", "DEDUPLICATED", "LIVE"], [[ + webObserveShort(webObserveText(replay?.topic), 32), + webObserveShort(webObserveText(partitions), 24), + webObserveShort(webObserveText(barrier), 56), + counts?.matched, + counts?.replayed, + counts?.buffered, + counts?.bufferedDelivered, + counts?.liveDelivered, + counts?.deduplicated, + product?.livePhase, + ]]), + ...(failure !== null ? [ + "Typed failure:", + webObserveTable(["PHASE", "CODE", "STREAM_PHASE", "STREAM_CODE", "MESSAGE"], [[ + failure.phase, + failure.code, + failure.streamPhase, + failure.streamCode, + webObserveShort(webObserveText(failure.message), 96), + ]]), + ] : []), + ]; +} + function renderWorkbenchKafkaDebugReplayEvidence( evidence: Record, reportSha: unknown, @@ -391,6 +462,14 @@ function renderWebObserveCommandTable(value: Record): string { replayScreenshot?.sha256 ?? resultScreenshot?.sha256, ), ] : []), + ...(observerCommand?.type === "validateExistingSessionRefresh" && replayEvidence !== null ? [ + "", + ...renderExistingSessionRefreshEvidence( + replayEvidence, + result?.reportSha256 ?? details?.reportSha256, + replayScreenshot?.sha256 ?? resultScreenshot?.sha256, + ), + ] : []), ...(observerCommand?.type === "validateWorkbenchTraceReadability" && replayEvidence !== null ? [ "", ...renderWorkbenchTraceReadabilityEvidence( diff --git a/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts b/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts index 85e3ce9d..f1082eff 100644 --- a/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts +++ b/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts @@ -187,15 +187,73 @@ test("existing session refresh rejects duplicate or reordered Workbench identiti test("existing session refresh failures preserve typed phase and code", () => { const source = nodeWebObserveRunnerRealtimeSource(); - const build = new Function(`function errorSummary(error) { return { message: error.message }; }\n${source}\nreturn realtimeExistingRefreshFailure;`) as () => ( - phase: string, error: Error, - ) => Record; - const failure = build()("retention-barrier", new Error("connected evidence timed out")); + const build = new Function(`function errorSummary(error) { return { message: error.message }; }\n${source}\nreturn { realtimeExistingRefreshFailure, realtimeExistingRefreshStreamError };`) as () => { + realtimeExistingRefreshFailure: (phase: string, error: Error & { details?: Record }) => Record; + realtimeExistingRefreshStreamError: (value: Record) => Error & { details?: Record }; + }; + const helpers = build(); + const error = helpers.realtimeExistingRefreshStreamError({ + phase: "flushing", + error: { code: "workbench_kafka_refresh_barrier_gap", message: "Kafka retention barrier gap", retryable: true }, + fallback: false, + }); + const failure = helpers.realtimeExistingRefreshFailure("retention-barrier", error); assert.equal(failure.phase, "retention-barrier"); assert.equal(failure.code, "retention_barrier_failed"); + assert.equal(failure.streamPhase, "flushing"); + assert.equal(failure.streamCode, "workbench_kafka_refresh_barrier_gap"); + assert.equal((failure.streamFailure as Record).fallback, false); assert.deepEqual(failure.valuesRedacted, true); }); +test("existing session refresh preserves rejected connected evidence and rejects missing browser replay delivery", () => { + const source = nodeWebObserveRunnerRealtimeSource(); + const build = new Function( + "sanitize", + `${source}\nreturn { realtimeExistingRefreshConnectedEvidence, realtimeAssertExistingRefreshDelivery };`, + ) as (sanitize: (value: T) => T) => { + realtimeExistingRefreshConnectedEvidence: (connected: Record, sessionId: string, profile: Record) => Record; + realtimeAssertExistingRefreshDelivery: (browser: Record, refreshReplay: Record) => void; + }; + const helpers = build((value) => value); + const sessionId = "ses_existing"; + const refreshReplay = { + phase: "live", + topic: "hwlab.event.v1", + topicPartitions: [0], + barrier: [{ partition: 0, endOffset: "18" }], + counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 }, + }; + const profile = { + expectedKafka: { topics: { hwlab: "hwlab.event.v1" }, capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true } }, + expectedProductSse: { deliverySemantics: "kafka-retention-then-live", liveOnly: false, replay: true, replaySupported: true, lossPossible: false, refreshHandoff: true }, + }; + const connected = { + realtimeSource: "hwlab.event.v1", + deliverySemantics: "invalid", + liveOnly: false, + replay: true, + replaySupported: true, + lossPossible: false, + filters: { sessionId, traceId: null }, + capabilities: profile.expectedKafka.capabilities, + refreshReplay, + }; + assert.throws( + () => helpers.realtimeExistingRefreshConnectedEvidence(connected, sessionId, profile), + (error: Error & { details?: Record }) => { + assert.deepEqual(error.details?.refreshReplay?.barrier, refreshReplay.barrier); + assert.equal(error.details?.refreshReplay?.counts?.replayed, 8); + return true; + }, + ); + assert.doesNotThrow(() => helpers.realtimeAssertExistingRefreshDelivery({ businessEventCount: 9 }, refreshReplay)); + assert.throws( + () => helpers.realtimeAssertExistingRefreshDelivery({ businessEventCount: 8 }, refreshReplay), + /fewer than replayed plus buffered delivery 9/u, + ); +}); + test("realtime fanout proves one formal user event through AgentRun, HWLAB Kafka, and product SSE", () => { const source = nodeWebObserveRunnerRealtimeSource(); const build = new Function(`${source}\nreturn realtimeValidateTurnEvidence;`) as () => (input: Record) => void; diff --git a/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts b/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts index fb635630..ffd82a98 100644 --- a/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts @@ -390,9 +390,26 @@ async function validateExistingSessionRefresh(command) { const settled = await waitForWorkbenchSessionHydrated(page, sessionId, { timeoutMs: effective.barrierTimeoutMs }); if (settled.ok !== true) throw new Error("existing session did not hydrate after refresh"); phase = "retention-barrier"; - const connected = await realtimePoll(async () => page.evaluate(() => window.__unideskExistingSessionRefresh?.connected?.at(-1) || null), effective.barrierTimeoutMs, "existing session refresh connected evidence"); - realtimeAssertConnectedContract(connected, sessionId, "existing-session-refresh", effective, { requireRetainedEvents: true }); - refreshReplay = realtimeConnectedSummary(connected).refreshReplay; + const refreshState = await realtimePoll(async () => page.evaluate(() => { + const state = window.__unideskExistingSessionRefresh; + const streamFailure = state?.failures?.at(-1) || null; + if (streamFailure) return { streamFailure }; + const connected = state?.connected?.at(-1) || null; + return connected ? { connected } : null; + }), effective.barrierTimeoutMs, "existing session refresh connected evidence"); + if (refreshState.streamFailure) throw realtimeExistingRefreshStreamError(refreshState.streamFailure); + const connected = refreshState.connected; + let connectedSummary = null; + try { + const connectedEvidence = realtimeExistingRefreshConnectedEvidence(connected, sessionId, effective); + connectedSummary = connectedEvidence.connected; + refreshReplay = connectedEvidence.refreshReplay; + } catch (error) { + connectedSummary = error?.details?.connected || null; + refreshReplay = error?.details?.refreshReplay || null; + if (connectedSummary) report.connected = connectedSummary; + throw error; + } phase = "live-handoff"; await realtimeWaitForSameWorkbenchDom(before, effective.barrierTimeoutMs); await sleep(effective.reconnectQuietMs); @@ -402,7 +419,9 @@ async function validateExistingSessionRefresh(command) { if (browserEvidence.sources.length !== 1) throw new Error("main Workbench product EventSource count is " + browserEvidence.sources.length + ", expected 1"); if (browserEvidence.sources[0]?.opened !== 1) throw new Error("main Workbench product EventSource open count is not 1"); if (browserEvidence.connected.length !== 1) throw new Error("main Workbench connected event count is " + browserEvidence.connected.length + ", expected 1"); + if (browserEvidence.failures.length !== 0) throw realtimeExistingRefreshStreamError(browserEvidence.failures.at(-1)); if (browserEvidence.errors.length !== 0) throw new Error("main Workbench product EventSource observed an error"); + realtimeAssertExistingRefreshDelivery(browserEvidence, refreshReplay); const mainStreams = network.filter((item) => item.path === effective.productEventsPath && item.sessionId === sessionId); const forbidden = network.filter((item) => realtimeForbiddenRequest(item, effective.forbiddenRequestPaths)); if (mainStreams.length !== 1) throw new Error("main Workbench session SSE request count is " + mainStreams.length + ", expected 1"); @@ -420,6 +439,7 @@ async function validateExistingSessionRefresh(command) { openCount: browserEvidence.sources[0]?.opened || 0, connectedEventCount: browserEvidence.connected.length, businessEventCount: browserEvidence.businessEventCount, + typedFailureCount: browserEvidence.failures.length, livePhase: refreshReplay?.phase === "live", valuesRedacted: true, }; @@ -430,7 +450,7 @@ async function validateExistingSessionRefresh(command) { code: "refresh_live_handoff_validated", completedAt: new Date().toISOString(), after, - connected: realtimeConnectedSummary(connected), + connected: connectedSummary, refreshReplay, productSse, forbiddenRequests: { count: 0, rows: [], valuesRedacted: true }, @@ -500,6 +520,7 @@ async function validateExistingSessionRefresh(command) { traceIds: report.after?.traceIds || report.before?.traceIds || [], phase: failure.phase, code: failure.code, + failure, before: report.before ? realtimeWorkbenchDomSummary(report.before) : null, after: report.after ? realtimeWorkbenchDomSummary(report.after) : null, refreshReplay, @@ -523,14 +544,67 @@ function realtimeExistingRefreshFailure(phase, error) { "live-handoff": "live_handoff_failed", "evidence-write": "evidence_write_failed", }; + const streamFailure = realtimeExistingRefreshStreamFailure(error?.details?.streamFailure); return { phase, code: codes[phase] || "existing_session_refresh_failed", + streamPhase: streamFailure?.phase || null, + streamCode: streamFailure?.code || null, + streamFailure, error: errorSummary(error), valuesRedacted: true, }; } +function realtimeExistingRefreshConnectedEvidence(connected, sessionId, profile) { + const connectedSummary = realtimeConnectedSummary(connected); + const refreshReplay = connectedSummary.refreshReplay; + try { + realtimeAssertConnectedContract(connected, sessionId, "existing-session-refresh", profile, { requireRetainedEvents: true }); + } catch (error) { + const wrapped = error instanceof Error ? error : new Error(String(error)); + wrapped.details = { ...(wrapped.details || {}), connected: connectedSummary, refreshReplay, valuesRedacted: true }; + throw wrapped; + } + return { connected: connectedSummary, refreshReplay, valuesRedacted: true }; +} + +function realtimeAssertExistingRefreshDelivery(browserEvidence, refreshReplay) { + const businessEventCount = Number(browserEvidence?.businessEventCount); + const replayed = Number(refreshReplay?.counts?.replayed); + const bufferedDelivered = Number(refreshReplay?.counts?.bufferedDelivered); + if (![businessEventCount, replayed, bufferedDelivered].every((value) => Number.isInteger(value) && value >= 0)) { + throw new Error("existing session refresh delivery counts are invalid"); + } + const minimumDelivered = replayed + bufferedDelivered; + if (businessEventCount < minimumDelivered) { + throw new Error("existing session refresh browser observed " + businessEventCount + " business events, fewer than replayed plus buffered delivery " + minimumDelivered); + } +} + +function realtimeExistingRefreshStreamError(value) { + const streamFailure = realtimeExistingRefreshStreamFailure(value); + const error = new Error(streamFailure?.message || "existing session refresh stream reported a typed failure"); + error.details = { streamFailure, valuesRedacted: true }; + return error; +} + +function realtimeExistingRefreshStreamFailure(value) { + const nested = value?.error && typeof value.error === "object" ? value.error : value; + const phase = typeof value?.phase === "string" ? value.phase : typeof nested?.phase === "string" ? nested.phase : null; + const code = typeof nested?.code === "string" ? nested.code : typeof value?.code === "string" ? value.code : null; + const message = typeof nested?.message === "string" ? nested.message.slice(0, 240) : typeof value?.message === "string" ? value.message.slice(0, 240) : null; + if (!phase && !code && !message) return null; + return { + phase, + code, + message, + retryable: nested?.retryable === true, + fallback: value?.fallback === true, + valuesRedacted: true, + }; +} + async function realtimeArmExistingSessionRefreshProbe(targetPage, profile) { await targetPage.addInitScript(() => { const markerKey = "__unideskExistingSessionRefreshCapture"; @@ -540,7 +614,7 @@ async function realtimeArmExistingSessionRefreshProbe(targetPage, profile) { let config; try { config = JSON.parse(raw); } catch { return; } const NativeEventSource = window.EventSource; - const state = { sources: [], connected: [], businessEventCount: 0, errors: [] }; + const state = { sources: [], connected: [], businessEventCount: 0, failures: [], errors: [] }; window.__unideskExistingSessionRefresh = state; if (typeof NativeEventSource !== "function") { state.errors.push({ kind: "eventsource-unavailable" }); @@ -559,6 +633,9 @@ async function realtimeArmExistingSessionRefreshProbe(targetPage, profile) { try { state.connected.push(JSON.parse(event.data)); } catch { state.errors.push({ kind: "connected-parse" }); } }); this.addEventListener(config.businessEvent, () => { state.businessEventCount += 1; }); + this.addEventListener("workbench.error", (event) => { + try { state.failures.push(JSON.parse(event.data)); } catch { state.errors.push({ kind: "workbench-error-parse" }); } + }); this.addEventListener("error", () => { state.errors.push({ kind: "eventsource-error" }); }); } } diff --git a/scripts/src/hwlab-node/web-observe-render.test.ts b/scripts/src/hwlab-node/web-observe-render.test.ts index b69e2077..ed3d35ad 100644 --- a/scripts/src/hwlab-node/web-observe-render.test.ts +++ b/scripts/src/hwlab-node/web-observe-render.test.ts @@ -66,6 +66,98 @@ test("observe status renderer exposes the exact command phase and failed report assert.match(text, /terminal event arrived before subscriber disconnect/u); }); +test("observe status renderer exposes existing-session refresh handoff and typed failure evidence", () => { + const base = { + ok: true, + status: "running", + command: "web-probe observe status", + id: "webobs-fixture", + node: "NC01", + lane: "v03", + next: {}, + }; + const rendered = withWebObserveStatusRendered({ + ...base, + observer: { + processAlive: true, + manifest: {}, + heartbeat: {}, + diagnostics: {}, + commands: {}, + exactCommand: { + commandId: "cmd-existing-refresh", + type: "validateExistingSessionRefresh", + phase: "done", + ok: true, + result: { + status: "passed", + sessionId: "ses_existing", + traceIds: ["trc_existing"], + phase: "live", + code: "refresh_live_handoff_validated", + before: { messageCount: 2 }, + after: { messageCount: 2 }, + refreshReplay: { + phase: "live", + topic: "hwlab.event.v1", + topicPartitions: [0], + barrier: [{ partition: 0, endOffset: "18" }], + counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 }, + }, + productSse: { browserSourceCount: 1, openCount: 1, connectedEventCount: 1, businessEventCount: 9, typedFailureCount: 0, livePhase: true }, + forbiddenRequestCount: 0, + reportSha256: "sha256:existing-refresh-report", + screenshot: { sha256: "sha256:existing-refresh-image" }, + }, + }, + tails: { samples: [], control: [], network: [] }, + }, + }); + const text = String(rendered.renderedText); + assert.match(text, /Workbench 既有 Session 刷新:/u); + assert.match(text, /ses_existing.*trc_existing.*live.*refresh_live_handoff_validated.*2.*2.*1.*1.*1.*9.*0.*0/u); + assert.match(text, /hwlab\.event\.v1.*0.*0:18.*8.*8.*1.*1.*0.*0.*true/u); + assert.match(text, /sha256:existing-refresh-report.*sha256:existing-refresh-image/u); + + const failed = withWebObserveStatusRendered({ + ...base, + observer: { + processAlive: true, + manifest: {}, + heartbeat: {}, + diagnostics: {}, + commands: {}, + exactCommand: { + commandId: "cmd-existing-refresh-failed", + type: "validateExistingSessionRefresh", + phase: "failed", + ok: false, + error: { + message: "Kafka retention barrier gap", + details: { + sessionId: "ses_existing", + traceIds: ["trc_existing"], + phase: "retention-barrier", + code: "retention_barrier_failed", + failure: { + phase: "retention-barrier", + code: "retention_barrier_failed", + streamPhase: "flushing", + streamCode: "workbench_kafka_refresh_barrier_gap", + message: "Kafka retention barrier gap", + }, + reportSha256: "sha256:failed-existing-refresh-report", + }, + }, + }, + tails: { samples: [], control: [], network: [] }, + }, + }); + const failedText = String(failed.renderedText); + assert.match(failedText, /Typed failure:/u); + assert.match(failedText, /retention-barrier.*retention_barrier_failed.*flushing.*workbench_kafka_refresh_barrier_gap.*Kafka retention barrier gap/u); +}); + test("observe status renderer exposes bounded Workbench Kafka debug replay evidence", () => { const rendered = withWebObserveStatusRendered({ ok: true, diff --git a/scripts/src/hwlab-node/web-observe-scripts.test.ts b/scripts/src/hwlab-node/web-observe-scripts.test.ts index 9c501864..78e69a86 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.test.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.test.ts @@ -58,6 +58,91 @@ test("observe status returns one exact completed command without prompt payloads assert.equal(status.exactCommand.valuesRedacted, true); }); +test("observe status preserves bounded existing-session refresh evidence", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-existing-refresh-status-")); + const commandId = "cmd-existing-refresh"; + await mkdir(join(stateDir, "commands", "done"), { recursive: true }); + await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({ + ok: true, + commandId, + type: "validateExistingSessionRefresh", + completedAt: "2026-07-11T12:00:00.000Z", + result: { + ok: true, + status: "passed", + profile: "pure-kafka-live", + sessionId: "ses_existing", + traceIds: ["trc_existing"], + phase: "live", + code: "refresh_live_handoff_validated", + before: { messageCount: 2, identities: ["must-not-project"], missingIdentityRows: [], duplicateIdentities: [], traceIds: ["trc_existing"] }, + after: { messageCount: 2, identities: ["must-not-project"], missingIdentityRows: [], duplicateIdentities: [], traceIds: ["trc_existing"] }, + refreshReplay: { + phase: "live", + topic: "hwlab.event.v1", + topicPartitions: [0], + barrier: [{ partition: 0, endOffset: "18" }], + counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 }, + }, + productSse: { requestCount: 1, browserSourceCount: 1, openCount: 1, connectedEventCount: 1, businessEventCount: 9, typedFailureCount: 0, livePhase: true }, + forbiddenRequestCount: 0, + reportPath: "/tmp/existing-refresh-report.json", + reportSha256: "sha256:existing-refresh-report", + screenshot: { path: "/tmp/existing-refresh.png", sha256: "sha256:existing-refresh-image" }, + }, + }) + "\n"); + + const status = await runStatusScript(stateDir, commandId); + const result = status.exactCommand.result; + assert.equal(result.phase, "live"); + assert.equal(result.code, "refresh_live_handoff_validated"); + assert.equal(result.before.messageCount, 2); + assert.equal(result.before.identities, undefined); + assert.deepEqual(result.refreshReplay.barrier, [{ partition: 0, endOffset: "18" }]); + assert.equal(result.refreshReplay.counts.replayed, 8); + assert.equal(result.productSse.typedFailureCount, 0); + assert.equal(result.reportSha256, "sha256:existing-refresh-report"); + assert.equal(result.screenshot.sha256, "sha256:existing-refresh-image"); +}); + +test("observe status preserves typed existing-session refresh stream failures", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-existing-refresh-failure-")); + const commandId = "cmd-existing-refresh-failed"; + await mkdir(join(stateDir, "commands", "failed"), { recursive: true }); + await writeFile(join(stateDir, "commands", "failed", `${commandId}.json`), JSON.stringify({ + ok: false, + commandId, + type: "validateExistingSessionRefresh", + failedAt: "2026-07-11T12:00:00.000Z", + error: { + message: "Kafka retention barrier gap", + details: { + sessionId: "ses_existing", + traceIds: ["trc_existing"], + phase: "retention-barrier", + code: "retention_barrier_failed", + failure: { + phase: "retention-barrier", + code: "retention_barrier_failed", + streamPhase: "flushing", + streamCode: "workbench_kafka_refresh_barrier_gap", + streamFailure: { phase: "flushing", code: "workbench_kafka_refresh_barrier_gap", message: "Kafka retention barrier gap" }, + }, + reportSha256: "sha256:failed-existing-refresh-report", + }, + }, + }) + "\n"); + + const status = await runStatusScript(stateDir, commandId); + const details = status.exactCommand.error.details; + assert.equal(details.phase, "retention-barrier"); + assert.equal(details.code, "retention_barrier_failed"); + assert.equal(details.failure.streamPhase, "flushing"); + assert.equal(details.failure.streamCode, "workbench_kafka_refresh_barrier_gap"); + assert.equal(details.failure.message, "Kafka retention barrier gap"); + assert.deepEqual(details.traceIds, ["trc_existing"]); +}); + test("observe status keeps exact command not-found structured", async () => { const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-exact-missing-")); const status = await runStatusScript(stateDir, "cmd-missing"); diff --git a/scripts/src/hwlab-node/web-observe-scripts.ts b/scripts/src/hwlab-node/web-observe-scripts.ts index 6569d2e9..961b6129 100644 --- a/scripts/src/hwlab-node/web-observe-scripts.ts +++ b/scripts/src/hwlab-node/web-observe-scripts.ts @@ -79,6 +79,23 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, rawHwlabEventWindow:raw?{available:raw.available??null,contract:raw.contract||null,reason:raw.reason||null,samePage:raw.samePage??null,productEventsPath:raw.productEventsPath||null,productEventSourceRequestCount:raw.productEventSourceRequestCount??null,sourceContractPresent:raw.sourceContractPresent??null,unfilteredContractPresent:raw.unfilteredContractPresent??null,textBytes:raw.textBytes??null,textHash:raw.textHash||null,textPresent:raw.textPresent??null,counts:rawCounts?{received:rawCounts.received??null,retained:rawCounts.retained??null,decoded:rawCounts.decoded??null,rejected:rawCounts.rejected??null,evicted:rawCounts.evicted??null,bytes:rawCounts.bytes??null}:null,valuesRedacted:true}:null }; }; + const compactExistingSessionRefreshEvidence=(value)=>{ + const item=value&&typeof value==='object'?value:null;if(!item)return{}; + const replay=item.refreshReplay&&typeof item.refreshReplay==='object'?item.refreshReplay:null; + const counts=replay&&replay.counts&&typeof replay.counts==='object'?replay.counts:null; + const product=item.productSse&&typeof item.productSse==='object'?item.productSse:null; + const before=item.before&&typeof item.before==='object'?item.before:null; + const after=item.after&&typeof item.after==='object'?item.after:null; + const failure=item.failure&&typeof item.failure==='object'?item.failure:null; + const streamFailure=failure&&failure.streamFailure&&typeof failure.streamFailure==='object'?failure.streamFailure:item.streamFailure&&typeof item.streamFailure==='object'?item.streamFailure:null; + const compactDom=(dom)=>dom?{messageCount:dom.messageCount??null,traceIds:Array.isArray(dom.traceIds)?dom.traceIds.slice(0,4):[],missingIdentityCount:Array.isArray(dom.missingIdentityRows)?dom.missingIdentityRows.length:null,duplicateIdentityCount:Array.isArray(dom.duplicateIdentities)?dom.duplicateIdentities.length:null,valuesRedacted:true}:null; + return{ + before:compactDom(before),after:compactDom(after), + refreshReplay:replay?{phase:replay.phase||null,topic:replay.topic||null,topicPartitions:Array.isArray(replay.topicPartitions)?replay.topicPartitions.slice(0,8):[],barrier:Array.isArray(replay.barrier)?replay.barrier.slice(0,8).map((entry)=>({partition:entry?.partition??null,endOffset:entry?.endOffset||null})):[],counts:counts?{matched:counts.matched??null,replayed:counts.replayed??null,buffered:counts.buffered??null,bufferedDelivered:counts.bufferedDelivered??null,liveDelivered:counts.liveDelivered??null,deduplicated:counts.deduplicated??null}:null,valuesRedacted:true}:null, + productSse:product?{requestCount:product.requestCount??null,browserSourceCount:product.browserSourceCount??null,openCount:product.openCount??null,connectedEventCount:product.connectedEventCount??null,businessEventCount:product.businessEventCount??null,typedFailureCount:product.typedFailureCount??null,livePhase:product.livePhase??null,valuesRedacted:true}:null, + failure:failure||streamFailure?{phase:failure&&failure.phase||item.phase||null,code:failure&&failure.code||item.code||null,streamPhase:failure&&failure.streamPhase||streamFailure&&streamFailure.phase||null,streamCode:failure&&failure.streamCode||streamFailure&&streamFailure.code||null,message:short(streamFailure&&streamFailure.message||failure&&failure.error&&failure.error.message||'',200),valuesRedacted:true}:null + }; + }; const compactTraceSnapshot=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return null;const disclosure=item.disclosure&&typeof item.disclosure==='object'?item.disclosure:null;return{status:item.status||null,traceId:item.traceId||null,disclosure:disclosure?{available:disclosure.available??null,open:disclosure.open??null,eventCount:disclosure.eventCount??null,readableRowCount:disclosure.readableRowCount??null,renderedRowCount:disclosure.renderedRowCount??null,rowWindowed:disclosure.rowWindowed??null}:null,rowCount:item.rowCount??null,readableRowCount:item.readableRowCount??null,sourceAuthorityRowCount:item.sourceAuthorityRowCount??null,readableSourceRowCount:item.readableSourceRowCount??null,projectedAuthorityRowCount:item.projectedAuthorityRowCount??null,rowSetHash:item.rowSetHash||null,valuesRedacted:true};}; const compactTracePhase=(value)=>{const item=value&&typeof value==='object'?value:null;return item?{observed:item.observed??null,stable:item.stable??null,reason:item.reason||null,status:item.status||null,elapsedMs:item.elapsedMs??null,sampleCount:item.sampleCount??null,snapshot:compactTraceSnapshot(item.snapshot),valuesRedacted:true}:null;}; const compactTraceReadabilityEvidence=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return{};const scope=item.scope&&typeof item.scope==='object'?item.scope:null;const disclosure=item.disclosure&&typeof item.disclosure==='object'?item.disclosure:null;const retention=item.runningRetention&&typeof item.runningRetention==='object'?item.runningRetention:null;return{startedDuringRunning:item.startedDuringRunning??null,forcedExpansion:item.forcedExpansion??null,scope:scope?{selector:short(scope.selector),conversationCount:scope.conversationCount??null,productCardCount:scope.productCardCount??null,debugPanelCountInsideConversation:scope.debugPanelCountInsideConversation??null,isolatedDebugExcluded:scope.isolatedDebugExcluded??null,valuesRedacted:true}:null,disclosure:disclosure?{openBefore:disclosure.openBefore??null,openAfterExpansion:disclosure.openAfterExpansion??null,openAfterTerminal:disclosure.openAfterTerminal??null,autoReadable:disclosure.autoReadable??null,valuesRedacted:true}:null,running:compactTracePhase(item.running),terminalArrival:compactTracePhase(item.terminalArrival),terminal:compactTracePhase(item.terminal),runningRetention:retention?{mode:retention.mode||null,passed:retention.passed??null,retainedRowCount:retention.retainedRowCount??null,runningEventCount:retention.runningEventCount??null,terminalEventCount:retention.terminalEventCount??null,runningSourceSeqMin:retention.runningSourceSeqMin??null,runningSourceSeqMax:retention.runningSourceSeqMax??null,terminalSourceSeqMin:retention.terminalSourceSeqMin??null,terminalSourceSeqMax:retention.terminalSourceSeqMax??null,valuesRedacted:true}:null,retainedRunningRowCount:item.retainedRunningRowCount??null,failures:Array.isArray(item.failures)?item.failures.slice(0,8).map((failure)=>({code:failure&&failure.code||null,message:short(failure&&failure.message),valuesRedacted:true})):null};}; @@ -97,7 +114,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string, if(!parsed)continue; const result=parsed.result&&typeof parsed.result==='object'?parsed.result:null; const error=parsed.error&&typeof parsed.error==='object'?parsed.error:null; - return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,...compactReplayEvidence(error.details),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),valuesRedacted:true}:null}:null,valuesRedacted:true}; + return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(result):{}),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,...compactReplayEvidence(error.details),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(error.details):{}),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,traceIds:Array.isArray(error.details.traceIds)?error.details.traceIds.slice(0,4):[],topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,forbiddenRequestCount:error.details.forbiddenRequestCount??null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),valuesRedacted:true}:null}:null,valuesRedacted:true}; } return {commandId:exactCommandId,phase:'not-found',ok:false,valuesRedacted:true}; }; @@ -396,7 +413,7 @@ walk(dir); const selectedFiles=out.slice(0,maxFiles); const files=selectedFiles.slice(0,24).map(file=>{const st=fs.statSync(file); return {relative:path.relative(dir,file),byteCount:st.size,sha256:shaFile(file)}}); const totalBytes=selectedFiles.reduce((sum,file)=>sum+fs.statSync(file).size,0); -console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,fileCount:selectedFiles.length,listedFileCount:files.length,omittedFileCount:Math.max(0,selectedFiles.length-files.length),totalBytes,files,valuesRedacted:true})); +console.log(JSON.stringify({ok:true,command:'web-probe-observe collect',stateDir:dir,mode:'list',view:'files',fileCount:selectedFiles.length,listedFileCount:files.length,omittedFileCount:Math.max(0,selectedFiles.length-files.length),totalBytes,files,valuesRedacted:true})); `)} "$state_dir" ${shellQuote(collectFile ?? "")} ${shellQuote(collectFinding ?? "")} ${shellQuote(collectGrep ?? "")}`; } diff --git a/scripts/src/hwlab-node/web-probe-observe-collect.test.ts b/scripts/src/hwlab-node/web-probe-observe-collect.test.ts index 17bdce2d..d5d85de3 100644 --- a/scripts/src/hwlab-node/web-probe-observe-collect.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-collect.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { describe, expect, test } from "bun:test"; @@ -6,6 +6,7 @@ import type { CommandResult } from "../command"; import type { NodeWebProbeObserveOptions } from "./entry"; import { buildNodeWebProbeObserveCollectPayload } from "./web-probe-observe-collect"; import { withWebObserveCollectRendered } from "../hwlab-node-web-observe-render"; +import { nodeWebObserveCollectNodeScript } from "./web-observe-scripts"; describe("web-probe observe collect child JSON recovery", () => { test("recovers collect JSON from trans stdout dump", () => { @@ -174,14 +175,23 @@ test("accepts the standard file collect envelope without requiring a summary vie test("accepts the structured files listing envelope when --file is absent", () => { const options = collectOptions(); options.collectView = "files"; + const stateDir = mkdtempSync(join(tmpdir(), "unidesk-web-observe-files-list-")); + writeFileSync(join(stateDir, "manifest.json"), "{}\n"); + const child = Bun.spawnSync(["bash", "-lc", nodeWebObserveCollectNodeScript(options.maxFiles, null, null, null)], { + env: { ...process.env, state_dir: stateDir }, + stdout: "pipe", + stderr: "pipe", + }); + expect(child.exitCode).toBe(0); const payload = buildNodeWebProbeObserveCollectPayload(options, { workspace: "/workspace" }, { command: ["trans", "NC01:/workspace", "sh"], cwd: "/repo", exitCode: 0, - stdout: JSON.stringify({ ok: true, command: "web-probe-observe collect", stateDir: "/remote/state", mode: "list", files: [], valuesRedacted: true }), - stderr: "", signal: null, timedOut: false, + stdout: Buffer.from(child.stdout).toString("utf8"), + stderr: Buffer.from(child.stderr).toString("utf8"), signal: null, timedOut: false, } satisfies CommandResult); expect(payload.ok).toBe(true); expect(payload.status).toBe("collected"); expect((payload.collect as Record).mode).toBe("list"); + expect((payload.collect as Record).view).toBe("files"); }); function collectOptions(): NodeWebProbeObserveOptions {