

























1-// Tests trajectory export command output and filesystem writes.
1+// Tests trajectory export command approval routing.
22import fs from "node:fs";
33import os from "node:os";
44import path from "node:path";
55import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+import { buildExportTrajectoryCommandReply } from "./commands-export-trajectory.js";
67import type { HandleCommandsParams } from "./commands-types.js";
788-const hoisted = await vi.hoisted(async () => {
9-const { createExportCommandSessionMocks } = await import("./commands-export-test-mocks.js");
10-return {
11- ...createExportCommandSessionMocks(vi),
12-exportTrajectoryBundleMock: vi.fn(() => ({
13-outputDir: "/tmp/workspace/.openclaw/trajectory-exports/openclaw-trajectory-session",
14-manifest: {
15-eventCount: 7,
16-runtimeEventCount: 3,
17-transcriptEventCount: 4,
18-},
19-events: [{ type: "context.compiled" }],
20-runtimeFile: "/tmp/target-store/session.trajectory.jsonl",
21-supplementalFiles: ["metadata.json", "artifacts.json", "prompts.json"],
22-})),
23-resolveDefaultTrajectoryExportDirMock: vi.fn(
24-() => "/tmp/workspace/.openclaw/trajectory-exports/openclaw-trajectory-session",
25-),
26-accessMock: vi.fn(
27-async (file: fs.PathLike, actualAccess: (path: fs.PathLike) => Promise<void>) => {
28-await actualAccess(file);
29-},
30-),
31-statMock: vi.fn(
32-async (file: fs.PathLike, actualStat: (path: fs.PathLike) => Promise<unknown>) => {
33-return await actualStat(file);
34-},
35-),
36-};
37-});
38-39-vi.mock("../../config/sessions/paths.js", () => ({
40-resolveDefaultSessionStorePath: hoisted.resolveDefaultSessionStorePathMock,
41-resolveSessionFilePath: hoisted.resolveSessionFilePathMock,
42-resolveSessionFilePathOptions: hoisted.resolveSessionFilePathOptionsMock,
43-}));
44-45-vi.mock("../../config/sessions/store.js", () => ({
46-loadSessionStore: hoisted.loadSessionStoreMock,
47-}));
48-49-vi.mock("../../trajectory/export.js", () => ({
50-exportTrajectoryBundle: hoisted.exportTrajectoryBundleMock,
51-resolveDefaultTrajectoryExportDir: hoisted.resolveDefaultTrajectoryExportDirMock,
52-}));
53-54-vi.mock("node:fs", async () => {
55-const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
56-const mockedFs = { ...actual };
57-return {
58- ...mockedFs,
59-default: mockedFs,
60-};
61-});
62-63-vi.mock("node:fs/promises", async () => {
64-const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
65-const mockedFs = {
66- ...actual,
67-access: (file: fs.PathLike) => hoisted.accessMock(file, actual.access),
68-stat: (file: fs.PathLike) => hoisted.statMock(file, actual.stat),
69-};
70-return {
71- ...mockedFs,
72-default: mockedFs,
73-};
74-});
75-76-import {
77-buildExportTrajectoryCommandReply,
78-buildExportTrajectoryReply,
79-} from "./commands-export-trajectory.js";
80-819const tempDirs: string[] = [];
82-const mockedSessionFile = "/tmp/target-store/session.jsonl";
83108411function makeTempDir(): string {
8512const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-export-command-"));
8613tempDirs.push(dir);
8714return dir;
8815}
891617+afterEach(() => {
18+for (const dir of tempDirs.splice(0)) {
19+fs.rmSync(dir, { recursive: true, force: true });
20+}
21+});
22+9023function makeParams(workspaceDir = makeTempDir()): HandleCommandsParams {
9124return {
9225cfg: {
@@ -188,11 +121,6 @@ function requireRecord(value: unknown): Record<string, unknown> {
188121return value as Record<string, unknown>;
189122}
190123191-function exportBundleParams(): Record<string, unknown> {
192-const calls = hoisted.exportTrajectoryBundleMock.mock.calls as unknown[][];
193-return requireRecord(calls[0]?.[0]);
194-}
195-196124function execCallRecord(
197125execCalls: Array<{ defaults: unknown; params: unknown }>,
198126index = 0,
@@ -207,150 +135,6 @@ function execCallRecord(
207135};
208136}
209137210-describe("buildExportTrajectoryReply", () => {
211-beforeEach(() => {
212-vi.clearAllMocks();
213-hoisted.accessMock.mockImplementation(
214-async (file: fs.PathLike, actualAccess: (path: fs.PathLike) => Promise<void>) => {
215-if (file.toString() === "/tmp/target-store/session.jsonl") {
216-return;
217-}
218-await actualAccess(file);
219-},
220-);
221-hoisted.statMock.mockImplementation(
222-async (file: fs.PathLike, actualStat: (path: fs.PathLike) => Promise<unknown>) => {
223-if (file.toString() === "/tmp/target-store/session.jsonl") {
224-return {};
225-}
226-return await actualStat(file);
227-},
228-);
229-fs.mkdirSync(path.dirname(mockedSessionFile), { recursive: true });
230-fs.writeFileSync(mockedSessionFile, "{}\n");
231-});
232-233-afterEach(() => {
234-fs.rmSync(mockedSessionFile, { force: true });
235-for (const dir of tempDirs.splice(0)) {
236-fs.rmSync(dir, { recursive: true, force: true });
237-}
238-});
239-240-it("builds a trajectory bundle from the target session", async () => {
241-const params = makeParams();
242-const reply = await buildExportTrajectoryReply(params);
243-244-expect(reply.text).toContain("✅ Trajectory exported!");
245-expect(reply.text).toContain("session-branch.json");
246-expect(reply.text).not.toContain("session.jsonl");
247-expect(reply.text).not.toContain("runtime.jsonl");
248-expect(hoisted.resolveDefaultSessionStorePathMock).toHaveBeenCalledWith("target");
249-const exportParams = exportBundleParams();
250-expect(exportParams.sessionId).toBe("session-1");
251-expect(exportParams.sessionKey).toBe("agent:target:session");
252-expect(exportParams.workspaceDir).toBe(params.workspaceDir);
253-expect(String(exportParams.workspaceDir)).toContain("openclaw-export-command-");
254-});
255-256-it("keeps user-named output paths inside the workspace trajectory export directory", async () => {
257-const params = makeParams();
258-params.command.commandBodyNormalized = "/export-trajectory my-bundle";
259-260-await buildExportTrajectoryReply(params);
261-262-expect(exportBundleParams().outputDir).toBe(
263-path.join(params.workspaceDir, ".openclaw", "trajectory-exports", "my-bundle"),
264-);
265-});
266-267-it("rejects absolute output paths", async () => {
268-const params = makeParams();
269-params.command.commandBodyNormalized = "/export-trajectory /tmp/outside";
270-271-const reply = await buildExportTrajectoryReply(params);
272-273-expect(reply.text).toContain("Failed to resolve output path");
274-expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled();
275-});
276-277-it("rejects home-relative output paths", async () => {
278-const params = makeParams();
279-params.command.commandBodyNormalized = "/export-trajectory ~/bundle";
280-281-const reply = await buildExportTrajectoryReply(params);
282-283-expect(reply.text).toContain("Failed to resolve output path");
284-expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled();
285-});
286-287-it("does not echo absolute session paths when the transcript is missing", async () => {
288-fs.rmSync(mockedSessionFile, { force: true });
289-hoisted.accessMock.mockImplementation(
290-async (file: fs.PathLike, actualAccess: (path: fs.PathLike) => Promise<void>) => {
291-if (file.toString() === "/tmp/target-store/session.jsonl") {
292-throw Object.assign(new Error("missing"), { code: "ENOENT" });
293-}
294-await actualAccess(file);
295-},
296-);
297-hoisted.statMock.mockImplementation(
298-async (file: fs.PathLike, actualStat: (path: fs.PathLike) => Promise<unknown>) => {
299-if (file.toString() === "/tmp/target-store/session.jsonl") {
300-throw Object.assign(new Error("missing"), { code: "ENOENT" });
301-}
302-return await actualStat(file);
303-},
304-);
305-306-const reply = await buildExportTrajectoryReply(makeParams());
307-308-expect(reply.text).toBe("❌ Session file not found.");
309-expect(reply.text).not.toContain("/tmp/target-store/session.jsonl");
310-expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled();
311-});
312-313-it("rejects output paths redirected by a symlinked exports directory", async () => {
314-const workspaceDir = makeTempDir();
315-const outsideDir = makeTempDir();
316-fs.mkdirSync(path.join(workspaceDir, ".openclaw"), { recursive: true });
317-fs.symlinkSync(outsideDir, path.join(workspaceDir, ".openclaw", "trajectory-exports"));
318-const params = makeParams(workspaceDir);
319-params.command.commandBodyNormalized = "/export-trajectory my-bundle";
320-321-const reply = await buildExportTrajectoryReply(params);
322-323-expect(reply.text).toContain("Failed to resolve output path");
324-expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled();
325-});
326-327-it("rejects default output paths redirected by a symlinked exports directory", async () => {
328-const workspaceDir = makeTempDir();
329-const outsideDir = makeTempDir();
330-fs.mkdirSync(path.join(workspaceDir, ".openclaw"), { recursive: true });
331-fs.symlinkSync(outsideDir, path.join(workspaceDir, ".openclaw", "trajectory-exports"));
332-333-const reply = await buildExportTrajectoryReply(makeParams(workspaceDir));
334-335-expect(reply.text).toContain("Failed to resolve output path");
336-expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled();
337-});
338-339-it("rejects symlinked state directories before creating export folders", async () => {
340-const workspaceDir = makeTempDir();
341-const outsideDir = makeTempDir();
342-fs.symlinkSync(outsideDir, path.join(workspaceDir, ".openclaw"));
343-const params = makeParams(workspaceDir);
344-params.command.commandBodyNormalized = "/export-trajectory my-bundle";
345-346-const reply = await buildExportTrajectoryReply(params);
347-348-expect(reply.text).toContain("Failed to resolve output path");
349-expect(fs.existsSync(path.join(outsideDir, "trajectory-exports"))).toBe(false);
350-expect(hoisted.exportTrajectoryBundleMock).not.toHaveBeenCalled();
351-});
352-});
353-354138describe("buildExportTrajectoryCommandReply", () => {
355139beforeEach(() => {
356140vi.clearAllMocks();
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。