mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
test(qa): cover support export and stability (#118956)
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
title: Gateway stability runtime and bundle
|
||||
|
||||
scenario:
|
||||
id: gateway-stability-runtime
|
||||
surface: observability
|
||||
category: observability.diagnostic-collection
|
||||
coverage:
|
||||
primary:
|
||||
- observability.bounded-in-process-stability-recorder
|
||||
- observability.openclaw-gateway-stability
|
||||
- observability.openclaw-gateway-stability-bundle
|
||||
objective: Prove the live Gateway stability RPC, bounded recorder, persisted bundle filtering, and export-from-bundle CLI paths as one operator workflow.
|
||||
successCriteria:
|
||||
- A real child CLI reads the live Gateway diagnostics.stability RPC through authenticated WebSocket transport.
|
||||
- The in-process recorder retains only its fixed capacity and reports dropped records after sustained diagnostic load.
|
||||
- The CLI reads and filters the newest persisted stability bundle without retaining private event identifiers.
|
||||
- Export-from-bundle writes a shareable zip containing the sanitized stability snapshot.
|
||||
docsRefs:
|
||||
- docs/gateway/diagnostics.md
|
||||
- docs/cli/gateway.md
|
||||
- docs/gateway/protocol.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- src/cli/gateway-cli/register.ts
|
||||
- src/gateway/server-methods/diagnostics.ts
|
||||
- src/logging/diagnostic-stability.ts
|
||||
- src/logging/diagnostic-stability-bundle.ts
|
||||
- test/e2e/qa-lab/runtime/gateway-stability-runtime.ts
|
||||
- test/e2e/qa-lab/runtime/gateway-stability-runtime.test.ts
|
||||
execution:
|
||||
kind: script
|
||||
path: test/e2e/qa-lab/runtime/gateway-stability-runtime.ts
|
||||
summary: Exercise live stability RPC, bounded retention, persisted bundle filtering, and support export through the real child CLI.
|
||||
args:
|
||||
- --artifact-base
|
||||
- ${outputDir}
|
||||
timeoutMs: 240000
|
||||
@@ -0,0 +1,37 @@
|
||||
title: Gateway support diagnostics export
|
||||
|
||||
scenario:
|
||||
id: gateway-support-export
|
||||
surface: observability
|
||||
category: observability.diagnostic-collection
|
||||
coverage:
|
||||
primary:
|
||||
- cli.diagnostics-export
|
||||
- cli.support-safe-redaction
|
||||
- observability.openclaw-gateway-diagnostics-export
|
||||
- observability.support-zip-redaction
|
||||
- observability.support-zip-artifact-safety
|
||||
objective: Prove the real Gateway diagnostics CLI writes a private, payload-free support zip with bounded log collection and sanitized runtime snapshots.
|
||||
successCriteria:
|
||||
- A real child CLI exports diagnostics while connected to an isolated live Gateway.
|
||||
- The JSON result reports the requested zip path, nonzero size, and a payload-free privacy manifest.
|
||||
- The zip contains sanitized config, logs, status, health, diagnostics, and summary entries without Gateway or hook credentials.
|
||||
- The artifact uses private file permissions and every archive entry stays inside the zip root.
|
||||
docsRefs:
|
||||
- docs/gateway/diagnostics.md
|
||||
- docs/cli/gateway.md
|
||||
- docs/concepts/qa-e2e-automation.md
|
||||
codeRefs:
|
||||
- src/cli/gateway-cli/register.ts
|
||||
- src/logging/diagnostic-support-export.ts
|
||||
- src/logging/diagnostic-support-bundle.ts
|
||||
- test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts
|
||||
- test/e2e/qa-lab/runtime/gateway-support-export-runtime.test.ts
|
||||
execution:
|
||||
kind: script
|
||||
path: test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts
|
||||
summary: Run the real child CLI against a live isolated Gateway and inspect the resulting support zip privacy and artifact-safety contract.
|
||||
args:
|
||||
- --artifact-base
|
||||
- ${outputDir}
|
||||
timeoutMs: 240000
|
||||
@@ -0,0 +1,40 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
|
||||
import { runGatewayStabilityRuntime } from "./gateway-stability-runtime.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
describe("gateway stability runtime evidence", () => {
|
||||
it(
|
||||
"proves live RPC, bounded retention, bundle filtering, and export",
|
||||
{ timeout: 240_000 },
|
||||
async () => {
|
||||
const artifactBase = tempDirs.make("gateway-stability-runtime-");
|
||||
const evidence = await runGatewayStabilityRuntime({
|
||||
artifactBase,
|
||||
repoRoot: process.cwd(),
|
||||
});
|
||||
|
||||
const result = evidence.entries[0]?.result;
|
||||
expect(result?.status, result?.failure?.reason).toBe("pass");
|
||||
const summary = JSON.parse(
|
||||
await fs.readFile(path.join(artifactBase, "gateway-stability-summary.json"), "utf8"),
|
||||
) as {
|
||||
liveRpcCapacity: number;
|
||||
retainedCount: number;
|
||||
droppedCount: number;
|
||||
filteredEvents: number;
|
||||
bundleReason: string;
|
||||
supportBytes: number;
|
||||
};
|
||||
expect(summary.liveRpcCapacity).toBe(1000);
|
||||
expect(summary.retainedCount).toBe(1000);
|
||||
expect(summary.droppedCount).toBeGreaterThan(0);
|
||||
expect(summary.filteredEvents).toBe(3);
|
||||
expect(summary.bundleReason).toBe("qa_gateway_stability");
|
||||
expect(summary.supportBytes).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
// QA evidence for live stability RPC, bounded retention, bundles, and support export.
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import JSZip from "jszip";
|
||||
import {
|
||||
emitDiagnosticEvent,
|
||||
resetDiagnosticEventsForTest,
|
||||
} from "../../../../src/infra/diagnostic-events.js";
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import {
|
||||
resetDiagnosticStabilityBundleForTest,
|
||||
writeDiagnosticStabilityBundleSync,
|
||||
} from "../../../../src/logging/diagnostic-stability-bundle.js";
|
||||
import {
|
||||
getDiagnosticStabilitySnapshot,
|
||||
resetDiagnosticStabilityRecorderForTest,
|
||||
startDiagnosticStabilityRecorder,
|
||||
stopDiagnosticStabilityRecorder,
|
||||
} from "../../../../src/logging/diagnostic-stability.js";
|
||||
import {
|
||||
createOpenClawTestInstance,
|
||||
type OpenClawTestInstance,
|
||||
} from "../../../helpers/openclaw-test-instance.js";
|
||||
import { createQaScriptEvidenceWriter } from "./script-evidence.js";
|
||||
|
||||
const SOURCE_PATH = "test/e2e/qa-lab/runtime/gateway-stability-runtime.ts";
|
||||
const SCENARIO_ID = "gateway-stability-runtime";
|
||||
const SYNTHETIC_EVENT_COUNT = 1_205;
|
||||
const PRIVATE_CHAT_ID = "qa-private-stability-chat";
|
||||
|
||||
export type GatewayStabilityRuntimeOptions = {
|
||||
artifactBase: string;
|
||||
repoRoot: string;
|
||||
};
|
||||
|
||||
type StabilitySnapshot = {
|
||||
capacity: number;
|
||||
count: number;
|
||||
dropped: number;
|
||||
events: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
type StabilityBundleCliResult = {
|
||||
bundle: {
|
||||
reason: string;
|
||||
snapshot: StabilitySnapshot;
|
||||
};
|
||||
};
|
||||
|
||||
type SupportExportCliResult = {
|
||||
path: string;
|
||||
bytes: number;
|
||||
manifest: {
|
||||
privacy: {
|
||||
payloadFree: boolean;
|
||||
rawLogsIncluded: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type GatewayStabilitySummary = {
|
||||
liveRpcCapacity: number;
|
||||
liveRpcCount: number;
|
||||
retainedCount: number;
|
||||
droppedCount: number;
|
||||
filteredEvents: number;
|
||||
bundleReason: string;
|
||||
supportArchive: string;
|
||||
supportBytes: number;
|
||||
};
|
||||
|
||||
function parseOptions(argv: string[], repoRoot = process.cwd()): GatewayStabilityRuntimeOptions {
|
||||
let artifactBase: string | undefined;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const option = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (option !== "--artifact-base") {
|
||||
throw new Error(`unknown argument: ${option}`);
|
||||
}
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error("--artifact-base requires a value");
|
||||
}
|
||||
artifactBase = value;
|
||||
index += 1;
|
||||
}
|
||||
if (!artifactBase) {
|
||||
throw new Error("--artifact-base is required");
|
||||
}
|
||||
return { artifactBase: path.resolve(repoRoot, artifactBase), repoRoot };
|
||||
}
|
||||
|
||||
function parseCliJson<T>(
|
||||
label: string,
|
||||
result: Awaited<ReturnType<OpenClawTestInstance["cli"]>>,
|
||||
): T {
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`${label} failed with exit ${String(result.code)}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.stdout) as T;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${label} returned invalid JSON: ${formatErrorMessage(error)}\n${result.stdout}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readZipEntries(file: string): Promise<Record<string, string>> {
|
||||
const zip = await JSZip.loadAsync(await fs.readFile(file));
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [name, entry] of Object.entries(zip.files)) {
|
||||
if (!entry.dir) {
|
||||
entries[name] = await entry.async("string");
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function createWriter(options: GatewayStabilityRuntimeOptions) {
|
||||
return createQaScriptEvidenceWriter({
|
||||
artifactBase: options.artifactBase,
|
||||
logFileName: "gateway-stability-runtime.log",
|
||||
primaryModel: "gateway/diagnostics-stability",
|
||||
providerMode: "mock-openai",
|
||||
repoRoot: options.repoRoot,
|
||||
target: {
|
||||
id: SCENARIO_ID,
|
||||
title: "Gateway stability runtime and bundle",
|
||||
sourcePath: SOURCE_PATH,
|
||||
docsRefs: ["docs/gateway/diagnostics.md", "docs/cli/gateway.md"],
|
||||
codeRefs: [
|
||||
SOURCE_PATH,
|
||||
"src/gateway/server-methods/diagnostics.ts",
|
||||
"src/logging/diagnostic-stability.ts",
|
||||
"src/logging/diagnostic-stability-bundle.ts",
|
||||
"src/cli/gateway-cli/register.ts",
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resetStabilityState(): void {
|
||||
stopDiagnosticStabilityRecorder();
|
||||
resetDiagnosticStabilityRecorderForTest();
|
||||
resetDiagnosticEventsForTest();
|
||||
resetDiagnosticStabilityBundleForTest();
|
||||
}
|
||||
|
||||
function writeBoundedStabilityBundle(stateDir: string) {
|
||||
resetStabilityState();
|
||||
startDiagnosticStabilityRecorder();
|
||||
for (let index = 0; index < SYNTHETIC_EVENT_COUNT; index += 1) {
|
||||
emitDiagnosticEvent({
|
||||
type: "webhook.received",
|
||||
channel: "gateway",
|
||||
updateType: "qa-stability",
|
||||
chatId: `${PRIVATE_CHAT_ID}-${index}`,
|
||||
});
|
||||
}
|
||||
const snapshot = getDiagnosticStabilitySnapshot({ limit: 1000 });
|
||||
assert.equal(snapshot.capacity, 1000);
|
||||
assert.equal(snapshot.count, 1000);
|
||||
assert.equal(snapshot.events.length, 1000);
|
||||
assert.equal(snapshot.dropped, SYNTHETIC_EVENT_COUNT - snapshot.capacity);
|
||||
assert.equal(JSON.stringify(snapshot).includes(PRIVATE_CHAT_ID), false);
|
||||
|
||||
const result = writeDiagnosticStabilityBundleSync({
|
||||
reason: "qa_gateway_stability",
|
||||
stateDir,
|
||||
});
|
||||
if (result.status !== "written") {
|
||||
throw new Error(`expected stability bundle write, got ${result.status}`);
|
||||
}
|
||||
stopDiagnosticStabilityRecorder();
|
||||
resetDiagnosticStabilityRecorderForTest();
|
||||
resetDiagnosticEventsForTest();
|
||||
return { path: result.path, snapshot };
|
||||
}
|
||||
|
||||
export async function runGatewayStabilityRuntime(options: GatewayStabilityRuntimeOptions) {
|
||||
await fs.mkdir(options.artifactBase, { recursive: true });
|
||||
const writer = createWriter(options);
|
||||
const startedAt = Date.now();
|
||||
let instance: OpenClawTestInstance | undefined;
|
||||
try {
|
||||
instance = await createOpenClawTestInstance({
|
||||
name: "qa-gateway-stability",
|
||||
config: {
|
||||
diagnostics: { enabled: true },
|
||||
},
|
||||
});
|
||||
await instance.startGateway();
|
||||
|
||||
const liveSnapshot = parseCliJson<StabilitySnapshot>(
|
||||
"gateway stability live RPC",
|
||||
await instance.cli(
|
||||
[
|
||||
"gateway",
|
||||
"stability",
|
||||
"--limit",
|
||||
"10",
|
||||
"--url",
|
||||
instance.url,
|
||||
"--token",
|
||||
instance.gatewayToken,
|
||||
"--timeout",
|
||||
"10000",
|
||||
"--json",
|
||||
],
|
||||
{ timeoutMs: 120_000 },
|
||||
),
|
||||
);
|
||||
assert.equal(liveSnapshot.capacity, 1000);
|
||||
assert.ok(liveSnapshot.count <= liveSnapshot.capacity);
|
||||
assert.ok(liveSnapshot.events.length <= 10);
|
||||
|
||||
const bounded = writeBoundedStabilityBundle(instance.stateDir);
|
||||
const persistedArtifact = path.join(options.artifactBase, "gateway-stability-bundle.json");
|
||||
await fs.copyFile(bounded.path, persistedArtifact);
|
||||
|
||||
const bundleResult = parseCliJson<StabilityBundleCliResult>(
|
||||
"gateway stability persisted bundle",
|
||||
await instance.cli(
|
||||
[
|
||||
"gateway",
|
||||
"stability",
|
||||
"--bundle",
|
||||
"latest",
|
||||
"--limit",
|
||||
"3",
|
||||
"--type",
|
||||
"webhook.received",
|
||||
"--json",
|
||||
],
|
||||
{ timeoutMs: 120_000 },
|
||||
),
|
||||
);
|
||||
assert.equal(bundleResult.bundle.reason, "qa_gateway_stability");
|
||||
assert.equal(bundleResult.bundle.snapshot.capacity, 1000);
|
||||
assert.equal(bundleResult.bundle.snapshot.count, 1000);
|
||||
assert.equal(bundleResult.bundle.snapshot.events.length, 3);
|
||||
assert.equal(JSON.stringify(bundleResult).includes(PRIVATE_CHAT_ID), false);
|
||||
|
||||
const supportArchive = path.join(options.artifactBase, "gateway-stability-support.zip");
|
||||
const exportResult = parseCliJson<SupportExportCliResult>(
|
||||
"gateway stability export",
|
||||
await instance.cli(
|
||||
[
|
||||
"gateway",
|
||||
"stability",
|
||||
"--bundle",
|
||||
"latest",
|
||||
"--export",
|
||||
"--output",
|
||||
supportArchive,
|
||||
"--url",
|
||||
instance.url,
|
||||
"--token",
|
||||
instance.gatewayToken,
|
||||
"--timeout",
|
||||
"10000",
|
||||
"--json",
|
||||
],
|
||||
{ timeoutMs: 120_000 },
|
||||
),
|
||||
);
|
||||
assert.equal(path.resolve(exportResult.path), path.resolve(supportArchive));
|
||||
assert.ok(exportResult.bytes > 0);
|
||||
assert.equal(exportResult.manifest.privacy.payloadFree, true);
|
||||
assert.equal(exportResult.manifest.privacy.rawLogsIncluded, false);
|
||||
|
||||
const archiveEntries = await readZipEntries(supportArchive);
|
||||
assert.ok(archiveEntries["stability/latest.json"]);
|
||||
const archiveText = Object.values(archiveEntries).join("\n");
|
||||
assert.equal(archiveText.includes(PRIVATE_CHAT_ID), false);
|
||||
assert.equal(archiveText.includes(instance.gatewayToken), false);
|
||||
assert.equal(archiveText.includes(instance.stateDir), false);
|
||||
|
||||
const summary: GatewayStabilitySummary = {
|
||||
liveRpcCapacity: liveSnapshot.capacity,
|
||||
liveRpcCount: liveSnapshot.count,
|
||||
retainedCount: bounded.snapshot.count,
|
||||
droppedCount: bounded.snapshot.dropped,
|
||||
filteredEvents: bundleResult.bundle.snapshot.events.length,
|
||||
bundleReason: bundleResult.bundle.reason,
|
||||
supportArchive: path.basename(supportArchive),
|
||||
supportBytes: exportResult.bytes,
|
||||
};
|
||||
const summaryPath = path.join(options.artifactBase, "gateway-stability-summary.json");
|
||||
await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
||||
writer.appendLog(
|
||||
`gateway-stability: live=${liveSnapshot.count}/${liveSnapshot.capacity} retained=${bounded.snapshot.count} dropped=${bounded.snapshot.dropped}\n`,
|
||||
);
|
||||
return await writer.write({
|
||||
artifacts: [
|
||||
{ kind: "summary", filePath: summaryPath },
|
||||
{ kind: "summary", filePath: persistedArtifact },
|
||||
{ kind: "archive", filePath: supportArchive },
|
||||
],
|
||||
details: `live RPC capacity=${liveSnapshot.capacity}; retained=${bounded.snapshot.count}; dropped=${bounded.snapshot.dropped}; bundle export passed`,
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "pass",
|
||||
});
|
||||
} catch (error) {
|
||||
const details = formatErrorMessage(error);
|
||||
writer.appendLog(`gateway-stability: ${details}\n`);
|
||||
return await writer.write({
|
||||
details,
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "fail",
|
||||
});
|
||||
} finally {
|
||||
resetStabilityState();
|
||||
await instance?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
runGatewayStabilityRuntime(parseOptions(process.argv.slice(2)))
|
||||
.then((evidence) => {
|
||||
const status = evidence.entries[0]?.result.status;
|
||||
process.stdout.write(`gateway-stability-runtime: ${status}\n`);
|
||||
process.exitCode = status === "pass" ? 0 : 1;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
process.stderr.write(`gateway-stability-runtime: ${formatErrorMessage(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../helpers/temp-dir.js";
|
||||
import { runGatewaySupportExportRuntime } from "./gateway-support-export-runtime.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
describe("gateway support export runtime evidence", () => {
|
||||
it("runs the real CLI and writes a private payload-free zip", { timeout: 240_000 }, async () => {
|
||||
const artifactBase = tempDirs.make("gateway-support-export-");
|
||||
const evidence = await runGatewaySupportExportRuntime({
|
||||
artifactBase,
|
||||
repoRoot: process.cwd(),
|
||||
});
|
||||
|
||||
const result = evidence.entries[0]?.result;
|
||||
expect(result?.status, result?.failure?.reason).toBe("pass");
|
||||
const summary = JSON.parse(
|
||||
await fs.readFile(path.join(artifactBase, "gateway-support-export-summary.json"), "utf8"),
|
||||
) as {
|
||||
bytes: number;
|
||||
entries: string[];
|
||||
payloadFree: boolean;
|
||||
privateMode: boolean;
|
||||
rawLogsIncluded: boolean;
|
||||
};
|
||||
expect(summary).toMatchObject({
|
||||
payloadFree: true,
|
||||
privateMode: true,
|
||||
rawLogsIncluded: false,
|
||||
});
|
||||
expect(summary.bytes).toBeGreaterThan(0);
|
||||
expect(summary.entries).toContain("health/gateway-health.json");
|
||||
expect(summary.entries).toContain("status/gateway-status.json");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
// QA evidence for the real Gateway support-export CLI and zip privacy boundary.
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import JSZip from "jszip";
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import {
|
||||
createOpenClawTestInstance,
|
||||
type OpenClawTestInstance,
|
||||
} from "../../../helpers/openclaw-test-instance.js";
|
||||
import { createQaScriptEvidenceWriter } from "./script-evidence.js";
|
||||
|
||||
const SOURCE_PATH = "test/e2e/qa-lab/runtime/gateway-support-export-runtime.ts";
|
||||
const SCENARIO_ID = "gateway-support-export";
|
||||
|
||||
export type GatewaySupportExportRuntimeOptions = {
|
||||
artifactBase: string;
|
||||
repoRoot: string;
|
||||
};
|
||||
|
||||
type DiagnosticsExportResult = {
|
||||
path: string;
|
||||
bytes: number;
|
||||
manifest: {
|
||||
contents: Array<{ path: string; bytes: number; mediaType: string }>;
|
||||
privacy: {
|
||||
payloadFree: boolean;
|
||||
rawLogsIncluded: boolean;
|
||||
notes: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type GatewaySupportExportSummary = {
|
||||
archive: string;
|
||||
bytes: number;
|
||||
entries: string[];
|
||||
payloadFree: boolean;
|
||||
privateMode: boolean;
|
||||
rawLogsIncluded: boolean;
|
||||
};
|
||||
|
||||
function parseOptions(
|
||||
argv: string[],
|
||||
repoRoot = process.cwd(),
|
||||
): GatewaySupportExportRuntimeOptions {
|
||||
let artifactBase: string | undefined;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const option = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if (option !== "--artifact-base") {
|
||||
throw new Error(`unknown argument: ${option}`);
|
||||
}
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error("--artifact-base requires a value");
|
||||
}
|
||||
artifactBase = value;
|
||||
index += 1;
|
||||
}
|
||||
if (!artifactBase) {
|
||||
throw new Error("--artifact-base is required");
|
||||
}
|
||||
return { artifactBase: path.resolve(repoRoot, artifactBase), repoRoot };
|
||||
}
|
||||
|
||||
function parseCliJson<T>(
|
||||
label: string,
|
||||
result: Awaited<ReturnType<OpenClawTestInstance["cli"]>>,
|
||||
): T {
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
`${label} failed with exit ${String(result.code)}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.stdout) as T;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${label} returned invalid JSON: ${formatErrorMessage(error)}\n${result.stdout}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readZipEntries(file: string): Promise<Record<string, string>> {
|
||||
const zip = await JSZip.loadAsync(await fs.readFile(file));
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [name, entry] of Object.entries(zip.files)) {
|
||||
if (!entry.dir) {
|
||||
entries[name] = await entry.async("string");
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function assertSafeArchiveEntries(entries: readonly string[]): void {
|
||||
for (const entry of entries) {
|
||||
assert.ok(entry.length > 0, "support zip entry must not be empty");
|
||||
assert.equal(path.posix.isAbsolute(entry), false, `absolute support zip entry: ${entry}`);
|
||||
assert.equal(
|
||||
entry.split("/").some((part) => part === "" || part === "." || part === ".."),
|
||||
false,
|
||||
`unsafe support zip entry: ${entry}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createWriter(options: GatewaySupportExportRuntimeOptions) {
|
||||
return createQaScriptEvidenceWriter({
|
||||
artifactBase: options.artifactBase,
|
||||
logFileName: "gateway-support-export.log",
|
||||
primaryModel: "gateway/diagnostics-export",
|
||||
providerMode: "mock-openai",
|
||||
repoRoot: options.repoRoot,
|
||||
target: {
|
||||
id: SCENARIO_ID,
|
||||
title: "Gateway support diagnostics export",
|
||||
sourcePath: SOURCE_PATH,
|
||||
docsRefs: ["docs/gateway/diagnostics.md", "docs/cli/gateway.md"],
|
||||
codeRefs: [
|
||||
SOURCE_PATH,
|
||||
"src/cli/gateway-cli/register.ts",
|
||||
"src/logging/diagnostic-support-export.ts",
|
||||
"src/logging/diagnostic-support-bundle.ts",
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function runGatewaySupportExportRuntime(options: GatewaySupportExportRuntimeOptions) {
|
||||
await fs.mkdir(options.artifactBase, { recursive: true });
|
||||
const writer = createWriter(options);
|
||||
const startedAt = Date.now();
|
||||
let instance: OpenClawTestInstance | undefined;
|
||||
try {
|
||||
instance = await createOpenClawTestInstance({
|
||||
name: "qa-gateway-support-export",
|
||||
config: {
|
||||
diagnostics: { enabled: true },
|
||||
},
|
||||
env: {
|
||||
OPENCLAW_TEST_FILE_LOG: "1",
|
||||
},
|
||||
});
|
||||
await instance.startGateway();
|
||||
|
||||
const outputPath = path.join(options.artifactBase, "gateway-support-export.zip");
|
||||
const result = parseCliJson<DiagnosticsExportResult>(
|
||||
"gateway diagnostics export",
|
||||
await instance.cli(
|
||||
[
|
||||
"gateway",
|
||||
"diagnostics",
|
||||
"export",
|
||||
"--output",
|
||||
outputPath,
|
||||
"--log-lines",
|
||||
"7",
|
||||
"--log-bytes",
|
||||
"4096",
|
||||
"--url",
|
||||
instance.url,
|
||||
"--token",
|
||||
instance.gatewayToken,
|
||||
"--timeout",
|
||||
"10000",
|
||||
"--json",
|
||||
],
|
||||
{ timeoutMs: 120_000 },
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(path.resolve(result.path), path.resolve(outputPath));
|
||||
assert.ok(result.bytes > 0, "diagnostics export must be nonempty");
|
||||
assert.equal(result.manifest.privacy.payloadFree, true);
|
||||
assert.equal(result.manifest.privacy.rawLogsIncluded, false);
|
||||
|
||||
const entries = await readZipEntries(outputPath);
|
||||
const entryNames = Object.keys(entries).toSorted();
|
||||
assertSafeArchiveEntries(entryNames);
|
||||
for (const required of [
|
||||
"config/sanitized.json",
|
||||
"config/shape.json",
|
||||
"diagnostics.json",
|
||||
"health/gateway-health.json",
|
||||
"logs/openclaw-sanitized.jsonl",
|
||||
"manifest.json",
|
||||
"status/gateway-status.json",
|
||||
"summary.md",
|
||||
]) {
|
||||
assert.ok(entryNames.includes(required), `support zip missing ${required}`);
|
||||
}
|
||||
|
||||
const combined = Object.values(entries).join("\n");
|
||||
for (const [label, privateValue] of [
|
||||
["Gateway token", instance.gatewayToken],
|
||||
["hook token", instance.hookToken],
|
||||
["home directory", instance.homeDir],
|
||||
["state directory", instance.stateDir],
|
||||
] as const) {
|
||||
assert.equal(combined.includes(privateValue), false, `support zip leaked ${label}`);
|
||||
}
|
||||
assert.match(combined, /payload[- ]free/iu);
|
||||
assert.match(combined, /sanitized/iu);
|
||||
|
||||
const mode = (await fs.stat(outputPath)).mode & 0o777;
|
||||
const privateMode = process.platform === "win32" || mode === 0o600;
|
||||
assert.equal(privateMode, true, `support zip mode must be 0600, got ${mode.toString(8)}`);
|
||||
|
||||
const summary: GatewaySupportExportSummary = {
|
||||
archive: path.basename(outputPath),
|
||||
bytes: result.bytes,
|
||||
entries: entryNames,
|
||||
payloadFree: result.manifest.privacy.payloadFree,
|
||||
privateMode,
|
||||
rawLogsIncluded: result.manifest.privacy.rawLogsIncluded,
|
||||
};
|
||||
const summaryPath = path.join(options.artifactBase, "gateway-support-export-summary.json");
|
||||
await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
||||
writer.appendLog(
|
||||
`gateway-support-export: files=${entryNames.length} bytes=${result.bytes} privateMode=${String(privateMode)}\n`,
|
||||
);
|
||||
return await writer.write({
|
||||
artifacts: [
|
||||
{ kind: "summary", filePath: summaryPath },
|
||||
{ kind: "archive", filePath: outputPath },
|
||||
],
|
||||
details: `real CLI wrote ${entryNames.length} sanitized files in a private payload-free zip`,
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "pass",
|
||||
});
|
||||
} catch (error) {
|
||||
const details = formatErrorMessage(error);
|
||||
writer.appendLog(`gateway-support-export: ${details}\n`);
|
||||
return await writer.write({
|
||||
details,
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "fail",
|
||||
});
|
||||
} finally {
|
||||
await instance?.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
runGatewaySupportExportRuntime(parseOptions(process.argv.slice(2)))
|
||||
.then((evidence) => {
|
||||
const status = evidence.entries[0]?.result.status;
|
||||
process.stdout.write(`gateway-support-export: ${status}\n`);
|
||||
process.exitCode = status === "pass" ? 0 : 1;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
process.stderr.write(`gateway-support-export: ${formatErrorMessage(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user