import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "bun:test"; import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes"; import { nodeWebObserveRunnerSource } from "./hwlab-node-web-observe-runner-source"; import { classifyQuickVerifyCleanupStep, createQuickVerifyObserverLifecycle, quickVerifyCleanupFindings, quickVerifyUnhandledExceptionSummary, readAnalysisSummaryFromWorkspace, sentinelChildStartMemorySkip, sentinelServiceProxyInvocation, stopQuickVerifyObserverAfterPartialStart, } from "./hwlab-node-web-sentinel-p5-observe"; import { createWebProbeSentinelService } from "./hwlab-node-web-sentinel-service"; test("sentinel service proxy executes its script through trans standard input", () => { assert.deepEqual(sentinelServiceProxyInvocation("NC01:k3s", "echo sentinel-service-proxy"), { argv: ["trans", "NC01:k3s", "sh"], input: "echo sentinel-service-proxy", }); }); test("workspace analysis fallback maps report generatedAt to reportUpdatedAt", () => { const directory = mkdtempSync(join(tmpdir(), "unidesk-workspace-analysis-")); try { const stateDir = ".state/web-observe/webobs-fixture-20260712T000000Z"; mkdirSync(join(directory, stateDir, "analysis"), { recursive: true }); writeFileSync(join(directory, stateDir, "analysis", "report.json"), JSON.stringify({ generatedAt: "2026-07-12T00:00:00.000Z", findings: [] })); const summary = readAnalysisSummaryFromWorkspace({} as never, stateDir, 5, (script) => { const result = spawnSync("sh", { cwd: directory, input: script, encoding: "utf8" }); return { command: ["sh"], cwd: directory, exitCode: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "", signal: result.signal, timedOut: false, }; }); assert.equal(summary.ok, true, JSON.stringify(summary)); assert.equal(summary.reportUpdatedAt, "2026-07-12T00:00:00.000Z", JSON.stringify(summary)); } finally { rmSync(directory, { recursive: true, force: true }); } }); test("recorded sentinel run is available through the latest report index", () => { const stateRoot = mkdtempSync(join(tmpdir(), "unidesk-sentinel-index-")); const service = createWebProbeSentinelService({ spec: hwlabRuntimeLaneSpecForNode("v03", "NC01"), sentinelId: "nc01-web-probe-sentinel", stateRootOverride: stateRoot, schedulerEnabled: false, }); try { const scenarioId = String(service.config.scenarios[0]?.id); const recorded = service.recordRun({ runId: "sentinel-run-index-test", scenarioId, status: "succeeded", reportJsonSha256: "sha256:index-test", summary: { source: "test", valuesRedacted: true }, views: { summary: { renderedText: "indexed summary" } }, findingCount: 0, artifactCount: 0, updatedAt: "2026-07-13T00:00:00.000Z", valuesRedacted: true, }); const latest = service.report("summary", null); assert.equal(recorded.ok, true); assert.equal(latest.ok, true); assert.equal((latest.run as Record).id, "sentinel-run-index-test"); } finally { service.close(); rmSync(stateRoot, { recursive: true, force: true }); } }); test("latest report selects the newest stored run without a report SHA", () => { const stateRoot = mkdtempSync(join(tmpdir(), "unidesk-sentinel-index-")); const service = createWebProbeSentinelService({ spec: hwlabRuntimeLaneSpecForNode("v03", "NC01"), sentinelId: "nc01-web-probe-sentinel", stateRootOverride: stateRoot, schedulerEnabled: false, }); try { const scenarioId = String(service.config.scenarios[0]?.id); service.recordRun({ runId: "sentinel-run-old-artifact", scenarioId, status: "succeeded", reportJsonSha256: "sha256:old-artifact", summary: { source: "test", valuesRedacted: true }, views: { summary: { renderedText: "old indexed summary" } }, findingCount: 0, artifactCount: 0, updatedAt: "2026-07-13T00:00:00.000Z", valuesRedacted: true, }); service.recordRun({ runId: "sentinel-run-new-stored-finding", scenarioId, status: "blocked", summary: { source: "test", valuesRedacted: true }, views: { summary: { renderedText: "new stored summary" } }, findings: [{ id: "quick-verify-observer-start-failed", severity: "error", summary: "observer start failed" }], findingCount: 1, artifactCount: 0, updatedAt: "2026-07-13T00:00:01.000Z", valuesRedacted: true, }); const latest = service.report("summary", null); assert.equal(latest.ok, true); assert.equal((latest.run as Record).id, "sentinel-run-new-stored-finding"); assert.equal((latest.run as Record).reportJsonSha256, null); assert.match(String(latest.renderedText), /run=sentinel-run-new-stored-finding/u); assert.match(String(latest.renderedText), /页面观察未启动/u); } finally { service.close(); rmSync(stateRoot, { recursive: true, force: true }); } }); function forceStopStep(observer: Record): Record { return { phase: "observe-stop-after-terminal", ok: false, payload: { ok: false, status: "blocked", forceReason: "graceful-stop-not-consumed", observer: { command: "web-probe-observe force-stop", ...observer, }, valuesRedacted: true, }, valuesRedacted: true, }; } test("quick verify cleanup downgrades abandoned graceful stop when force stop confirms not alive", () => { const step = forceStopStep({ aliveBefore: true, aliveAfter: false, termSent: true, killSent: true, pendingAbandoned: 1, processingAbandoned: 0, }); const cleanup = classifyQuickVerifyCleanupStep(step); assert.equal(cleanup.cleanupClass, "bounded-force-stop"); assert.equal(cleanup.gracefulStopOk, false); assert.equal(cleanup.forceStopOk, true); assert.equal(cleanup.aliveAfter, false); assert.equal(cleanup.pendingAbandoned, 1); const findings = quickVerifyCleanupFindings(step); assert.equal(findings.length, 1); assert.equal(findings[0]?.id, "observer-graceful-stop-timeout-force-stop-succeeded"); assert.equal(findings[0]?.severity, "warning"); }); test("quick verify cleanup reads force-stop evidence from top-level CLI payload", () => { const step = { phase: "observe-stop-after-terminal", ok: false, payload: { ok: true, command: "web-probe-observe force-stop", forced: true, reason: "graceful-stop-not-consumed", aliveBefore: true, aliveAfter: false, termSent: true, killSent: true, pendingAbandoned: 1, processingAbandoned: 0, valuesRedacted: true, }, valuesRedacted: true, }; const cleanup = classifyQuickVerifyCleanupStep(step); const findings = quickVerifyCleanupFindings(step); assert.equal(cleanup.cleanupClass, "bounded-force-stop"); assert.equal(cleanup.forceStopOk, true); assert.equal(cleanup.aliveAfter, false); assert.deepEqual(cleanup.evidenceSources, ["payload"]); assert.equal(findings[0]?.id, "observer-graceful-stop-timeout-force-stop-succeeded"); }); test("quick verify cleanup emits process-alive code when force stop leaves observer alive", () => { const step = forceStopStep({ aliveBefore: true, aliveAfter: true, termSent: true, killSent: false, pendingAbandoned: 0, processingAbandoned: 1, }); const cleanup = classifyQuickVerifyCleanupStep(step); const findings = quickVerifyCleanupFindings(step); assert.equal(cleanup.cleanupClass, "observer-stop-failed"); assert.equal(cleanup.forceStopOk, false); assert.equal(cleanup.aliveAfter, true); assert.equal(findings.length, 1); assert.equal(findings[0]?.id, "observer-force-stop-left-process-alive"); assert.match(String(findings[0]?.evidenceSummary), /aliveAfter=true/u); }); test("quick verify cleanup emits evidence-gap code when stopped state cannot be confirmed", () => { const step = forceStopStep({ aliveBefore: true, termSent: true, killSent: true, pendingAbandoned: 1, processingAbandoned: 0, }); const cleanup = classifyQuickVerifyCleanupStep(step); const findings = quickVerifyCleanupFindings(step); assert.equal(cleanup.cleanupClass, "observer-stop-failed"); assert.equal(cleanup.aliveAfter, null); assert.deepEqual(cleanup.missingEvidenceFields, ["aliveAfter"]); assert.equal(findings.length, 1); assert.equal(findings[0]?.id, "observer-cleanup-evidence-missing"); assert.match(String(findings[0]?.evidenceSummary), /missing=aliveAfter/u); }); test("quick verify observer lifecycle stops once on success, failure, and exception fallback", () => { const cases = [ { name: "success", initialPhase: "observe-stop-after-terminal", expectedPhase: "observe-stop-after-terminal" }, { name: "failure", initialPhase: "observe-stop-after-failure", expectedPhase: "observe-stop-after-failure" }, { name: "exception-fallback", initialPhase: null, expectedPhase: "observe-stop-after-finally" }, ] as const; for (const scenario of cases) { const calls: { observerId: string; phase: string }[] = []; const lifecycle = createQuickVerifyObserverLifecycle("webobs-fixture", (observerId, phase) => { calls.push({ observerId, phase }); return { phase, ok: true, observerId }; }); if (scenario.initialPhase !== null) lifecycle.stop(scenario.initialPhase); const cleanupStep = lifecycle.stop("observe-stop-after-finally"); assert.deepEqual(calls, [{ observerId: "webobs-fixture", phase: scenario.expectedPhase }], scenario.name); assert.equal(cleanupStep.phase, scenario.expectedPhase, scenario.name); assert.strictEqual(cleanupStep, lifecycle.cleanupStep, scenario.name); } }); test("quick verify observer lifecycle retries a failed stop again from the bounded finally fallback", () => { let calls = 0; const lifecycle = createQuickVerifyObserverLifecycle("webobs-stop-throws", () => { calls += 1; throw new Error("sensitive-stop-error"); }); const first = lifecycle.stop("observe-stop-after-failure"); const fallback = lifecycle.stop("observe-stop-after-finally"); assert.equal(calls, 4); assert.notStrictEqual(first, fallback); assert.equal(first.failure, "observer-stop-unhandled-exception"); assert.equal(Array.isArray(fallback.stopAttempts), true); assert.equal((fallback.stopAttempts as unknown[]).length, 4); assert.equal(JSON.stringify(first).includes("sensitive-stop-error"), false); }); test("quick verify observer lifecycle retries a failed force-stop and caches the successful fallback", () => { let calls = 0; const lifecycle = createQuickVerifyObserverLifecycle("webobs-stop-retry", (_observerId, phase) => { calls += 1; return { phase, ok: calls === 2, observerId: "webobs-stop-retry" }; }); const first = lifecycle.stop("observe-stop-after-failure"); const cached = lifecycle.stop("observe-stop-after-finally"); assert.equal(calls, 2); assert.equal(first.ok, true); assert.strictEqual(first, cached); assert.equal(Array.isArray(first.stopAttempts), true); }); test("quick verify cleans a partial start only when start output contains an observer id", () => { const calls: { observerId: string; phase: string }[] = []; const stopObserver = (observerId: string, phase: string) => { calls.push({ observerId, phase }); return { phase, ok: true, observerId }; }; assert.equal(stopQuickVerifyObserverAfterPartialStart(null, stopObserver), null); const cleanupStep = stopQuickVerifyObserverAfterPartialStart("webobs-partial", stopObserver); assert.deepEqual(calls, [{ observerId: "webobs-partial", phase: "observe-stop-after-start-failure" }]); assert.equal(cleanupStep?.ok, true); }); test("quick verify unexpected exception evidence is bounded and redacted", () => { const secret = "sensitive-upstream-message"; const summary = quickVerifyUnhandledExceptionSummary(new Error(secret)); const serialized = JSON.stringify(summary); assert.equal(summary.errorName, "Error"); assert.match(String(summary.errorMessageSha256), /^[a-f0-9]{64}$/u); assert.equal(summary.errorMessageTruncated, false); assert.equal(serialized.includes(secret), false); }); test("sentinel preserves a child launch-time memory race as skipped instead of observe-start-failed", () => { const skip = sentinelChildStartMemorySkip({ ok: true, command: "web-probe observe start", data: { ok: true, status: "skipped-wait-next-round", observerCreated: false, memoryGuard: { mode: "sentinel-cadence", status: "skipped", memory: { metric: "MemAvailable", source: "/proc/meminfo", swapExcluded: true, availableBytes: 4 * 1024 ** 3 - 1, thresholdBytes: 4 * 1024 ** 3, comparator: "less-than", }, }, }, }); assert.equal(skip?.status, "skipped-wait-next-round"); assert.equal(skip?.decision, "skip-observer"); assert.equal(skip?.observerCreated, false); assert.equal((skip?.memoryGuard as Record).mode, "sentinel-cadence"); assert.equal(sentinelChildStartMemorySkip({ data: { status: "blocked", observerCreated: false } }), null); }); test("web observe runner records finite lifecycle policy and closes the browser", () => { const source = nodeWebObserveRunnerSource(); assert.match(source, /sampling:\s*\{[^}]*maxSamples,[^}]*maxRunMs,[^}]*maxRunSeconds:/u); assert.match(source, /observerLifecycle:\s*\{[^}]*maxSamples,[^}]*maxRunMs,[^}]*maxRunSeconds:/u); assert.match(source, /if \(maxRunMs > 0 && Date\.now\(\) - startedAtMs >= maxRunMs\)/u); assert.match(source, /const maxScreenshots = requiredBoundedInteger\(process\.env\.UNIDESK_WEB_OBSERVE_MAX_SCREENSHOTS/u); assert.match(source, /const maxPerformanceEntries = requiredBoundedInteger\(process\.env\.UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_ENTRIES/u); assert.match(source, /const maxRealtimeEventBodyBytes = requiredBoundedInteger\(process\.env\.UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_BODY_BYTES/u); assert.match(source, /while \(browserProcessHistory\.length > maxBrowserProcessHistoryEntries\)/u); assert.match(source, /while \(browserFreezeSignalHistory\.length > maxBrowserFreezeSignalHistoryEntries\)/u); assert.match(source, /if \(screenshotCount >= maxScreenshots\)/u); assert.match(source, /const maxBytes = responseBodyMaxBytes/u); assert.match(source, /function responseBodyReadContract\(response, maxBytes = responseBodyMaxBytes\)/u); assert.match(source, /contentEncoding[\s\S]*?reason: "encoded-body-size-unbounded"/u); assert.match(source, /!Number\.isFinite\(contentLength\)[\s\S]*?reason: "content-length-unavailable"/u); assert.match(source, /bodyReadSkipped: body\.error\?\.reason \|\| "bounded-response-read-failed"/u); assert.match(source, /\.slice\(0, maxProcessScanEntries\)/u); assert.match(source, /if \(items\.length > maxEntries\)[\s\S]*?items\.splice/u); assert.match(source, /process\.once\(signal,[\s\S]*?closeBrowserResources\("signal:" \+ signal\)/u); assert.match(source, /finally \{[\s\S]*?await closeBrowserResources/u); assert.match(source, /closingObserverPage\.close\(\)[\s\S]*?closingPage\.close\(\)[\s\S]*?closingContext\.close\(\)[\s\S]*?closingBrowser\.close\(\)/u); const directory = mkdtempSync(join(tmpdir(), "web-observe-runner-check-")); try { const runner = join(directory, "observer-runner.mjs"); writeFileSync(runner, source); const syntax = spawnSync("node", ["--check", runner], { encoding: "utf8" }); assert.equal(syntax.status, 0, syntax.stderr); } finally { rmSync(directory, { recursive: true, force: true }); } });