repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/bak/__e2e__/commands.test.ts
Rawimport { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SessionInfo } from "@earendil-works/pi-coding-agent";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { BakArchive } from "../archive.js";
import bakExtension from "../index.js";
import { type S3Config, S3Store } from "../s3.js";
import { type HostIdentity, statePaths } from "../state.js";
import {
removeHosts,
storageConfig,
testAlias,
writeSession,
} from "./support.js";
const config = storageConfig;
interface Note {
message: string;
level: string;
}
type CommandHandler = (args: string, ctx: unknown) => Promise<void> | void;
type EventHandler = (event: unknown, ctx: unknown) => Promise<void> | void;
const aliases: string[] = [];
let workspace: string;
let sessionDir: string;
let notes: Note[] = [];
let run: (args: string) => Promise<void>;
let emit: (event: string) => Promise<void>;
let context: Record<string, unknown>;
let currentSession: SessionInfo | undefined;
/**
* Registers the extension exactly like Pi does, but against a throwaway state
* directory and session store so real backups stay untouched.
*/
function loadExtension(): void {
const handlers = new Map<string, EventHandler[]>();
let command: { handler: CommandHandler } | undefined;
const pi = {
on(event: string, handler: EventHandler) {
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
},
registerCommand(_name: string, definition: { handler: CommandHandler }) {
command = definition;
},
};
bakExtension(pi as never);
if (!command) throw new Error("bak command was not registered");
const registered = command;
run = async (args: string) => {
await registered.handler(args, context);
};
emit = async (event: string) => {
for (const handler of handlers.get(event) ?? [])
await handler({ type: event }, context);
};
}
function sessionContext(): Record<string, unknown> {
return {
hasUI: true,
ui: {
notify: (message: string, level = "info") => {
notes.push({ message, level });
},
setStatus: () => undefined,
theme: { fg: (_style: string, text: string) => text },
},
sessionManager: {
getSessionDir: () => sessionDir,
usesDefaultSessionDir: () => false,
getSessionFile: () => currentSession?.path,
getHeader: () =>
currentSession
? {
id: currentSession.id,
cwd: currentSession.cwd,
timestamp: currentSession.created.toISOString(),
}
: undefined,
getSessionName: () => currentSession?.name,
getEntries: () => [],
},
};
}
function lastNote(): Note {
const note = notes.at(-1);
if (!note) throw new Error("no bak notification recorded");
return note;
}
async function archivedIds(alias: string): Promise<string[]> {
const cache = await sandboxArchive().refresh();
return (
cache.indexes
.find((index) => index.alias === alias)
?.sessions.map((record) => record.id) ?? []
);
}
async function waitForArchived(alias: string, id: string): Promise<void> {
for (let attempt = 0; attempt < 30; attempt++) {
if ((await archivedIds(alias)).includes(id)) return;
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`session ${id} never reached the archive`);
}
function sandboxArchive(): BakArchive {
return new BakArchive(
new S3Store(config as S3Config),
statePaths(process.env),
);
}
describe.skipIf(!config)("bak commands against real S3 storage", () => {
const original = {
state: process.env.XDG_STATE_HOME,
cache: process.env.XDG_CACHE_HOME,
};
beforeAll(async () => {
workspace = await mkdtemp(join(tmpdir(), "bak-cmd-e2e-"));
process.env.XDG_STATE_HOME = join(workspace, "state");
process.env.XDG_CACHE_HOME = join(workspace, "cache");
sessionDir = join(workspace, "sessions");
await mkdir(sessionDir, { recursive: true });
context = sessionContext();
loadExtension();
});
afterAll(async () => {
if (config && aliases.length > 0) {
const catalog = await new S3Store(config).get("catalog/hosts.json");
const hosts = catalog
? (
JSON.parse(new TextDecoder().decode(catalog.body)) as {
hosts: HostIdentity[];
}
).hosts
: [];
await removeHosts(
config,
hosts
.filter((host) => aliases.includes(host.alias))
.map((host) => host.hostId),
);
}
process.env.XDG_STATE_HOME = original.state;
process.env.XDG_CACHE_HOME = original.cache;
if (original.state === undefined) delete process.env.XDG_STATE_HOME;
if (original.cache === undefined) delete process.env.XDG_CACHE_HOME;
await rm(workspace, { recursive: true, force: true });
});
it("reports an uninitialized host", async () => {
notes = [];
await run("status");
expect(lastNote().message).toContain("not initialized");
expect(lastNote().message).toContain("0 pending");
});
it("initializes, backs up local sessions, and reports status", async () => {
const alias = testAlias();
aliases.push(alias);
notes = [];
await run(`init ${alias}`);
expect(lastNote().message).toBe(`bak: initialized as ${alias}`);
const identity = JSON.parse(
await readFile(statePaths(process.env).identity, "utf8"),
) as HostIdentity;
expect(identity.alias).toBe(alias);
const first = await writeSession(sessionDir, { name: "command session" });
const second = await writeSession(sessionDir, { messages: ["x", "y"] });
await run("backup");
expect(lastNote().message).toBe("bak: backed up 2; 2 indexed");
await run("status");
expect(lastNote().message).toContain(alias);
expect(lastNote().message).toContain("ready");
// The counter spans every catalog host, including unrelated real hosts.
expect(lastNote().message).toMatch(/[1-9]\d* indexed/);
expect(lastNote().message).toContain("0 pending");
expect(lastNote().message).not.toContain("error");
const cache = await sandboxArchive().refresh();
const ids =
cache.indexes
.find((index) => index.alias === alias)
?.sessions.map((record) => record.id) ?? [];
expect(ids.sort()).toEqual([first.id, second.id].sort());
});
it("searches remote names from the cached catalog", async () => {
const alias = aliases.at(-1) as string;
notes = [];
await run("refresh");
await run("search COMMAND SESSION");
expect(lastNote().message).toContain(`${alias}/`);
expect(lastNote().message).toContain("command session");
});
it("refreshes the catalog and restores by alias prefix", async () => {
const alias = aliases.at(-1) as string;
notes = [];
await run("refresh");
expect(lastNote().message).toMatch(/refreshed \d+ host\(s\)/);
const cache = await sandboxArchive().refresh();
const record = cache.indexes.find((index) => index.alias === alias)
?.sessions[0];
expect(record).toBeDefined();
const target = join(workspace, "restore-target");
await run(`restore ${alias}/${record?.id.slice(0, 8)} ${target}`);
expect(lastNote().message).toBe("bak: restored 1, skipped 0, conflicts 0");
expect(await readdir(target)).toHaveLength(1);
await run(`restore ${alias}/${record?.id.slice(0, 8)} ${target}`);
expect(lastNote().message).toBe("bak: restored 0, skipped 1, conflicts 0");
});
it("restores every archived session of the host", async () => {
const alias = aliases.at(-1) as string;
const target = join(workspace, "restore-all");
notes = [];
await run(`restore all ${target}`);
expect(lastNote().message).toMatch(/restored \d+, skipped \d+/);
const cache = await sandboxArchive().refresh();
const own =
cache.indexes.find((index) => index.alias === alias)?.sessions.length ??
0;
expect((await readdir(target)).length).toBeGreaterThanOrEqual(own);
});
it("keeps working after session lifecycle events", async () => {
notes = [];
await emit("session_start");
await writeSession(sessionDir, { messages: ["after restart"] });
await run("backup");
expect(lastNote().message).toBe("bak: backed up 3; 3 indexed");
await run("status");
expect(lastNote().message).toMatch(/[1-9]\d* indexed/);
expect(lastNote().message).toContain("0 pending");
expect(lastNote().message).not.toContain("error");
});
it("backs up the active session when the agent settles", async () => {
const alias = aliases.at(-1) as string;
notes = [];
currentSession = await writeSession(sessionDir, {
messages: ["settled"],
name: "settled session",
});
await emit("agent_settled");
await waitForArchived(alias, currentSession.id);
expect(notes.filter((note) => note.level !== "info")).toEqual([]);
const markers = await readdir(statePaths(process.env).pendingDir);
expect(markers).toEqual([]);
});
it("retains a pending retry after a failed backup and recovers", async () => {
const alias = aliases.at(-1) as string;
const bucket = process.env.PI_BAK_S3_BUCKET;
notes = [];
currentSession = await writeSession(sessionDir, { messages: ["pending"] });
process.env.PI_BAK_S3_BUCKET = `${(config as S3Config).bucket}-missing`;
loadExtension();
await run("backup");
expect(lastNote().level).toBe("error");
expect(await readdir(statePaths(process.env).pendingDir)).not.toEqual([]);
process.env.PI_BAK_S3_BUCKET = bucket;
if (bucket === undefined) delete process.env.PI_BAK_S3_BUCKET;
loadExtension();
await emit("session_start");
await emit("agent_settled");
await waitForArchived(alias, currentSession.id);
for (let attempt = 0; attempt < 30; attempt++) {
if ((await readdir(statePaths(process.env).pendingDir)).length === 0)
break;
await new Promise((resolve) => setTimeout(resolve, 500));
}
expect(await readdir(statePaths(process.env).pendingDir)).toEqual([]);
});
it("reports usage and initialization errors", async () => {
notes = [];
await run("bogus");
expect(lastNote().level).toBe("error");
expect(lastNote().message).toContain("Usage:");
await run(`init ${testAlias()}`);
expect(lastNote().level).toBe("error");
expect(lastNote().message).toMatch(/Already initialized as/);
});
});