tests/pocketServer.test.ts
Rama main · réplica de lectura del repositorio original.
// The pocket server: `/api/*` answered inside a phone's WebView.
//
// What is under test is the whole of what a pocket vault CAN do and the
// honesty of what it cannot. Three claims, and each of them is a promise the
// product makes out loud in docs/mobile.md:
//
// 1. The routes the web client depends on to read, write, navigate and
// search a vault answer with the SAME wire shapes the server answers
// with — because it is the same client, unmodified, and a shape that
// drifts is a screen that renders nothing with no error anywhere.
// 2. A save carries the same `baseMtimeMs` precondition and answers the
// same `409 code:"stale"`. That precondition is the entire reason two
// writers on one vault is survivable, and a pocket vault has two by
// definition.
// 3. What it cannot do, it REFUSES with a reason. A 501 naming the missing
// thing is a sentence a reader can act on; an empty list is a vault that
// looks broken.
//
// The filesystem is a Map (tests/helpers/memoryFs.ts) and git is a stub: what
// is interesting here is the server, and a server is only interesting in what
// it does with a filesystem, not in which one it was given.
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { PocketIndex, isNote } from "../mobile/src/pocket/index.ts";
import { createPocketServer, isoToday, type PocketGit, type PocketRequest, type PocketResponse } from "../mobile/src/pocket/server.ts";
import { createVaultIo, isHiddenPath, safeVaultPath } from "../mobile/src/pocket/vaultIo.ts";
import { makeMemoryFs, type MemoryFs } from "./helpers/memoryFs.ts";
import type { Backlink, MeData, NoteData, PocketSyncStatus, SearchHit, SettingsResponse, TagCount, TreeNode } from "../shared/types.ts";
const ROOT = "/vault";
const VAULT: Record<string, string> = {
"/vault/Welcome.md": [
"---",
"title: Welcome",
"tags: [start-here, guide]",
"date: 2026-01-02",
"---",
"",
"# Welcome",
"",
"This is the room. It links to [[Ideas/Astrolabe]] and to [[Missing Note]].",
"",
"",
"",
"- [ ] read the guide",
].join("\n"),
"/vault/Ideas/Astrolabe.md": [
"---",
"aliases:",
" - The Instrument",
"tags:",
" - guide",
"status: drafting",
"---",
"",
"## On the instrument",
"",
"An astrolabe measures the sky. #astronomy",
"",
"Back to [[Welcome]].",
].join("\n"),
"/vault/Ideas/قراءة.md": ["# قراءة", "", "الكتابة بالعربيّة مع التشكيل: الكِتَابَة.", ""].join("\n"),
"/vault/Media/diagram.png": "\u0089PNG\r\n\u001a\nnot really a png, but bytes",
};
function makeServer(seed: Record<string, string> = VAULT): {
call(method: string, url: string, body?: unknown, headers?: Record<string, string>): Promise<PocketResponse>;
json(method: string, url: string, body?: unknown): Promise<unknown>;
fs: MemoryFs;
index: PocketIndex;
commits: string[];
} {
const fs = makeMemoryFs(seed);
const io = createVaultIo(fs, ROOT);
const index = new PocketIndex();
const commits: string[] = [];
const git: PocketGit = {
async commit(message) {
commits.push(message);
return `sha${commits.length}`;
},
async history(path) {
return [
{
sha: "abc1234def",
short: "abc1234",
iso: "2026-02-01T10:00:00.000Z",
subject: "Astrolabe pocket: a note",
path,
added: null,
removed: null,
},
];
},
async blobAt() {
return "the older text\n";
},
};
const held = new Map<string, unknown>();
const server = createPocketServer({
io,
git,
store: {
async get(key) {
return held.get(key) ?? null;
},
async set(key, value) {
held.set(key, value);
},
},
index,
repo: { name: "owner/vault", branch: "main" },
now: () => 1_700_000_000_000,
});
const call = async (
method: string,
url: string,
body?: unknown,
headers?: Record<string, string>,
): Promise<PocketResponse> => {
const request: PocketRequest = { method, url, headers: headers ?? {} };
if (body !== undefined) request.body = JSON.stringify(body);
return server.handle(request);
};
return {
call,
async json(method, url, body) {
const answer = await call(method, url, body);
return JSON.parse(String(answer.body));
},
fs,
index,
commits,
};
}
/** Build the index the way `PocketSession.build()` does, so what the routes
* read is what a real open would have read. */
async function loaded(seed: Record<string, string> = VAULT): Promise<ReturnType<typeof makeServer>> {
const made = makeServer(seed);
const io = createVaultIo(made.fs, ROOT);
for (const file of await io.list()) {
if (isHiddenPath(file.path)) continue;
if (isNote(file.path)) {
const content = await io.readText(file.path);
if (content !== null) made.index.put(file.path, content, file.mtimeMs);
} else made.index.putAsset(file.path, file.size, file.mtimeMs);
}
return made;
}
describe("the pocket server — the session", () => {
it("is the owner, alone, with no public half", async () => {
const server = await loaded();
const me = (await server.json("GET", "/api/me")) as { admin: boolean; public: boolean; protected: boolean; siteName: string };
// Every one of these is load-bearing in the client: `admin` decides
// whether the editor exists at all, `protected` whether a sign-in screen
// is meaningful, `public` whether visitor scoping is on.
assert.equal(me.admin, true);
assert.equal(me.protected, false);
assert.equal(me.public, false);
assert.equal(me.siteName, "owner/vault");
});
it("refuses to pretend there is somebody to sign in as", async () => {
const server = await loaded();
const answer = await server.call("POST", "/api/login", { password: "x" });
assert.equal(answer.status, 501);
});
});
describe("the pocket server — the tree", () => {
it("is folders, then notes, then attachments, alpha within each", async () => {
const server = await loaded();
const tree = (await server.json("GET", "/api/tree")) as TreeNode;
assert.equal(tree.type, "folder");
const top = (tree.children ?? []).map((n) => `${n.type}:${n.name}`);
assert.deepEqual(top, ["folder:Ideas", "folder:Media", "file:Welcome.md"]);
const media = (tree.children ?? []).find((n) => n.name === "Media");
const image = media?.children?.[0];
// An attachment carries its kind and size: a folder of images that
// expands to nothing reads as "my files are gone".
assert.equal(image?.attachment?.kind, "image");
assert.equal(image?.attachment?.ext, "png");
assert.ok((image?.attachment?.size ?? 0) > 0);
});
it("never shows the repository", async () => {
const server = await loaded({ ...VAULT, "/vault/.git/config": "[core]\n", "/vault/.trash/Old.md": "gone" });
const tree = (await server.json("GET", "/api/tree")) as TreeNode;
const names = (tree.children ?? []).map((n) => n.name);
assert.ok(!names.includes(".git"));
assert.ok(!names.includes(".trash"));
});
it("refuses a path that leaves the vault, before it reaches the filesystem", () => {
assert.throws(() => safeVaultPath("../../etc/passwd"));
assert.throws(() => safeVaultPath("Notes/../../secret"));
assert.throws(() => safeVaultPath(".git/config"));
assert.throws(() => safeVaultPath("a\u0000b"));
assert.equal(safeVaultPath("/Ideas/Astrolabe.md"), "Ideas/Astrolabe.md");
});
});
describe("the pocket server — reading and writing a note", () => {
it("answers a note with its bytes and its mtime, and a miss with 404", async () => {
const server = await loaded();
const note = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData;
assert.equal(note.path, "Welcome.md");
assert.match(note.content, /# Welcome/);
assert.ok(note.mtimeMs > 0);
assert.equal((await server.call("GET", "/api/note?path=Nowhere.md")).status, 404);
});
it("saves, reindexes and commits — one commit per save", async () => {
const server = await loaded();
const before = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData;
server.fs.tick();
const written = (await server.json("PUT", "/api/note?path=Welcome.md", {
content: "# Welcome\n\nA changed line about kestrels.\n",
baseMtimeMs: before.mtimeMs,
})) as NoteData;
assert.match(written.content, /kestrels/);
assert.equal(server.commits.length, 1);
assert.equal(server.commits[0], "Astrolabe pocket: Welcome");
// The index moved with the file: a save that does not reindex is a search
// box that answers about yesterday.
const hits = (await server.json("GET", "/api/search?q=kestrels")) as SearchHit[];
assert.equal(hits[0]?.path, "Welcome.md");
});
it("REFUSES a save that would overwrite a version the writer never saw", async () => {
const server = await loaded();
const first = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData;
server.fs.tick();
await server.call("PUT", "/api/note?path=Welcome.md", { content: "from the laptop\n" });
server.fs.tick();
const answer = await server.call("PUT", "/api/note?path=Welcome.md", {
content: "from the phone\n",
baseMtimeMs: first.mtimeMs,
});
assert.equal(answer.status, 409);
assert.equal(JSON.parse(String(answer.body)).code, "stale");
// …and nothing was written.
const now = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData;
assert.equal(now.content, "from the laptop\n");
});
it("creates, renames and deletes, and a delete leaves an undo behind", async () => {
const server = await loaded();
assert.equal((await server.call("POST", "/api/note", { path: "Scratch.md" })).status, 200);
assert.equal((await server.call("POST", "/api/note", { path: "Scratch.md" })).status, 409);
assert.equal((await server.call("POST", "/api/rename", { path: "Scratch.md", toPath: "Ideas/Scratch.md" })).status, 200);
assert.equal((await server.call("GET", "/api/note?path=Scratch.md")).status, 404);
assert.equal((await server.call("GET", "/api/note?path=Ideas/Scratch.md")).status, 200);
const deleted = (await server.json("DELETE", "/api/note?path=Ideas/Scratch.md")) as { ok: boolean; trashPath?: string };
assert.equal(deleted.ok, true);
assert.ok(deleted.trashPath?.startsWith(".trash/"));
const trash = (await server.json("GET", "/api/trash")) as { name: string }[];
assert.equal(trash.length, 1);
const restored = (await server.json("POST", "/api/trash/restore", { name: trash[0]?.name })) as { path: string };
assert.equal(restored.path, "Ideas/Scratch.md");
});
it("answers `note/state` for the open tabs and nulls what is gone", async () => {
const server = await loaded();
const states = (await server.json(
"GET",
"/api/note/state?path=Welcome.md&path=Nowhere.md",
)) as { states: { path: string; mtimeMs: number | null }[] };
assert.equal(states.states.length, 2);
assert.ok((states.states[0]?.mtimeMs ?? 0) > 0);
assert.equal(states.states[1]?.mtimeMs, null);
});
});
describe("the pocket server — finding things", () => {
it("searches titles and bodies, and folds Arabic the way the vault is typed", async () => {
const server = await loaded();
const byTitle = (await server.json("GET", "/api/search?q=Astrolabe")) as SearchHit[];
assert.equal(byTitle[0]?.path, "Ideas/Astrolabe.md");
const byBody = (await server.json("GET", "/api/search?q=sky")) as SearchHit[];
assert.ok(byBody.some((hit) => hit.path === "Ideas/Astrolabe.md"));
// The note carries `الكِتَابَة` with harakat; the query has none. One key.
const arabic = (await server.json("GET", `/api/search?q=${encodeURIComponent("الكتابة")}`)) as SearchHit[];
assert.ok(arabic.some((hit) => hit.path === "Ideas/قراءة.md"), "pointed Arabic and plain Arabic are one key");
});
it("honours the search operators, and narrows to nothing rather than ignoring one", async () => {
const server = await loaded();
const tagged = (await server.json("GET", "/api/search?q=tag%3Aguide")) as SearchHit[];
assert.deepEqual(tagged.map((h) => h.path).sort(), ["Ideas/Astrolabe.md", "Welcome.md"]);
const books = (await server.json("GET", "/api/search?q=in%3Abooks+sky")) as SearchHit[];
assert.deepEqual(books, [], "an operator the pocket cannot answer means no results, not all results");
});
it("gives a snippet with no raw markdown in it", async () => {
const server = await loaded();
const hits = (await server.json("GET", "/api/search?q=instrument")) as SearchHit[];
const snippet = hits[0]?.snippet ?? "";
assert.ok(snippet.length > 0);
assert.ok(!snippet.includes("##"), "a heading's hashes never reach the reader");
assert.ok(!/\[\[/.test(snippet), "wikilink brackets never reach the reader");
});
it("resolves a wikilink by the server's rule, and answers a miss with null", async () => {
const server = await loaded();
assert.deepEqual(await server.json("GET", "/api/resolve?name=Astrolabe"), { path: "Ideas/Astrolabe.md" });
assert.deepEqual(await server.json("GET", "/api/resolve?name=Ideas/Astrolabe"), { path: "Ideas/Astrolabe.md" });
assert.deepEqual(await server.json("GET", "/api/resolve?name=The%20Instrument"), { path: "Ideas/Astrolabe.md" });
// A miss is 200 with a null, never a 404: unresolved names are ordinary.
assert.deepEqual(await server.json("GET", "/api/resolve?name=Missing%20Note"), { path: null });
});
it("carries backlinks with the line they sit on and the sentence around them", async () => {
const server = await loaded();
const back = (await server.json("GET", "/api/backlinks?path=Ideas/Astrolabe.md")) as Backlink[];
assert.equal(back.length, 1);
assert.equal(back[0]?.path, "Welcome.md");
assert.ok(back[0]?.line && back[0].line > 1, "the line counts in the FULL source, frontmatter included");
assert.match(back[0]?.context ?? "", /the room/);
});
it("draws a graph whose edges are resolved links, counted once", async () => {
const server = await loaded();
const graph = (await server.json("GET", "/api/graph")) as { nodes: { id: string; links: number }[]; edges: { source: string; target: string }[] };
assert.equal(graph.nodes.length, 3);
assert.deepEqual(
graph.edges.map((e) => `${e.source}→${e.target}`).sort(),
["Ideas/Astrolabe.md→Welcome.md", "Welcome.md→Ideas/Astrolabe.md"],
);
// `[[Missing Note]]` resolves to nothing and draws no edge to nowhere.
assert.ok(!graph.edges.some((e) => e.target.includes("Missing")));
});
it("counts tags and properties", async () => {
const server = await loaded();
const tags = (await server.json("GET", "/api/tags")) as TagCount[];
assert.equal(tags.find((t) => t.tag === "guide")?.count, 2);
assert.equal(tags.find((t) => t.tag === "astronomy")?.count, 1);
const props = (await server.json("GET", "/api/props")) as { key: string; values: { value: string }[] }[];
assert.equal(props.find((p) => p.key === "status")?.values[0]?.value, "drafting");
assert.ok(!props.some((p) => p.key === "tags"), "tags have a shelf of their own");
});
});
describe("the pocket server — attachments", () => {
it("serves an attachment's bytes with a type, and refuses to serve a note as one", async () => {
const server = await loaded();
const answer = await server.call("GET", "/api/file?path=Media/diagram.png");
assert.equal(answer.status, 200);
assert.equal(answer.headers["Content-Type"], "image/png");
assert.equal(answer.headers["X-Content-Type-Options"], "nosniff");
assert.ok(answer.body instanceof Uint8Array);
assert.equal((await server.call("GET", "/api/file?path=Welcome.md")).status, 400);
});
it("answers a byte range, because a book is read a page at a time", async () => {
const server = await loaded();
const whole = await server.call("GET", "/api/file?path=Media/diagram.png");
const size = (whole.body as Uint8Array).byteLength;
const part = await server.call("GET", "/api/file?path=Media/diagram.png", undefined, { range: "bytes=0-3" });
assert.equal(part.status, 206);
assert.equal(part.headers["Content-Range"], `bytes 0-3/${size}`);
assert.equal((part.body as Uint8Array).byteLength, 4);
const silly = await server.call("GET", "/api/file?path=Media/diagram.png", undefined, { range: "bytes=99999-" });
assert.equal(silly.status, 416);
});
});
describe("the pocket server — a voice note is kept, not transcribed (3.24.0)", () => {
// A 16-byte WebM header is enough: the pocket sniffs the container and
// writes the bytes, it never decodes them.
const webm = Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42, 0x86, 0x81, 0x01, 0x42, 0xf7, 0x81, 0x01, 0x42, 0xf2, 0x81]).toString("base64");
it("writes the recording into the vault and links it from the day's inbox, in one commit", async () => {
const server = await loaded();
const answer = await server.call("POST", "/api/voice", { audio: webm, date: "2026-09-23", time: "14:02" });
assert.equal(answer.status, 200);
const job = JSON.parse(String(answer.body)) as { status: string; audio: string; notePath: string; error: string };
assert.equal(job.status, "kept");
assert.equal(job.error, "pocket");
// The pocket's own attachment setting: vault-root, then Voice/.
assert.equal(job.audio, "Voice/2026-09-23 1402.webm");
assert.equal(job.notePath, "Inbox/2026-09-23.md");
const note = (await server.json("GET", "/api/note?path=Inbox/2026-09-23.md")) as NoteData;
assert.equal(note.content, "- 14:02 — [[Voice/2026-09-23 1402.webm#t=0|🎙]]\n");
const file = await server.call("GET", "/api/file?path=Voice/2026-09-23 1402.webm");
assert.equal(file.status, 200);
assert.equal(server.commits.at(-1), "Astrolabe pocket: voice note");
// A second one in the same minute is (2), and joins the same note.
const again = JSON.parse(String((await server.call("POST", "/api/voice", { audio: webm, date: "2026-09-23", time: "14:02" })).body)) as { audio: string };
assert.equal(again.audio, "Voice/2026-09-23 1402 (2).webm");
const both = (await server.json("GET", "/api/note?path=Inbox/2026-09-23.md")) as NoteData;
assert.equal(both.content.split("\n").filter(Boolean).length, 2);
});
it("refuses what is not a recording, and the transcript half is a 501 with the reason", async () => {
const server = await loaded();
const bad = await server.call("POST", "/api/voice", { audio: Buffer.from("hello").toString("base64") });
assert.equal(bad.status, 415);
for (const route of ["/api/voice/engine", "/api/voice/some-job"]) {
const answer = await server.call("GET", route);
assert.equal(answer.status, 501);
const body = JSON.parse(String(answer.body)) as { error: string; code: string };
assert.equal(body.code, "pocket");
assert.match(body.error, /server/);
}
});
it("answers the voice settings as a pocket's facts, and refuses to store a model", async () => {
const server = await loaded();
const settings = (await server.json("GET", "/api/settings")) as SettingsResponse;
assert.deepEqual(settings.effective.voice, { model: "off", backend: "auto", language: "auto", keepAudio: true });
const answer = await server.call("PATCH", "/api/settings", { voice: { model: "small-q5_1" } });
assert.equal(answer.status, 501);
});
it("refuses a voices folder and an external speaker with the reason, and scans nothing", async () => {
const server = await loaded();
const settings = (await server.json("GET", "/api/settings")) as SettingsResponse;
assert.equal(settings.effective.speak.voicesDir, null);
assert.equal(settings.effective.speak.external, null);
for (const speak of [{ voicesDir: "/home/me/piper-voices" }, { external: { command: "piper --output_file {out}", langs: ["fr"] } }]) {
const answer = await server.call("PATCH", "/api/settings", { speak });
assert.equal(answer.status, 501);
const body = JSON.parse(String(answer.body)) as { error: string; code: string; fields: string[] };
assert.equal(body.code, "pocket");
assert.match(body.error, /server/);
assert.deepEqual(body.fields, [`speak.${Object.keys(speak)[0]}`]);
}
const rescan = await server.call("POST", "/api/speak/voices/rescan");
assert.equal(rescan.status, 501);
});
});
describe("the pocket server — a captured line (3.27.0)", () => {
// Today's capture field on the phone shell asks the pocket what it asks an
// instance: `POST /api/capture`, one line under `## Captured`, the same
// bytes shared/capture.ts writes on the server, and the same refusal of a
// note that moved under the index.
it("appends under ## Captured in the note named, and commits it like a save", async () => {
const server = await loaded();
const answer = await server.call("POST", "/api/capture", { text: "call the binder", path: "Welcome.md", time: "09:15" });
assert.equal(answer.status, 200);
assert.deepEqual(JSON.parse(String(answer.body)), { ok: true, path: "Welcome.md" });
const note = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData;
assert.match(note.content, /\n## Captured\n\n- 09:15 call the binder\n$/);
assert.equal(server.commits.at(-1), "Astrolabe pocket: Welcome");
// A second line joins the same section.
await server.call("POST", "/api/capture", { text: "and the framer", path: "Welcome.md", time: "09:20" });
const again = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData;
assert.match(again.content, /- 09:15 call the binder\n- 09:20 and the framer\n$/);
});
it("writes the day's inbox — where the share sheet and a kept voice note go — when no note is named", async () => {
const server = await loaded();
const answer = await server.call("POST", "/api/capture", { text: "a thought", time: "22:40" });
assert.equal(answer.status, 200);
const { path } = JSON.parse(String(answer.body)) as { path: string };
assert.equal(path, `Inbox/${isoToday(1_700_000_000_000)}.md`);
const note = (await server.json("GET", `/api/note?path=${encodeURIComponent(path)}`)) as NoteData;
assert.equal(note.content, "## Captured\n\n- 22:40 a thought\n");
// …and the tree has it at once: the index took the write.
const tree = JSON.stringify(await server.json("GET", "/api/tree"));
assert.ok(tree.includes(path));
});
it("refuses an empty line, a path that is not a note, and a note that moved under the index", async () => {
const server = await loaded();
const empty = await server.call("POST", "/api/capture", { text: " " });
assert.equal(empty.status, 400);
assert.equal(JSON.parse(String(empty.body)).code, "captureEmpty");
assert.equal((await server.call("POST", "/api/capture", { text: "x", path: "Media/diagram.png" })).status, 400);
// A sync pull rewrote the file behind the index's back.
server.fs.tick();
await server.fs.promises.writeFile(`${ROOT}/Welcome.md`, "from the laptop\n", "utf8");
const stale = await server.call("POST", "/api/capture", { text: "lost?", path: "Welcome.md", time: "10:00" });
assert.equal(stale.status, 409);
assert.equal(JSON.parse(String(stale.body)).code, "stale");
const text = await server.fs.promises.readFile(`${ROOT}/Welcome.md`, "utf8");
assert.equal(text, "from the laptop\n");
});
});
describe("the pocket server — git is the version history", () => {
it("lists a note's past and can read one version back", async () => {
const server = await loaded();
const history = (await server.json("GET", "/api/history?path=Welcome.md")) as { repo: boolean; revisions: { sha: string }[] };
assert.equal(history.repo, true);
assert.equal(history.revisions[0]?.sha, "abc1234def");
const at = Date.parse("2026-02-01T10:00:00.000Z");
const one = (await server.json("GET", `/api/versions/one?path=Welcome.md&at=${at}`)) as { content: string };
assert.equal(one.content, "the older text\n");
const restored = (await server.json("POST", "/api/versions/restore", { path: "Welcome.md", at })) as NoteData;
assert.equal(restored.content, "the older text\n");
});
});
describe("the pocket server — what it cannot do, it says", () => {
const cases: [string, string][] = [
["POST", "/api/publish"],
["GET", "/api/posts"],
["GET", "/api/comments?path=Welcome.md"],
["GET", "/api/clip/token"],
["POST", "/api/fonts/upload"],
["GET", "/api/sync/status"],
["GET", "/api/design/active"],
["GET", "/api/export"],
["POST", "/api/replace"],
["GET", "/api/feeds"],
["POST", "/api/feeds/keep"],
["GET", "/api/webmentions"],
["GET", "/api/webmentions/status"],
["POST", "/api/webmentions/1/verify"],
["POST", "/api/import/preview"],
];
for (const [method, route] of cases) {
it(`${method} ${route} is a 501 with a reason in it`, async () => {
const server = await loaded();
const answer = await server.call(method, route, method === "GET" ? undefined : {});
assert.equal(answer.status, 501);
const body = JSON.parse(String(answer.body)) as { error: string; code: string };
assert.equal(body.code, "pocket");
// Not a stub sentence: it has to name the thing that is missing.
assert.ok(body.error.length > 30, `"${body.error}" does not explain anything`);
assert.ok(/server|visitor|public|network|disk|data directory|whole vault|phone|repository|corpus|installation/i.test(body.error));
});
}
it("an unknown route is a plain 404, as it is on the server", async () => {
const server = await loaded();
assert.equal((await server.call("GET", "/api/nonsense")).status, 404);
});
});
describe("the pocket server — device state stays on the device", () => {
it("merges preferences by their timestamps and hands the whole map back", async () => {
const server = await loaded();
await server.json("PUT", "/api/prefs", { keys: { "astrolabe.theme": { v: "sumi", t: 10 } } });
const older = (await server.json("PUT", "/api/prefs", {
keys: { "astrolabe.theme": { v: "linen", t: 5 } },
})) as { keys: Record<string, { v: string }> };
assert.equal(older.keys["astrolabe.theme"]?.v, "sumi", "the newest write wins, whichever order it arrived in");
const newer = (await server.json("PUT", "/api/prefs", {
keys: { "astrolabe.theme": { v: "linen", t: 20 } },
})) as { keys: Record<string, { v: string }> };
assert.equal(newer.keys["astrolabe.theme"]?.v, "linen");
});
it("keeps the workspace, and refuses what is not one", async () => {
const server = await loaded();
assert.deepEqual(await server.json("PUT", "/api/state/workspace", { workspace: { panes: [] } }), { ok: true });
assert.deepEqual(await server.json("GET", "/api/state/workspace"), { workspace: { panes: [] } });
assert.deepEqual(await server.json("PUT", "/api/state/workspace", { workspace: "nope" }), { ok: false });
});
});
describe("the pocket server — the vault by date (Today, the Timeline)", () => {
it("answers the Timeline's notes with the day each belongs to", async () => {
const server = await loaded({
...VAULT,
"/vault/Inbox/2026-01-05.md": "- 09:10 — a line [[Voice/1.webm#t=0|🎙]]\n- 09:30 — another\n",
});
const rows = (await server.json("GET", "/api/timeline")) as { path: string; day: string | null; excerpt: string; captured: number; voice: number }[];
const welcome = rows.find((r) => r.path === "Welcome.md");
assert.equal(welcome?.day, "2026-01-02", "the frontmatter's day names itself");
assert.match(welcome?.excerpt ?? "", /This is the room/);
const inbox = rows.find((r) => r.path === "Inbox/2026-01-05.md");
assert.deepEqual([inbox?.captured, inbox?.voice], [2, 1]);
});
it("answers on this day with the excerpt, earlier years only", async () => {
const server = await loaded();
const hits = (await server.json("GET", "/api/onthisday?date=2027-01-02")) as { path: string; year: number; excerpt: string }[];
assert.deepEqual(hits.map((h) => [h.path, h.year]), [["Welcome.md", 2026]]);
assert.match(hits[0]?.excerpt ?? "", /This is the room/);
assert.deepEqual(await server.json("GET", "/api/onthisday?date=2026-01-02"), []);
});
});
describe("the pocket server — the shelves that are note-based", () => {
it("finds the tasks in the vault", async () => {
const server = await loaded();
const tasks = (await server.json("GET", "/api/tasks")) as { path: string; task: { done: boolean } }[];
assert.equal(tasks.length, 1);
assert.equal(tasks[0]?.path, "Welcome.md");
assert.equal(tasks[0]?.task.done, false);
});
it("ticks a task by rewriting exactly its line", async () => {
const server = await loaded();
const tasks = (await server.json("GET", "/api/tasks")) as { path: string; task: { line: number } }[];
const line = tasks[0]?.task.line ?? 0;
await server.json("POST", "/api/task", { path: "Welcome.md", line, done: true, today: "2026-02-02" });
const note = (await server.json("GET", "/api/note?path=Welcome.md")) as NoteData;
assert.match(note.content, /- \[x\] read the guide/);
assert.match(note.content, /# Welcome/, "the rest of the note is the bytes it was");
});
it("always has an Orbits shelf to point at, even with no deck fence", async () => {
const server = await loaded();
const decks = (await server.json("GET", "/api/orbits?today=2026-02-02")) as { path: string; implicit: boolean }[];
assert.equal(decks.at(-1)?.path, "*");
assert.equal(decks.at(-1)?.implicit, true);
});
});
// ───────────────────────────────────────────────────────────────────────────
// THE SETTINGS, AND THE VAULT'S OWN SYNC (3.22.2)
//
// A friend of the owner installed the APK, opened a vault from GitHub and
// reported, in Arabic: "the sync settings don't save — whatever I type and
// press Save, it doesn't save; the sync switch keeps turning itself back off."
// Two things were wrong at once, and both are pinned below.
//
// 1. Settings were kept in the phone's own key-value store, so nothing a
// reader chose ever reached the laptop over the same repository. They go
// into `.astrolabe/settings.json` now — the file server/configMirror.ts
// mirrors for an instance, in the same shape — and are committed like a
// note save, so a push carries them.
// 2. The git-sync rows described a SERVER driving git at a remote, which a
// pocket vault has not got. The PATCH refuses those keys with the reason
// rather than accepting them and answering a fixed block back.
// ───────────────────────────────────────────────────────────────────────────
/** One file out of the working tree, by the same rules the server reads it. */
function vaultFile(fs: MemoryFs, rel: string): Promise<string | null> {
return createVaultIo(fs, ROOT).readText(rel);
}
/** A SECOND server over the SAME filesystem: a phone reopened, or the laptop
* pulling. Its device store is empty, which is the whole point — nothing that
* survives here survived in memory. */
function reopened(fs: MemoryFs): { json(method: string, url: string, body?: unknown): Promise<unknown>; index: PocketIndex } {
const index = new PocketIndex();
const server = createPocketServer({
io: createVaultIo(fs, ROOT),
git: {
async commit() {
return null;
},
async history() {
return [];
},
async blobAt() {
return null;
},
},
store: {
async get() {
return null;
},
async set() {
/* a fresh device remembers nothing */
},
},
index,
repo: { name: "owner/vault", branch: "main" },
now: () => 1_700_000_000_000,
});
return {
async json(method, url, body) {
const request: PocketRequest = { method, url, headers: {} };
if (body !== undefined) request.body = JSON.stringify(body);
return JSON.parse(String((await server.handle(request)).body));
},
index,
};
}
describe("the pocket server — settings that travel with the vault", () => {
it("writes them into the repository and reads them back from a fresh server", async () => {
const first = await loaded();
const saved = (await first.json("PATCH", "/api/settings", {
siteName: "The Canopy",
dateCalendar: "hijri",
dailyFolder: "journal",
})) as SettingsResponse;
assert.equal(saved.effective.siteName, "The Canopy");
assert.equal(saved.effective.dateCalendar, "hijri");
// IN THE VAULT, not on the phone: the same file, in the same shape, that
// every instance mirrors into `<vault>/.astrolabe/`.
const onDisk = await vaultFile(first.fs, ".astrolabe/settings.json");
assert.ok(onDisk !== null, "the settings file is in the working tree");
assert.deepEqual(JSON.parse(onDisk), {
siteName: "The Canopy",
dateCalendar: "hijri",
dailyFolder: "journal",
});
// Committed like a note save, so a push carries it to the laptop.
assert.ok(first.commits.includes("Astrolabe pocket: settings"));
// The reload the friend performed. A new server over the same filesystem,
// with an empty device store: everything survives, because none of it was
// ever on the device.
const again = reopened(first.fs);
const read = (await again.json("GET", "/api/settings")) as SettingsResponse;
assert.equal(read.effective.siteName, "The Canopy");
assert.equal(read.effective.dateCalendar, "hijri");
assert.equal(read.effective.dailyFolder, "journal");
});
it("clears a key with null and leaves the rest standing", async () => {
const server = await loaded();
await server.json("PATCH", "/api/settings", { siteName: "The Canopy", tagline: "notes" });
const after = (await server.json("PATCH", "/api/settings", { tagline: null })) as SettingsResponse;
assert.equal(after.effective.siteName, "The Canopy");
assert.equal(after.effective.tagline, null);
});
it("carries the properties card's switches on /api/me, so a hidden card stays hidden on the phone (3.40.0)", async () => {
const server = await loaded();
const before = (await server.json("GET", "/api/me")) as MeData;
assert.equal(before.propsCard, undefined, "a default vault says nothing");
await server.json("PATCH", "/api/settings", { propsCard: false, emptyPropsCard: false });
const me = (await reopened(server.fs).json("GET", "/api/me")) as MeData;
assert.equal(me.propsCard, false);
assert.equal(me.emptyPropsCard, false);
const back = (await server.json("PATCH", "/api/settings", { propsCard: null })) as SettingsResponse;
assert.equal(back.effective.propsCard, true);
assert.equal(((await server.json("GET", "/api/me")) as MeData).propsCard, undefined);
});
it("never shows the settings file as part of the vault", async () => {
const server = await loaded();
await server.json("PATCH", "/api/settings", { siteName: "The Canopy" });
const again = reopened(server.fs);
const io = createVaultIo(server.fs, ROOT);
for (const file of await io.list()) {
if (isHiddenPath(file.path)) continue;
if (isNote(file.path)) again.index.put(file.path, String(await io.readText(file.path)), file.mtimeMs);
else again.index.putAsset(file.path, file.size, file.mtimeMs);
}
const tree = (await again.json("GET", "/api/tree")) as TreeNode;
assert.ok(!(tree.children ?? []).some((n) => n.name === ".astrolabe"));
});
it("refuses the git-sync fields with the reason, and writes nothing at all", async () => {
const server = await loaded();
// The friend's exact edit: a remote, a branch and the master switch.
const answer = await server.call("PATCH", "/api/settings", {
gitSync: { enabled: true, remote: "https://example.com/v.git", branch: "main" },
siteName: "The Canopy",
});
assert.equal(answer.status, 501);
const body = JSON.parse(String(answer.body)) as { error: string; code: string; fields: string[] };
assert.equal(body.code, "pocket");
assert.deepEqual(body.fields, ["gitSync"]);
assert.match(body.error, /Backup & sync/);
// NOTHING was written — not even the legal half of the patch. A save that
// lands halfway and says nothing is the reported bug, restated.
assert.equal(await vaultFile(server.fs, ".astrolabe/settings.json"), null);
const read = (await server.json("GET", "/api/settings")) as SettingsResponse;
assert.notEqual(read.effective.siteName, "The Canopy");
});
it("refuses every key that needs an instance behind it, in a sentence", async () => {
const server = await loaded();
for (const patch of [
{ gitToken: "ghp_x" },
{ commentsEnabled: true },
{ publicLayout: "blog" },
{ languageFilter: "ar" },
{ favicon: "Media/icon.png" },
{ fonts: { prose: "lora" } },
{ noteVersions: false },
{ pdfSearch: true },
{ feeds: { fetch: true } },
{ webmentions: { accept: true } },
{ fediverse: { enabled: true } },
]) {
const answer = await server.call("PATCH", "/api/settings", patch);
assert.equal(answer.status, 501, `${Object.keys(patch)[0]} must be refused`);
const body = JSON.parse(String(answer.body)) as { error: string };
assert.ok(body.error.length > 20, "the refusal is a sentence, not a code");
}
});
it("answers a laptop's public-site settings as the facts a phone actually has", async () => {
// The repository has been open on an instance, so the file carries a
// public site's configuration. The phone must describe ITSELF.
const server = await loaded({
...VAULT,
"/vault/.astrolabe/settings.json": JSON.stringify({
siteName: "The Canopy",
commentsEnabled: true,
defaultTheme: "cinnabar",
footer: "© {year}",
fonts: { prose: "lora" },
}),
});
const read = (await server.json("GET", "/api/settings")) as SettingsResponse;
assert.equal(read.effective.siteName, "The Canopy", "the vault's name IS the vault's");
assert.equal(read.effective.commentsEnabled, false);
assert.equal(read.effective.defaultTheme, "follow");
assert.equal(read.effective.footer, null);
assert.equal(read.effective.fonts.prose, "system");
// And the STORED half, which is what prefills the panel's fields.
assert.equal(read.commentsEnabled, undefined);
});
});
describe("the pocket server — the vault's own sync", () => {
const status: PocketSyncStatus = {
repo: "owner/vault",
branch: "main",
phase: "idle",
ahead: 2,
syncedAtMs: 1_699_999_000_000,
online: true,
error: null,
conflicts: [{ path: "Welcome.md", phonePath: "Welcome (phone).md" }],
};
/** A server with a shell under it, the way pocket/session.ts builds one. */
function withShell(): { call(method: string, url: string): Promise<PocketResponse>; left(): boolean } {
const fs = makeMemoryFs(VAULT);
let left = false;
const server = createPocketServer({
io: createVaultIo(fs, ROOT),
git: {
async commit() {
return null;
},
async history() {
return [];
},
async blobAt() {
return null;
},
},
store: {
async get() {
return null;
},
async set() {},
},
index: new PocketIndex(),
repo: { name: "owner/vault", branch: "main" },
shell: {
syncState: () => status,
syncNow: async () => ({ ...status, ahead: 0, syncedAtMs: 1_700_000_000_000 }),
leave: async () => {
left = true;
},
},
now: () => 1_700_000_000_000,
});
return {
call: (method, url) => server.handle({ method, url, headers: {} }),
left: () => left,
};
}
it("answers the state the shell's own line is painted from", async () => {
const { call } = withShell();
const answer = await call("GET", "/api/pocket/sync");
assert.equal(answer.status, 200);
// Every field is load-bearing in the panel: the repository and the branch
// name the vault, and the other five decide which of nine sentences the
// line is (shared/pocketSync.ts).
assert.deepEqual(JSON.parse(String(answer.body)), status);
});
it("pulls and pushes, and answers the state afterwards", async () => {
const { call } = withShell();
const body = JSON.parse(String((await call("POST", "/api/pocket/sync")).body)) as PocketSyncStatus;
assert.equal(body.ahead, 0);
assert.equal(body.syncedAtMs, 1_700_000_000_000);
});
it("forgets the vault when asked, and only when asked", async () => {
const { call, left } = withShell();
assert.equal(left(), false);
assert.deepEqual(JSON.parse(String((await call("POST", "/api/pocket/leave")).body)), { ok: true });
assert.equal(left(), true);
});
it("refuses with a reason when there is no shell under it", async () => {
const server = await loaded();
for (const [method, route] of [
["GET", "/api/pocket/sync"],
["POST", "/api/pocket/sync"],
["POST", "/api/pocket/leave"],
] as [string, string][]) {
const answer = await server.call(method, route);
assert.equal(answer.status, 501);
assert.equal((JSON.parse(String(answer.body)) as { code: string }).code, "pocket");
}
});
it("still refuses the SERVER's sync routes, which are a different thing", async () => {
const server = await loaded();
for (const route of ["/api/sync/status", "/api/sync/now", "/api/sync/travel"]) {
assert.equal((await server.call("GET", route)).status, 501);
}
});
it("tells the client where it is, so the panel can stop offering a server", async () => {
const server = await loaded();
const me = (await server.json("GET", "/api/me")) as { pocket?: boolean };
assert.equal(me.pocket, true);
});
});