276 lines
9.7 KiB
TypeScript
276 lines
9.7 KiB
TypeScript
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { describe, expect, test } from "bun:test";
|
|
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", () => {
|
|
const dir = join(tmpdir(), `unidesk-collect-recovery-${Date.now()}-${process.pid}`);
|
|
mkdirSync(dir, { recursive: true });
|
|
const dumpPath = join(dir, "stdout.json");
|
|
writeFileSync(dumpPath, JSON.stringify({
|
|
ok: true,
|
|
command: "web-probe-observe collect",
|
|
view: "performance-summary",
|
|
stateDir: "/remote/state",
|
|
renderedText: "WEB PERFORMANCE SUMMARY\npayloads=2 events=3 groups=1",
|
|
valuesRedacted: true,
|
|
}));
|
|
const summary = {
|
|
stdout: {
|
|
stream: "stdout",
|
|
truncated: true,
|
|
dumpPath,
|
|
valuesRedacted: true,
|
|
},
|
|
valuesRedacted: true,
|
|
};
|
|
const payload = buildNodeWebProbeObserveCollectPayload(collectOptions(), { workspace: "/workspace" }, {
|
|
command: ["trans", "JD01:/workspace", "sh"],
|
|
cwd: "/repo",
|
|
exitCode: 0,
|
|
stdout: "tail only WEB PERFORMANCE SUMMARY",
|
|
stderr: `UNIDESK_SSH_TRUNCATION_SUMMARY ${JSON.stringify(summary)}\n`,
|
|
signal: null,
|
|
timedOut: false,
|
|
} satisfies CommandResult);
|
|
expect(payload.ok).toBe(true);
|
|
expect(payload.status).toBe("collected");
|
|
const collect = payload.collect as Record<string, unknown>;
|
|
expect(collect.view).toBe("performance-summary");
|
|
expect(collect.renderedText).toContain("payloads=2");
|
|
const recovery = payload.stdoutRecovery as Record<string, unknown>;
|
|
expect(recovery.stdoutKind).toBe("ssh-truncation-summary");
|
|
expect(recovery.dumpPath).toBe(dumpPath);
|
|
expect(recovery.source).toBe("dump");
|
|
});
|
|
});
|
|
|
|
test("propagates structured turn-summary not-found status without treating it as collected", () => {
|
|
const options = collectOptions();
|
|
options.collectView = "turn-summary";
|
|
options.collectCommandId = "cmd-missing";
|
|
const payload = buildNodeWebProbeObserveCollectPayload(options, { workspace: "/workspace" }, {
|
|
command: ["trans", "JD01:/workspace", "sh"],
|
|
cwd: "/repo",
|
|
exitCode: 0,
|
|
stdout: JSON.stringify({
|
|
ok: false,
|
|
command: "web-probe-observe collect",
|
|
view: "turn-summary",
|
|
status: "not-found",
|
|
stateDir: "/remote/state",
|
|
filter: { mode: "exact-command-id", requestedCommandId: "cmd-missing", matchedCount: 0 },
|
|
error: { code: "turn-summary-command-not-found" },
|
|
rows: [],
|
|
renderedText: "NOT_FOUND turn-summary-command-not-found; unrelated turns were not returned",
|
|
valuesRedacted: true,
|
|
}),
|
|
stderr: "",
|
|
signal: null,
|
|
timedOut: false,
|
|
} satisfies CommandResult);
|
|
expect(payload.ok).toBe(false);
|
|
expect(payload.status).toBe("not-found");
|
|
const collect = payload.collect as Record<string, unknown>;
|
|
expect(collect.status).toBe("not-found");
|
|
expect(collect.rows).toEqual([]);
|
|
});
|
|
|
|
test("collects an exact failed command artifact without treating artifact failure as collect failure", () => {
|
|
const options = collectOptions();
|
|
options.collectView = "files";
|
|
options.collectFile = "commands/failed/cmd-fixture.json";
|
|
const failedArtifact = {
|
|
ok: false,
|
|
commandId: "cmd-fixture",
|
|
type: "validateRealtimeFanout",
|
|
failedAt: "2026-07-10T12:00:00.000Z",
|
|
error: {
|
|
name: "Error",
|
|
message: "terminal event arrived before subscriber disconnect",
|
|
details: {
|
|
reportPath: "artifacts/realtime-fanout/cmd-fixture/report.json",
|
|
reportSha256: "sha256:fixture-report",
|
|
sessionToken: "must-not-render",
|
|
},
|
|
},
|
|
failureSample: { ok: true, sampleSeq: 22, valuesRedacted: true },
|
|
};
|
|
const payload = buildNodeWebProbeObserveCollectPayload(options, { workspace: "/workspace" }, {
|
|
command: ["trans", "NC01:/workspace", "sh"],
|
|
cwd: "/repo",
|
|
exitCode: 0,
|
|
stdout: JSON.stringify(failedArtifact),
|
|
stderr: "",
|
|
signal: null,
|
|
timedOut: false,
|
|
} satisfies CommandResult);
|
|
|
|
expect(payload.ok).toBe(true);
|
|
expect(payload.status).toBe("collected");
|
|
expect(payload.degradedReason).toBeNull();
|
|
const recovery = payload.stdoutRecovery as Record<string, unknown>;
|
|
expect(recovery.source).toBe("stdout");
|
|
expect(recovery.stdoutContractAccepted).toBe(true);
|
|
const collect = payload.collect as Record<string, any>;
|
|
expect(collect.ok).toBe(true);
|
|
expect(collect.artifactOutcome).toEqual({
|
|
ok: false,
|
|
commandId: "cmd-fixture",
|
|
type: "validateRealtimeFanout",
|
|
bucket: "failed",
|
|
valuesRedacted: true,
|
|
});
|
|
expect(collect.file.jsonContent.commandId).toBe("cmd-fixture");
|
|
expect(collect.file.jsonContent.error.message).toBe("terminal event arrived before subscriber disconnect");
|
|
expect(collect.file.jsonContent.error.details.sessionToken).toBe("[redacted]");
|
|
|
|
const rendered = withWebObserveCollectRendered(payload);
|
|
expect(String(rendered.renderedText)).toContain("cmd-fixture");
|
|
expect(String(rendered.renderedText)).toContain("terminal event arrived before subscriber disconnect");
|
|
});
|
|
|
|
test("accepts the standard file collect envelope without requiring a summary view", () => {
|
|
const options = collectOptions();
|
|
options.collectView = "files";
|
|
options.collectFile = "commands/failed/cmd-fixture.json";
|
|
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: "file",
|
|
requestedFile: options.collectFile,
|
|
resolvedFile: options.collectFile,
|
|
file: {
|
|
relative: options.collectFile,
|
|
byteCount: 21885,
|
|
sha256: "sha256:fixture",
|
|
jsonSummary: { topLevelKeys: ["ok", "commandId", "type", "failedAt", "error"] },
|
|
},
|
|
valuesRedacted: true,
|
|
}),
|
|
stderr: "",
|
|
signal: null,
|
|
timedOut: false,
|
|
} satisfies CommandResult);
|
|
|
|
expect(payload.ok).toBe(true);
|
|
expect(payload.status).toBe("collected");
|
|
expect(payload.degradedReason).toBeNull();
|
|
const collect = payload.collect as Record<string, any>;
|
|
expect(collect.mode).toBe("file");
|
|
expect(collect.file.byteCount).toBe(21885);
|
|
});
|
|
|
|
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: 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<string, unknown>).mode).toBe("list");
|
|
expect((payload.collect as Record<string, unknown>).view).toBe("files");
|
|
});
|
|
|
|
function collectOptions(): NodeWebProbeObserveOptions {
|
|
return {
|
|
action: "observe",
|
|
observeAction: "collect",
|
|
id: "webobs-test",
|
|
node: "JD01",
|
|
lane: "v03",
|
|
url: "https://example.test",
|
|
targetPath: "/",
|
|
viewport: "1920x1080",
|
|
browserProxyMode: "auto",
|
|
sampleIntervalMs: 1000,
|
|
screenshotIntervalMs: 0,
|
|
observerRefreshIntervalMs: 1000,
|
|
maxSamples: 10,
|
|
maxRunSeconds: 60,
|
|
commandTimeoutSeconds: 30,
|
|
waitMs: 0,
|
|
tailLines: 20,
|
|
maxFiles: 100,
|
|
gcKeepHours: 24,
|
|
gcLimit: 10,
|
|
confirm: false,
|
|
dryRun: false,
|
|
collectView: "performance-summary",
|
|
collectFile: null,
|
|
collectFinding: null,
|
|
collectGrep: null,
|
|
collectTraceId: null,
|
|
collectSampleSeq: null,
|
|
collectTimestamp: null,
|
|
collectTurn: null,
|
|
collectCommandId: null,
|
|
collectWindowMs: null,
|
|
analyzeArchivePrefix: null,
|
|
analyzeTailSamples: null,
|
|
full: false,
|
|
raw: false,
|
|
compactRaw: false,
|
|
stateDir: null,
|
|
jobId: null,
|
|
force: false,
|
|
commandType: null,
|
|
commandText: null,
|
|
commandSecondText: null,
|
|
commandPath: null,
|
|
commandLabel: null,
|
|
commandSessionId: null,
|
|
commandProvider: null,
|
|
commandProfile: null,
|
|
commandAfterRound: null,
|
|
commandSeverity: null,
|
|
commandAlternateSessionStrategy: null,
|
|
commandExpectedSentinelRange: null,
|
|
commandExpectedActionWaitMs: null,
|
|
commandDurationMs: null,
|
|
commandRequireComposerReady: false,
|
|
commandWaitProjectManagementReady: false,
|
|
commandFindingId: null,
|
|
commandBlocking: null,
|
|
commandAccountId: null,
|
|
commandFromAccountId: null,
|
|
commandToAccountId: null,
|
|
commandSourceId: null,
|
|
commandFileRef: null,
|
|
commandFilename: null,
|
|
commandTaskRef: null,
|
|
commandTaskId: null,
|
|
commandField: null,
|
|
commandLink: null,
|
|
commandTitle: null,
|
|
commandBody: null,
|
|
commandStatus: null,
|
|
commandHwpodId: null,
|
|
commandNodeId: null,
|
|
commandWorkspaceRoot: null,
|
|
commandRoot: null,
|
|
};
|
|
}
|