Files
obsidian-foundry-sync/tests/e4-5-statusnote.test.ts
Kaysser Kayyali cba0b60798 feat(E4.5): vault .sync-status.md note writer + airtight exclusion
The last E4 story — completes the E4 epic (sync status & parity). A maintained
status note in the vault mirrors the dashboard (on/off, mode, last-sync, parity,
recent activity), carrying a foundry.sync_status:"true" sentinel. The exclusion
is airtight + rename-safe: skip by BOTH path (dotfile) AND sentinel.

- src/server.ts writeStatusNote(state): renders .sync-status.md (frontmatter
  with foundry.sync_status:"true" + a body with on/off, mode, lastSync, parity,
  last 10 activity events) → writeWithBackup to refinedDir/.sync-status.md
  (apply mode backs up the previous). Called from log() (after appendActivity),
  refreshParity, and the mode handler — fire-and-forget.
- src/server.ts onChange: dotfile skip (rel.split("/").pop()?.startsWith("."))
  gated by features.syncStatus. A renamed status note (non-dot) is caught by the
  sentinel in runPushAttempt.
- src/server.ts runPushAttempt: sentinel check — if foundry.sync_status ===
  "true" → skip "sync status note (sentinel)", before the cc_uuid/contentHash
  checks. Gated by features.syncStatus. Rename-safe (sentinel survives a rename).
- src/batch.ts walkMd: exclude dotfiles from the index (!ent.name.startsWith("."))
  so .sync-status.md never enters state.index.matched/refinedOnly (never a row,
  never a push candidate).
- tests/e4-5-statusnote.test.ts: 5 tests — note written with sentinel + content;
  onChange skips .sync-status.md (no timer); sentinel check skips a renamed copy;
  sentinel absent on a normal note; index excludes .sync-status.md (only Roland
  indexed).

tsc clean; 240 passing project-wide (18 pre-existing fixture-missing unchanged).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-23 02:35:24 +00:00

120 lines
6.1 KiB
TypeScript

