|
| 1 | +// @vitest-environment node |
| 2 | +import { spawn } from "node:child_process"; |
| 3 | +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 4 | +import type { AddressInfo } from "node:net"; |
| 5 | +import { tmpdir } from "node:os"; |
| 6 | +import { join, resolve } from "node:path"; |
| 7 | +import { fileURLToPath } from "node:url"; |
| 8 | +import { createAdaptorServer } from "@hono/node-server"; |
| 9 | +import { |
| 10 | + createStudioApi, |
| 11 | + openProjectHistory, |
| 12 | + type StudioApiAdapter, |
| 13 | +} from "@hyperframes/studio-server"; |
| 14 | +import { runCommand } from "citty"; |
| 15 | +import { Hono } from "hono"; |
| 16 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 17 | +import { consumeCommandResult } from "../utils/commandResult.js"; |
| 18 | +import historyCommand, { historyDeps } from "./history.js"; |
| 19 | + |
| 20 | +const tracked = vi.hoisted(() => [] as Array<{ action: string; via: string }>); |
| 21 | +vi.mock("../telemetry/events.js", () => ({ |
| 22 | + trackHistoryAction: (props: { action: string; via: string }) => tracked.push(props), |
| 23 | +})); |
| 24 | + |
| 25 | +const cleanup: Array<() => unknown> = []; |
| 26 | +afterEach(async () => { |
| 27 | + for (const step of cleanup.splice(0).reverse()) await step(); |
| 28 | + tracked.length = 0; |
| 29 | +}); |
| 30 | + |
| 31 | +function tempDir(prefix: string): string { |
| 32 | + const dir = mkdtempSync(join(tmpdir(), prefix)); |
| 33 | + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); |
| 34 | + return dir; |
| 35 | +} |
| 36 | + |
| 37 | +/** A project of index.html "A" and notes.html "N", its history kept in a temp root, no preview running. */ |
| 38 | +function project() { |
| 39 | + const dir = tempDir("hf-history-cli-"); |
| 40 | + writeFileSync(join(dir, "index.html"), "A"); |
| 41 | + writeFileSync(join(dir, "notes.html"), "N"); |
| 42 | + historyDeps.historyRoot = tempDir("hf-history-cli-root-"); |
| 43 | + historyDeps.findServer = async () => null; |
| 44 | + const write = (path: string, text: string) => writeFileSync(join(dir, path), text); |
| 45 | + const read = (path: string) => readFileSync(join(dir, path), "utf-8"); |
| 46 | + async function hf(...args: string[]) { |
| 47 | + const log = vi.spyOn(console, "log").mockImplementation(() => {}); |
| 48 | + const error = vi.spyOn(console, "error").mockImplementation(() => {}); |
| 49 | + try { |
| 50 | + await runCommand(historyCommand, { rawArgs: [...args, "--dir", dir] }); |
| 51 | + const out = log.mock.calls.map((call) => call.join(" ")).join("\n"); |
| 52 | + const err = error.mock.calls.map((call) => call.join(" ")).join("\n"); |
| 53 | + return { out, err, code: consumeCommandResult().exitCode }; |
| 54 | + } finally { |
| 55 | + log.mockRestore(); |
| 56 | + error.mockRestore(); |
| 57 | + } |
| 58 | + } |
| 59 | + const json = async (...args: string[]) => JSON.parse((await hf(...args, "--json")).out); |
| 60 | + /** One agent turn that writes `path`. */ |
| 61 | + async function turn(name: string, label: string, path: string, text: string) { |
| 62 | + await hf("begin", "--who", name, "--label", label); |
| 63 | + write(path, text); |
| 64 | + return (await json("end")).entry; |
| 65 | + } |
| 66 | + return { dir, write, read, hf, json, turn }; |
| 67 | +} |
| 68 | + |
| 69 | +/** A running preview over the project's history, as `hyperframes preview` serves it. */ |
| 70 | +async function preview(dir: string) { |
| 71 | + const history = await openProjectHistory({ |
| 72 | + projectDir: dir, |
| 73 | + historyRoot: historyDeps.historyRoot, |
| 74 | + quietMs: 20, |
| 75 | + }); |
| 76 | + const adapter = { |
| 77 | + listProjects: () => [], |
| 78 | + resolveProject: (id: string) => (id === "demo" ? { id, dir } : null), |
| 79 | + history: () => history, |
| 80 | + } as unknown as StudioApiAdapter; |
| 81 | + const server = createAdaptorServer({ |
| 82 | + fetch: new Hono().route("/api", createStudioApi(adapter)).fetch, |
| 83 | + }); |
| 84 | + await new Promise<void>((ready) => server.listen(0, "127.0.0.1", ready)); |
| 85 | + cleanup.push(async () => { |
| 86 | + server.close(); |
| 87 | + await history.close(); |
| 88 | + }); |
| 89 | + const { port } = server.address() as AddressInfo; |
| 90 | + historyDeps.findServer = async () => |
| 91 | + ({ |
| 92 | + port, |
| 93 | + host: "127.0.0.1", |
| 94 | + projectName: "demo", |
| 95 | + projectDir: dir, |
| 96 | + version: "test", |
| 97 | + pid: null, |
| 98 | + }) as never; |
| 99 | + return history; |
| 100 | +} |
| 101 | + |
| 102 | +describe.each(["direct", "preview"])("hyperframes history (%s)", (mode) => { |
| 103 | + async function setup() { |
| 104 | + const p = project(); |
| 105 | + await p.hf(); // the first open records the baseline |
| 106 | + const held = mode === "preview" ? await preview(p.dir) : null; |
| 107 | + /** A person's edit, taken in as preview's file watcher would. */ |
| 108 | + async function personWrites(path: string, text: string) { |
| 109 | + p.write(path, text); |
| 110 | + if (!held) return; |
| 111 | + held.noteChange(path); |
| 112 | + await vi.waitFor(() => expect(held.list().at(-1)?.files[0]?.path).toBe(path)); |
| 113 | + } |
| 114 | + return { ...p, personWrites }; |
| 115 | + } |
| 116 | + |
| 117 | + it("an agent's labelled turn, a person's edit after it: --since shows both, undo of the turn keeps the edit", async () => { |
| 118 | + const { read, json, hf, turn, personWrites } = await setup(); |
| 119 | + const startedAt = new Date(Date.now() - 1).toISOString(); |
| 120 | + await turn("claude", "Bigger title", "index.html", "A2"); |
| 121 | + await personWrites("notes.html", "N2"); |
| 122 | + |
| 123 | + const both = (await json("--since", startedAt)).entries; |
| 124 | + expect(both.map((entry: { who: object; files: string[] }) => [entry.who, entry.files])).toEqual( |
| 125 | + [ |
| 126 | + [{ kind: "outside", name: "Outside" }, ["notes.html"]], |
| 127 | + [{ kind: "agent", name: "claude" }, ["index.html"]], |
| 128 | + ], |
| 129 | + ); |
| 130 | + const mine = (await json("--since", "mine", "--who", "claude")).entries; |
| 131 | + expect(mine).toMatchObject([{ who: { name: "Outside" }, files: ["notes.html"] }]); |
| 132 | + |
| 133 | + const undo = await hf("undo", "--who", "claude"); |
| 134 | + expect(undo.code, undo.err).toBe(0); |
| 135 | + expect([read("index.html"), read("notes.html")]).toEqual(["A", "N2"]); |
| 136 | + expect(tracked.map((event) => event.via)).toContain(mode); |
| 137 | + expect(tracked.map((event) => event.action)).toEqual( |
| 138 | + expect.arrayContaining(["begin", "end", "list", "undo"]), |
| 139 | + ); |
| 140 | + }); |
| 141 | + |
| 142 | + it("undo by one agent leaves another agent's open turn open", async () => { |
| 143 | + const { write, read, json, hf, turn } = await setup(); |
| 144 | + await turn("gemini", "Notes", "notes.html", "N2"); |
| 145 | + await hf("begin", "--who", "claude", "--label", "Bigger title"); |
| 146 | + write("index.html", "A2"); |
| 147 | + |
| 148 | + expect((await hf("undo", "--who", "gemini")).code).toBe(0); |
| 149 | + expect(read("notes.html")).toBe("N"); |
| 150 | + expect((await hf("end")).code).toBe(0); |
| 151 | + const claude = (await json()).entries.filter( |
| 152 | + (e: { who: { name: string } }) => e.who.name === "claude", |
| 153 | + ); |
| 154 | + expect(claude).toMatchObject([{ files: ["index.html"] }]); |
| 155 | + }); |
| 156 | + |
| 157 | + it("undo of an id without --who is the person's, and leaves an agent's open turn open", async () => { |
| 158 | + const { write, json, hf, turn } = await setup(); |
| 159 | + const notes = await turn("gemini", "Notes", "notes.html", "N2"); |
| 160 | + await hf("begin", "--who", "claude", "--label", "Bigger title"); |
| 161 | + write("index.html", "A2"); |
| 162 | + |
| 163 | + expect((await json("undo", notes.id)).entry.who).toEqual({ kind: "person", name: "You" }); |
| 164 | + expect((await hf("end")).code).toBe(0); |
| 165 | + }); |
| 166 | + |
| 167 | + it("undo refuses a conflict with exit 2, names the newer entries and both choices, then takes one", async () => { |
| 168 | + const { read, hf, turn } = await setup(); |
| 169 | + const first = await turn("claude", "First", "index.html", "A2"); |
| 170 | + await turn("gemini", "Second", "index.html", "A3"); |
| 171 | + |
| 172 | + const refused = await hf("undo", first.id.slice(0, 8)); |
| 173 | + expect(refused.code).toBe(2); |
| 174 | + expect(refused.out).toContain("Second"); |
| 175 | + expect(refused.out).toMatch(/--just-this[\s\S]*--back-to-before/); |
| 176 | + expect(read("index.html")).toBe("A3"); |
| 177 | + |
| 178 | + expect((await hf("undo", first.id, "--just-this")).code).toBe(0); |
| 179 | + expect(read("index.html")).toBe("A"); |
| 180 | + }); |
| 181 | + |
| 182 | + it("show lists an entry's files and --diff prints the text change", async () => { |
| 183 | + const { hf, turn } = await setup(); |
| 184 | + const entry = await turn("claude", "Retitle", "index.html", "A2"); |
| 185 | + expect((await hf("show", entry.id)).out).toContain("M index.html"); |
| 186 | + const diff = (await hf("show", entry.id.slice(0, 8), "--diff")).out; |
| 187 | + expect(diff).toMatch(/-A\n\\ No newline at end of file\n\+A2/); |
| 188 | + }); |
| 189 | + |
| 190 | + it("peek reads a file as it was without writing, and restore puts every file back", async () => { |
| 191 | + const { read, hf, turn } = await setup(); |
| 192 | + const entry = await turn("claude", "Retitle", "index.html", "A2"); |
| 193 | + await turn("claude", "Again", "index.html", "A3"); |
| 194 | + |
| 195 | + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); |
| 196 | + await hf("peek", entry.id, "index.html"); |
| 197 | + expect(String(stdout.mock.calls[0]?.[0])).toBe("A2"); |
| 198 | + stdout.mockRestore(); |
| 199 | + expect(read("index.html")).toBe("A3"); |
| 200 | + |
| 201 | + expect((await hf("restore", "start")).code).toBe(0); |
| 202 | + expect(read("index.html")).toBe("A"); |
| 203 | + }); |
| 204 | + |
| 205 | + it("pin marks an entry kept, --off unmarks it", async () => { |
| 206 | + const { json, hf, turn } = await setup(); |
| 207 | + const entry = await turn("claude", "Retitle", "index.html", "A2"); |
| 208 | + await hf("pin", entry.id); |
| 209 | + expect((await json()).entries[0]).toMatchObject({ id: entry.id, pinned: true }); |
| 210 | + await hf("pin", entry.id, "--off"); |
| 211 | + expect((await json()).entries[0]).toMatchObject({ pinned: false }); |
| 212 | + }); |
| 213 | +}); |
| 214 | + |
| 215 | +describe("hyperframes history, one owner", () => { |
| 216 | + it("goes through a running preview, so the log keeps one baseline and every entry once", async () => { |
| 217 | + const { dir, json, turn } = project(); |
| 218 | + const held = await preview(dir); |
| 219 | + await turn("claude", "Retitle", "index.html", "A2"); |
| 220 | + await json(); |
| 221 | + const log = readFileSync(join(historyDeps.historyRoot, held.projectId, "log.jsonl"), "utf-8"); |
| 222 | + const records = log |
| 223 | + .trim() |
| 224 | + .split("\n") |
| 225 | + .map((row) => JSON.parse(row).type); |
| 226 | + expect(records).toEqual(["baseline", "entry"]); |
| 227 | + }); |
| 228 | + |
| 229 | + it("a turn begun through a preview that stopped without closing it is still the agent's", async () => { |
| 230 | + const { dir, write, json, hf } = project(); |
| 231 | + await hf(); |
| 232 | + const turn = { |
| 233 | + via: "preview", |
| 234 | + id: "turn-1", |
| 235 | + who: { kind: "agent", name: "claude" }, |
| 236 | + label: "Retitle", |
| 237 | + startedAt: 1, |
| 238 | + }; |
| 239 | + writeFileSync(join(dir, ".hyperframes", "history-turn.json"), JSON.stringify(turn)); |
| 240 | + write("index.html", "A2"); |
| 241 | + |
| 242 | + expect((await json("end")).entry).toMatchObject({ |
| 243 | + id: "turn-1", |
| 244 | + who: turn.who, |
| 245 | + files: [{ path: "index.html" }], |
| 246 | + }); |
| 247 | + }); |
| 248 | + |
| 249 | + it("a CLI run waits while another process holds the history, instead of forking its log", async () => { |
| 250 | + const { dir, write } = project(); |
| 251 | + const home = tempDir("hf-history-cli-home-"); |
| 252 | + const historyRoot = join(home, ".cache", "hyperframes", "history"); |
| 253 | + const held = await openProjectHistory({ projectDir: dir, historyRoot }); |
| 254 | + write("index.html", "A2"); |
| 255 | + const cli = resolve(fileURLToPath(import.meta.url), "..", "..", "cli.ts"); |
| 256 | + const child = spawn("bun", ["run", cli, "history", "--dir", dir], { |
| 257 | + env: { |
| 258 | + ...process.env, |
| 259 | + HOME: home, |
| 260 | + HYPERFRAMES_SKIP_UPDATE_CHECK: "1", |
| 261 | + HYPERFRAMES_NO_TELEMETRY: "1", |
| 262 | + }, |
| 263 | + }); |
| 264 | + const exited = new Promise<number | null>((done) => child.on("exit", done)); |
| 265 | + const logFile = join(historyRoot, held.projectId, "log.jsonl"); |
| 266 | + await new Promise((settle) => setTimeout(settle, 1500)); |
| 267 | + expect( |
| 268 | + readFileSync(logFile, "utf-8").trim().split("\n"), |
| 269 | + "nothing written while held", |
| 270 | + ).toHaveLength(1); |
| 271 | + |
| 272 | + await held.close(); // commits the outside edit once |
| 273 | + expect(await exited).toBe(0); |
| 274 | + const types = readFileSync(logFile, "utf-8") |
| 275 | + .trim() |
| 276 | + .split("\n") |
| 277 | + .map((row) => JSON.parse(row).type); |
| 278 | + expect(types).toEqual(["baseline", "entry"]); |
| 279 | + }); |
| 280 | +}); |
0 commit comments