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 {