tests/pocketSession.test.ts
Rama main · réplica de lectura del repositorio original.
// ONE POCKET VAULT, ASSEMBLED (mobile/src/pocket/session.ts), offline.
//
// The session is the filesystem, the index, the repository and the server with
// one lifetime, and the sync loop over them: pull on open and on foreground,
// commit on save, push debounced and on background, one repository turn at a
// time. Under test here against the in-memory filesystem and a bare repository
// answered by `git http-backend` as a child process (tests/helpers/
// gitBackend.ts) — no socket, no network, no repository but the test's own.
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import { PocketSession } from "../mobile/src/pocket/session.ts";
import type { SyncState } from "../mobile/src/pocket/sync.ts";
import type { VaultEvent } from "../shared/types.ts";
import { makeMemoryFs, type MemoryFs } from "./helpers/memoryFs.ts";
import { makeRemote, type Remote } from "./helpers/gitBackend.ts";
const DIR = "/pocket/vault";
const SEED = {
"Welcome.md": "# Welcome\n\nLinks to [[Ideas/Astrolabe]].\n",
"Ideas/Astrolabe.md": "# Astrolabe\n",
"Media/diagram.png": "not really a png",
};
let remote: Remote | null = null;
const sessions: PocketSession[] = [];
afterEach(async () => {
// A save schedules a push 30s out; the test's own push clears it, and so
// does this, so no timer outlives its test.
for (const s of sessions.splice(0)) await s.push().catch(() => {});
remote?.cleanup();
remote = null;
});
interface Harness {
remote: Remote;
fs: MemoryFs;
session: PocketSession;
states: SyncState[];
events: VaultEvent[];
left: { value: number };
call(method: string, url: string, body?: unknown): Promise<{ status: number; json: any }>;
}
async function open(options: { leave?: boolean; clone?: boolean } = {}): Promise<Harness> {
remote = makeRemote(SEED);
const fs = makeMemoryFs();
const states: SyncState[] = [];
const events: VaultEvent[] = [];
const left = { value: 0 };
let base: string | null = null;
const held = new Map<string, unknown>();
let clock = 1_700_000_000_000;
const session = new PocketSession({
fs,
http: remote.http,
dir: DIR,
repo: { fullName: "owner/vault", branch: "main", cloneUrl: remote.url },
headers: {},
author: { name: "Phone", email: "phone@example.invalid" },
base: {
async read() {
return base;
},
async write(sha) {
base = sha;
},
},
store: {
async get(k) {
return held.get(k) ?? null;
},
async set(k, v) {
held.set(k, v);
},
},
onSync: (s) => states.push(s),
onEvent: (e) => events.push(e),
...(options.leave ? { onLeave: async () => void left.value++ } : {}),
now: () => (clock += 1000),
});
sessions.push(session);
if (options.clone !== false) await session.repo.clone();
const call = async (method: string, url: string, body?: unknown) => {
const answer = await session.handle({ method, url, headers: {}, body: body === undefined ? null : JSON.stringify(body) });
const text = typeof answer.body === "string" ? answer.body : new TextDecoder().decode(answer.body ?? new Uint8Array());
let json: unknown = text;
try {
json = JSON.parse(text);
} catch {
/* not JSON */
}
return { status: answer.status, json };
};
return { remote, fs, session, states, events, left, call };
}
const last = (h: Harness): SyncState => h.states[h.states.length - 1];
async function until(check: () => boolean, ms = 10_000): Promise<void> {
const start = Date.now();
while (!check()) {
if (Date.now() - start > ms) throw new Error("timed out waiting");
await new Promise((r) => setTimeout(r, 20));
}
}
describe("build", () => {
it("reads the working tree into the index, notes and assets apart, and says where the sync stands", async () => {
const h = await open();
const built = await h.session.build();
assert.equal(built.notes, 3, "the two notes and the README");
assert.equal(built.assets, 1, "the picture");
assert.ok(h.session.index.notes.has("Welcome.md"));
assert.ok(h.session.index.assets.has("Media/diagram.png"));
assert.equal(last(h).ahead, 0);
assert.deepEqual(last(h).conflicts, []);
});
it("names a standing (phone) pair from the tree, so a reopened app still says so", async () => {
const h = await open();
await h.fs.promises.writeFile(`${DIR}/Welcome (phone).md`, "# mine\n", { encoding: "utf8" });
await h.session.build();
assert.deepEqual(last(h).conflicts.map((p) => p.phonePath), ["Welcome (phone).md"]);
});
});
describe("the server it carries", () => {
it("answers /api/* from the index, and a save is a commit the sync line counts", async () => {
const h = await open();
await h.session.build();
const tree = await h.call("GET", "/api/tree");
assert.equal(tree.status, 200);
const note = await h.call("GET", "/api/note?path=Welcome.md");
assert.equal(note.json.content, SEED["Welcome.md"]);
const saved = await h.call("PUT", "/api/note?path=Welcome.md", { content: "# Welcome\n\nOn the phone.\n", baseMtimeMs: note.json.mtimeMs });
assert.equal(saved.status, 200);
assert.equal(last(h).ahead, 1, "committed");
assert.equal(await h.session.repo.ahead(), 1);
const sync = await h.call("GET", "/api/pocket/sync");
assert.equal(sync.json.repo, "owner/vault");
assert.equal(sync.json.branch, "main");
assert.equal(sync.json.ahead, 1);
});
it("leaves only through the caller's door, and refuses without one", async () => {
const without = await open();
await assert.rejects(() => without.call("POST", "/api/pocket/leave"));
remote?.cleanup();
const withDoor = await open({ leave: true });
const answer = await withDoor.call("POST", "/api/pocket/leave");
assert.equal(answer.status, 200);
assert.equal(withDoor.left.value, 1);
});
});
describe("the sync loop", () => {
it("push with nothing ahead asks the network nothing and says synced", async () => {
const h = await open();
await h.session.build();
const before = h.remote.requests.length;
await h.session.push();
assert.equal(h.remote.requests.length, before);
assert.equal(last(h).phase, "idle");
assert.ok(last(h).syncedAtMs !== null);
});
it("a save, then a push, reaches the remote; the line goes pushing → idle with nothing ahead", async () => {
const h = await open();
await h.session.build();
const note = await h.call("GET", "/api/note?path=Ideas/Astrolabe.md");
await h.call("PUT", "/api/note?path=Ideas/Astrolabe.md", { content: "# Astrolabe\n\nFrom the phone.\n", baseMtimeMs: note.json.mtimeMs });
await h.session.push();
assert.equal(h.remote.remoteFile("Ideas/Astrolabe.md"), "# Astrolabe\n\nFrom the phone.\n");
const phases = h.states.map((s) => s.phase);
assert.ok(phases.includes("pushing"));
assert.equal(last(h).phase, "idle");
assert.equal(last(h).ahead, 0);
assert.equal(h.session.syncStatus().error, null);
});
it("a pull brings the laptop's note into the index and tells the client once", async () => {
const h = await open();
await h.session.build();
h.remote.laptopCommit({ "From the laptop.md": "# From the laptop\n", "Ideas/Astrolabe.md": null }, "Laptop");
await h.session.pull();
assert.ok(h.session.index.notes.has("From the laptop.md"));
assert.ok(!h.session.index.notes.has("Ideas/Astrolabe.md"));
assert.deepEqual(h.events, [{ kind: "bulk", path: "" }]);
assert.equal(last(h).phase, "idle");
});
it("a push the remote refused as moved pulls, then pushes both", async () => {
const h = await open();
await h.session.build();
await h.call("PUT", "/api/note?path=Phone.md", { content: "# Phone\n" });
h.remote.laptopCommit({ "Laptop.md": "# Laptop\n" }, "Laptop");
await h.session.push();
assert.ok(h.states.some((s) => s.error === "remote-moved"), "the refusal is named, not reported as a failure of the network");
await until(() => h.remote!.remoteFile("Phone.md") !== null);
assert.equal(h.remote.remoteFile("Laptop.md"), "# Laptop\n");
await until(() => last(h).phase === "idle" && last(h).ahead === 0);
});
it("no network is 'offline', not a git error", async () => {
const h = await open();
await h.session.build();
h.remote.offline(true);
await h.session.pull();
assert.equal(last(h).online, false);
assert.equal(last(h).error, null);
h.remote.offline(false);
await h.session.pull();
assert.equal(last(h).online, true);
});
it("a refusal that is not the network is kept as the line's sentence", async () => {
const h = await open();
await h.session.build();
// A revoked token: the remote answers, and what it answers is a refusal.
h.remote.refuse(401);
await h.session.pull();
assert.equal(last(h).online, true);
assert.ok(last(h).error, "a sentence is kept");
});
it("one repository turn at a time: a pull and a push asked together run in order", async () => {
const h = await open();
await h.session.build();
await h.call("PUT", "/api/note?path=Order.md", { content: "# Order\n" });
h.remote.laptopCommit({ "Other.md": "# Other\n" }, "Laptop");
await Promise.all([h.session.pull(), h.session.push()]);
const phases = h.states.map((s) => s.phase).filter((p) => p !== "idle");
assert.deepEqual(phases.slice(0, 2), ["pulling", "pushing"]);
assert.equal(h.remote.remoteFile("Order.md"), "# Order\n");
});
});
describe("the lifecycle", () => {
it("a hidden page flushes the tree and pushes; a visible one pulls; the teardown lets go", async () => {
const h = await open();
await h.session.build();
const g = globalThis as Record<string, unknown>;
const doc = Object.assign(new EventTarget(), { visibilityState: "visible" });
const win = new EventTarget();
g.document = doc;
g.window = win;
let flushed = 0;
h.fs.promises.flush = async () => void flushed++;
const calls: string[] = [];
const pull = h.session.pull.bind(h.session);
const push = h.session.push.bind(h.session);
h.session.pull = async () => {
calls.push("pull");
await pull();
};
h.session.push = async () => {
calls.push("push");
await push();
};
try {
const stop = h.session.installLifecycle();
doc.visibilityState = "hidden";
doc.dispatchEvent(new Event("visibilitychange"));
doc.visibilityState = "visible";
doc.dispatchEvent(new Event("visibilitychange"));
win.dispatchEvent(new Event("pagehide"));
assert.deepEqual(calls, ["push", "pull", "push"]);
assert.equal(flushed, 2);
stop();
doc.dispatchEvent(new Event("visibilitychange"));
win.dispatchEvent(new Event("pagehide"));
assert.equal(calls.length, 3, "nothing after the teardown");
} finally {
delete g.document;
delete g.window;
h.session.pull = pull;
h.session.push = push;
}
});
});