227 lines
8.3 KiB
TypeScript
227 lines
8.3 KiB
TypeScript
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 { classifyQuickVerifyCleanupStep, quickVerifyCleanupFindings, readAnalysisSummaryFromWorkspace, sentinelServiceProxyInvocation } 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,
|
|
valuesRedacted: true,
|
|
});
|
|
const latest = service.report("summary", null);
|
|
assert.equal(recorded.ok, true);
|
|
assert.equal(latest.ok, true);
|
|
assert.equal((latest.run as Record<string, unknown>).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,
|
|
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,
|
|
valuesRedacted: true,
|
|
});
|
|
|
|
const latest = service.report("summary", null);
|
|
assert.equal(latest.ok, true);
|
|
assert.equal((latest.run as Record<string, unknown>).id, "sentinel-run-new-stored-finding");
|
|
assert.equal((latest.run as Record<string, unknown>).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<string, unknown>): Record<string, unknown> {
|
|
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);
|
|
});
|