Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 24 additions & 18 deletions nemoclaw/src/blueprint/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type fs from "node:fs";
import { join, normalize, sep } from "node:path";
import YAML from "yaml";

// ── In-memory filesystem ────────────────────────────────────────
Expand All @@ -14,12 +15,16 @@ interface FsEntry {

const store = new Map<string, FsEntry>();

function normalizePath(p: string): string {
return normalize(p);
}

function addFile(p: string, content: string): void {
store.set(p, { type: "file", content });
store.set(normalizePath(p), { type: "file", content });
}

function addDir(p: string): void {
store.set(p, { type: "dir" });
store.set(normalizePath(p), { type: "dir" });
}

const FAKE_HOME = "/fakehome";
Expand All @@ -36,29 +41,30 @@ vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof fs>();
return {
...original,
existsSync: (p: string) => store.has(p),
existsSync: (p: string) => store.has(normalizePath(p)),
mkdirSync: vi.fn((p: string) => {
addDir(p);
}),
readFileSync: (p: string) => {
const entry = store.get(p);
const entry = store.get(normalizePath(p));
if (entry?.type !== "file") throw new Error(`ENOENT: ${p}`);
return entry.content ?? "";
},
writeFileSync: vi.fn((p: string, data: string) => {
store.set(p, { type: "file", content: data });
store.set(normalizePath(p), { type: "file", content: data });
}),
readdirSync: (p: string) => {
const prefix = p.endsWith("/") ? p : p + "/";
const normalizedPath = normalizePath(p);
const prefix = normalizedPath.endsWith(sep) ? normalizedPath : `${normalizedPath}${sep}`;
const entries = new Set<string>();
for (const k of store.keys()) {
if (k.startsWith(prefix)) {
const rest = k.slice(prefix.length);
const first = rest.split("/")[0];
const first = rest.split(sep)[0];
if (first) entries.add(first);
}
}
if (entries.size === 0 && !store.has(p)) {
if (entries.size === 0 && !store.has(normalizedPath)) {
throw new Error(`ENOENT: ${p}`);
}
return [...entries].sort();
Expand Down Expand Up @@ -167,7 +173,7 @@ describe("runner", () => {

it("respects NEMOCLAW_BLUEPRINT_PATH env var", () => {
process.env.NEMOCLAW_BLUEPRINT_PATH = "/custom/path";
addFile("/custom/path/blueprint.yaml", YAML.stringify({ version: "3.0" }));
addFile(join("/custom/path", "blueprint.yaml"), YAML.stringify({ version: "3.0" }));
expect(loadBlueprint()).toEqual({ version: "3.0" });
});
});
Expand Down Expand Up @@ -317,8 +323,8 @@ describe("runner", () => {
it("saves run state to disk", async () => {
await actionApply("default", minimalBlueprint());

const stateKeys = [...store.keys()].filter((k) => k.includes("/state/runs/"));
const planKey = stateKeys.find((k) => k.endsWith("/plan.json"));
const stateKeys = [...store.keys()].filter((k) => k.includes(`${sep}state${sep}runs${sep}`));
const planKey = stateKeys.find((k) => k.endsWith(`${sep}plan.json`));
if (!planKey) throw new Error("plan.json not written to state dir");
const entry = store.get(planKey);
if (!entry?.content) throw new Error("plan.json has no content");
Expand Down Expand Up @@ -353,7 +359,7 @@ describe("runner", () => {
delete process.env.SECRET_KEY;
}

const planKey = [...store.keys()].find((k) => k.endsWith("/plan.json"));
const planKey = [...store.keys()].find((k) => k.endsWith(`${sep}plan.json`));
if (!planKey) throw new Error("plan.json not written to state dir");
const entry = store.get(planKey);
if (!entry?.content) throw new Error("plan.json has no content");
Expand Down Expand Up @@ -507,7 +513,7 @@ describe("runner", () => {
});

describe("actionStatus", () => {
const RUNS_DIR = `${FAKE_HOME}/.nemoclaw/state/runs`;
const RUNS_DIR = join(FAKE_HOME, ".nemoclaw", "state", "runs");

beforeEach(() => {
captureStdout();
Expand Down Expand Up @@ -572,7 +578,7 @@ describe("runner", () => {
});

describe("actionRollback", () => {
const RUNS_DIR = `${FAKE_HOME}/.nemoclaw/state/runs`;
const RUNS_DIR = join(FAKE_HOME, ".nemoclaw", "state", "runs");

beforeEach(() => {
captureStdout();
Expand Down Expand Up @@ -609,7 +615,7 @@ describe("runner", () => {

await actionRollback("nc-run-1");

expect(store.has(`${runDir}/rolled_back`)).toBe(true);
expect(store.has(join(runDir, "rolled_back"))).toBe(true);
});

it("still writes marker when plan.json is missing", async () => {
Expand All @@ -620,7 +626,7 @@ describe("runner", () => {
await actionRollback("nc-run-1");

expect(mockExeca).not.toHaveBeenCalled();
expect(store.has(`${runDir}/rolled_back`)).toBe(true);
expect(store.has(join(runDir, "rolled_back"))).toBe(true);
});

// ── Path traversal rejection ──────────────────────────────────
Expand Down Expand Up @@ -670,12 +676,12 @@ describe("runner", () => {
});

it("parses rollback with --run-id", async () => {
const runDir = `${FAKE_HOME}/.nemoclaw/state/runs/nc-run-1`;
const runDir = join(FAKE_HOME, ".nemoclaw", "state", "runs", "nc-run-1");
addDir(runDir);
addFile(`${runDir}/plan.json`, JSON.stringify({ sandbox_name: "sb" }));

await main(["rollback", "--run-id", "nc-run-1"]);
expect(store.has(`${runDir}/rolled_back`)).toBe(true);
expect(store.has(join(runDir, "rolled_back"))).toBe(true);
});

it("throws when rollback has no --run-id", async () => {
Expand Down
65 changes: 40 additions & 25 deletions nemoclaw/src/blueprint/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type fs from "node:fs";
const SNAP = "/snap/20260323";
import { join, normalize, sep } from "node:path";

const SNAP = normalize("/snap/20260323");

// ── In-memory filesystem ────────────────────────────────────────

Expand All @@ -14,12 +16,20 @@ interface FsEntry {

const store = new Map<string, FsEntry>();

function normalizePath(p: string): string {
return normalize(p);
}

function isSameOrDescendantPath(candidate: string, root: string): boolean {
return candidate === root || candidate.startsWith(`${root}${sep}`);
}

function addFile(p: string, content: string): void {
store.set(p, { type: "file", content });
store.set(normalizePath(p), { type: "file", content });
}

function addDir(p: string): void {
store.set(p, { type: "dir" });
store.set(normalizePath(p), { type: "dir" });
}

const FAKE_HOME = "/fakehome";
Expand All @@ -32,52 +42,57 @@ vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof fs>();
return {
...original,
existsSync: (p: string) => store.has(p),
existsSync: (p: string) => store.has(normalizePath(p)),
mkdirSync: vi.fn((p: string) => {
addDir(p);
}),
readFileSync: (p: string) => {
const entry = store.get(p);
const entry = store.get(normalizePath(p));
if (entry?.type !== "file") throw new Error(`ENOENT: ${p}`);
return entry.content ?? "";
},
writeFileSync: vi.fn((p: string, data: string) => {
store.set(p, { type: "file", content: data });
store.set(normalizePath(p), { type: "file", content: data });
}),
cpSync: vi.fn((src: string, dest: string) => {
const normalizedSrc = normalizePath(src);
const normalizedDest = normalizePath(dest);
for (const [k, v] of store) {
if (k === src || k.startsWith(src + "/")) {
const relative = k.slice(src.length);
store.set(dest + relative, { ...v });
if (isSameOrDescendantPath(k, normalizedSrc)) {
const relative = k.slice(normalizedSrc.length);
store.set(`${normalizedDest}${relative}`, { ...v });
}
}
}),
renameSync: vi.fn((oldPath: string, newPath: string) => {
const normalizedOldPath = normalizePath(oldPath);
const normalizedNewPath = normalizePath(newPath);
for (const [k, v] of [...store]) {
if (k === oldPath || k.startsWith(oldPath + "/")) {
const relative = k.slice(oldPath.length);
store.set(newPath + relative, v);
if (isSameOrDescendantPath(k, normalizedOldPath)) {
const relative = k.slice(normalizedOldPath.length);
store.set(`${normalizedNewPath}${relative}`, v);
store.delete(k);
}
}
}),
readdirSync: (p: string, opts?: { withFileTypes?: boolean }) => {
const prefix = p.endsWith("/") ? p : p + "/";
const normalizedPath = normalizePath(p);
const prefix = normalizedPath.endsWith(sep) ? normalizedPath : `${normalizedPath}${sep}`;
const childTypes = new Map<string, "file" | "dir">();
for (const [k, v] of store) {
if (k.startsWith(prefix)) {
const rest = k.slice(prefix.length);
const name = rest.split("/")[0];
const name = rest.split(sep)[0];
if (!name) continue;
const isNested = rest.includes("/");
const isNested = rest.includes(sep);
if (!childTypes.has(name)) {
childTypes.set(name, isNested ? "dir" : v.type);
} else if (isNested) {
childTypes.set(name, "dir");
}
}
}
if (childTypes.size === 0 && !store.has(p)) {
if (childTypes.size === 0 && !store.has(normalizedPath)) {
throw new Error(`ENOENT: ${p}`);
}
if (opts?.withFileTypes) {
Expand All @@ -98,8 +113,8 @@ vi.mock("execa", () => ({ execa: (...args: unknown[]) => mockExeca(...args) }));
const { createSnapshot, restoreIntoSandbox, cutoverHost, rollbackFromSnapshot, listSnapshots } =
await import("./snapshot.js");

const OPENCLAW_DIR = `${FAKE_HOME}/.openclaw`;
const SNAPSHOTS_DIR = `${FAKE_HOME}/.nemoclaw/snapshots`;
const OPENCLAW_DIR = join(FAKE_HOME, ".openclaw");
const SNAPSHOTS_DIR = join(FAKE_HOME, ".nemoclaw", "snapshots");

// ── Tests ───────────────────────────────────────────────────────

Expand Down Expand Up @@ -131,14 +146,14 @@ describe("snapshot", () => {
expect(result.startsWith(SNAPSHOTS_DIR)).toBe(true);

// Manifest was written
const manifestPath = `${result}/snapshot.json`;
const manifestPath = join(result, "snapshot.json");
const entry = store.get(manifestPath);
if (!entry?.content) throw new Error("manifest not written");
const manifest = JSON.parse(entry.content);
expect(manifest.source).toBe(OPENCLAW_DIR);
expect(manifest.file_count).toBe(2);
expect(manifest.contents).toContain("openclaw.json");
expect(manifest.contents).toContain("hooks/demo/HOOK.md");
expect(manifest.contents).toContain(join("hooks", "demo", "HOOK.md"));
});
});

Expand All @@ -155,7 +170,7 @@ describe("snapshot", () => {
expect(await restoreIntoSandbox(SNAP, "mybox")).toBe(true);
expect(mockExeca).toHaveBeenCalledWith(
"openshell",
["sandbox", "cp", `${SNAP}/openclaw`, "mybox:/sandbox/.openclaw"],
["sandbox", "cp", join(SNAP, "openclaw"), "mybox:/sandbox/.openclaw"],
{ reject: false },
);
});
Expand Down Expand Up @@ -221,7 +236,7 @@ describe("snapshot", () => {

expect(rollbackFromSnapshot(SNAP)).toBe(true);

const restored = store.get(`${OPENCLAW_DIR}/openclaw.json`);
const restored = store.get(join(OPENCLAW_DIR, "openclaw.json"));
if (!restored) throw new Error("openclaw.json not restored");
expect(restored.content).toBe('{"restored":true}');
});
Expand All @@ -245,8 +260,8 @@ describe("snapshot", () => {
});

it("returns manifests sorted newest-first", () => {
const snap1 = `${SNAPSHOTS_DIR}/20260101T000000Z`;
const snap2 = `${SNAPSHOTS_DIR}/20260201T000000Z`;
const snap1 = join(SNAPSHOTS_DIR, "20260101T000000Z");
const snap2 = join(SNAPSHOTS_DIR, "20260201T000000Z");
addDir(snap1);
addFile(
`${snap1}/snapshot.json`,
Expand Down Expand Up @@ -276,7 +291,7 @@ describe("snapshot", () => {
});

it("skips snapshots with corrupt manifests", () => {
const snap1 = `${SNAPSHOTS_DIR}/20260101T000000Z`;
const snap1 = join(SNAPSHOTS_DIR, "20260101T000000Z");
addDir(snap1);
addFile(`${snap1}/snapshot.json`, "NOT VALID JSON");

Expand Down