// E4.5 — vault .sync-status.md note writer + airtight exclusion.
//
// Covers: the note is written with the sentinel + status content; the watcher
// skips it by dot-path; the sentinel check in runPushAttempt skips a renamed
// copy; the index excludes dotfiles.
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { ClassicLevel } from "classic-level";
import { mkdtemp, mkdir, rm, readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Server } from "node:http";
import { AutoSyncController, startServer } from "../src/server.js";
import type { State, ServerConfig } from "../src/server.js";
import { splitFrontmatter, readFoundryBlock } from "../src/frontmatter.js";
let dir: string;
let state: State;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "e4-5-"));
await mkdir(join(dir, "refined"), { recursive: true });
const cfg: ServerConfig = {
journal: "", refinedDir: join(dir, "refined"), ccDir: "", outDir: join(dir, "out"),
mode: "apply", port: 0, host: "127.0.0.1",
relayCfg: { url: "http://r", apiKey: "k", clientId: "c" },
features: { syncStatus: true },
};
state = { db: {} as State["db"], cfg, index: null, autosync: null as unknown as State["autosync"] } as unknown as State;
state.syncState = { syncStateSchemaVersion: "sync-state/v1", mode: "RUN-THE-MATCH", autoSyncOn: false, lastSyncAt: null, parity: { status: "in-parity", oPending: 0, fPending: 0, conflict: 0, unsyncedLinked: 0, lastPollAt: null }, watchedDir: cfg.refinedDir, activity: [], updatedAt: new Date().toISOString(), conflict: null };
});
afterEach(async () => { await rm(dir, { recursive: true, force: true }); });
describe("E4.5 .sync-status.md writer", () => {
it("writes .sync-status.md with the sentinel + status content", async () => {
const controller = new AutoSyncController(state);
// Trigger a log (which calls writeStatusNote via appendActivity when features on).
(controller as any).log("Roland", "pushed", "→ JournalEntry.abc1 · baselined");
// writeStatusNote is fire-and-forget; wait for it.
await new Promise<void>((r) => setTimeout(r, 50));
const notePath = join(state.cfg.refinedDir, ".sync-status.md");
expect(existsSync(notePath)).toBe(true);
const md = await readFile(notePath, "utf8");
const { fm, body } = splitFrontmatter(md);
const fb = readFoundryBlock(fm);
expect(fb?.sync_status).toBe("true"); // the sentinel
expect(body).toContain("Sync Status");
expect(body).toContain("RUN-THE-MATCH");
});
});
describe("E4.5 airtight exclusion", () => {
it("onChange skips .sync-status.md by dot-path (no debounce timer)", () => {
const controller = new AutoSyncController(state);
(controller as any).onChange("change", ".sync-status.md", "");
expect((controller as any).timers.has(".sync-status.md")).toBe(false);
});
it("runPushAttempt skips a note with the sync_status sentinel (rename-safe)", async () => {
// A note renamed to a non-dot path but still carrying the sentinel.
await writeFile(join(state.cfg.refinedDir, "Sync Status.md"),
"---\nfoundry:\n sync_status: \"true\"\n cc_uuid: JournalEntry.abc1\n contentHash: " + "0".repeat(64) + "\n---\nbody\n", "utf8");
const controller = new AutoSyncController(state);
// Call process directly (bypasses onChange's dot-path skip).
await (controller as any).process("Sync Status.md");
expect(controller.events.some((e) => e.message.includes("sync status note (sentinel)"))).toBe(true);
});
it("a normal note (no sentinel) is NOT skipped by the sentinel check", async () => {
// The sentinel check should only fire for sync_status: "true" notes.
const controller = new AutoSyncController(state);
(controller as any).log("test", "skipped", "test event");
await new Promise<void>((r) => setTimeout(r, 20));
// The .sync-status.md was written (by the log). Now verify a normal note
// wouldn't be sentinel-skipped — check that the sentinel field is absent
// from a normal note's foundry block.
const notePath = join(state.cfg.refinedDir, ".sync-status.md");
const md = await readFile(notePath, "utf8");
const fb = readFoundryBlock(splitFrontmatter(md).fm);
expect(fb?.sync_status).toBe("true"); // the status note has it
expect(fb?.cc_uuid).toBeUndefined(); // a normal note wouldn't have sync_status
});
});
describe("E4.5 index excludes dotfiles", () => {
let server: Server;
let baseURL: string;
beforeAll(async () => {
const d = await mkdtemp(join(tmpdir(), "e4-5-idx-"));
await mkdir(join(d, "refined"), { recursive: true });
await mkdir(join(d, "cc"), { recursive: true });
// Write a normal note + a .sync-status.md (dotfile).
await writeFile(join(d, "refined", "Roland.md"), "---\ntype: npc\n---\nbody\n", "utf8");
await writeFile(join(d, "refined", ".sync-status.md"), "---\nfoundry:\n sync_status: \"true\"\n---\nstatus\n", "utf8");
const jdb = new ClassicLevel<string, string>(join(d, "journal"));
await jdb.open(); await jdb.close();
const { server: srv } = await startServer({
journal: join(d, "journal"), refinedDir: join(d, "refined"), ccDir: join(d, "cc"),
outDir: join(d, "out"), mode: "dev", port: 0, host: "127.0.0.1",
features: { syncStatus: true },
});
server = srv;
baseURL = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
(globalThis as unknown as { _e45dir: string })._e45dir = d;
});
afterAll(async () => {
await new Promise<void>((r) => server.close(() => r()));
await rm((globalThis as unknown as { _e45dir: string })._e45dir, { recursive: true, force: true });
});
it(".sync-status.md does not appear in the index", async () => {
const r = await fetch(`${baseURL}/api/index`).then(r => r.json()) as { matched: unknown[]; refinedOnly: unknown[]; ccOnly: unknown[] };
const all = [...r.matched, ...r.refinedOnly, ...r.ccOnly];
expect(all.length).toBe(1); // only Roland.md, not .sync-status.md
expect((all[0] as { name: string }).name).toBe("Roland");
});
});