import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createBobbinClient } from "#lib/api/client.ts";
import type { Fetch } from "#lib/fetch.ts";
vi.mock("$app/env", () => ({ browser: true }));
const { listRecents, mergeRecents, PUSH_DELAY_MS, recentPath, rememberVisit, syncRecents } =
await import("./recents");
type Pocket = import("./recents").Pocket;
const alice = "did:plc:alice";
const bob = "did:plc:bob";
const store = new Map<string, string>();
const fakeStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key)
};
const repo = (name: string) => ({ kind: "repo", ownerHandle: "alice.test", name }) as const;
const issue = {
kind: "issue",
ownerHandle: "alice.test",
repoName: "core",
title: "it broke",
rkey: "3kissue",
open: true
} as const;
const pull = {
kind: "pull",
ownerHandle: "bob.test",
repoName: "knot",
title: "fix it",
rkey: "3kpull",
state: "completed"
} as const;
const RECENTS = "org.tangled.temp.actor.defs#recentsPref";
const KNOTS = {
$type: "org.tangled.temp.actor.defs#savedKnotPref",
items: [{ id: "one.example", value: "one.example" }]
};
const ONBOARDING = {
$type: "org.tangled.temp.actor.defs#onboardingPref",
startedAt: "2026-09-01T00:00:00.000Z"
};
// keys sorted, the way pocket hands them back
const remoteRepo = (name: string, visited: string) => ({
$type: "org.tangled.temp.actor.defs#recentRepo",
name,
ownerHandle: "alice.test",
visited
});
const noPocket: Pocket = () => null;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
// a pocket that keeps one account's list, NotFound until something is saved
const pocketWith = (preferences?: unknown[]) => {
let stored = preferences && { preferences };
let failing = false;
const puts: { preferences: Record<string, unknown>[] }[] = [];
const fetch = vi.fn<Fetch>(async (input, init) => {
if (failing) return json({ error: "InternalServerError" }, 500);
if (String(input).includes("putPreferences")) {
stored = JSON.parse(String(init?.body));
puts.push(stored as (typeof puts)[number]);
return new Response("saved.", { status: 200 });
}
return stored ? json(stored) : json({ error: "NotFound", message: "no prefs" }, 400);
});
const ctx = createBobbinClient({ serviceUrl: "https://bobbin.example.com", fetch });
return {
pocket: (() => ctx) as Pocket,
fetch,
puts,
recents: () =>
stored?.preferences.find((pref) => (pref as { $type?: string }).$type === RECENTS),
replace: (next: unknown[]) => (stored = { preferences: next }),
fail: (on: boolean) => (failing = on)
};
};
const paths = (did: string) => listRecents(did).map(recentPath);
const settle = () => new Promise((done) => setImmediate(done));
beforeEach(() => {
store.clear();
vi.stubGlobal("localStorage", fakeStorage);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
vi.setSystemTime(new Date("2026-10-01T00:00:00Z"));
});
afterEach(async () => {
await vi.runAllTimersAsync();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("recents", () => {
it("starts empty", () => {
expect(listRecents(alice)).toEqual([]);
});
it("lists visits newest first, with when each happened", () => {
rememberVisit(alice, repo("core"), noPocket);
vi.advanceTimersByTime(1000);
rememberVisit(alice, issue, noPocket);
vi.advanceTimersByTime(1000);
rememberVisit(alice, pull, noPocket);
expect(paths(alice)).toEqual([
"/bob.test/knot/pulls/3kpull",
"/alice.test/core/issues/3kissue",
"/alice.test/core"
]);
expect(listRecents(alice).map((item) => item.visited)).toEqual([
"2026-10-01T00:00:02.000Z",
"2026-10-01T00:00:01.000Z",
"2026-10-01T00:00:00.000Z"
]);
});
it("moves a page seen again to the top instead of listing it twice", () => {
rememberVisit(alice, repo("core"), noPocket);
rememberVisit(alice, issue, noPocket);
rememberVisit(alice, { ...issue, title: "it broke again", open: false }, noPocket);
rememberVisit(alice, repo("core"), noPocket);
expect(paths(alice)).toEqual(["/alice.test/core", "/alice.test/core/issues/3kissue"]);
expect(listRecents(alice)[1]).toMatchObject({ title: "it broke again", open: false });
});
it("keeps only the last five", () => {
for (const name of ["a", "b", "c", "d", "e", "f"])
rememberVisit(alice, repo(name), noPocket);
expect(paths(alice)).toEqual(
["f", "e", "d", "c", "b"].map((name) => `/alice.test/${name}`)
);
});
it("keeps each account's visits apart", () => {
rememberVisit(alice, repo("core"), noPocket);
rememberVisit(bob, pull, noPocket);
expect(paths(alice)).toEqual(["/alice.test/core"]);
expect(paths(bob)).toEqual(["/bob.test/knot/pulls/3kpull"]);
});
it("cuts a long title down to what pocket takes", () => {
rememberVisit(alice, { ...issue, title: "ΓΌ".repeat(300) }, noPocket);
const [item] = listRecents(alice);
expect(item.kind === "issue" && [...item.title]).toHaveLength(100);
});
it("drops what it can't read back", () => {
store.set(
`tangled.recents.${alice}`,
JSON.stringify([
{ kind: "repo" },
null,
{ ...repo("bad"), visited: "yesterday" },
{ ...repo("x".repeat(101)), visited: "2026-09-30T00:00:00Z" },
{ ...issue, rkey: "not/a/key", visited: "2026-09-30T00:00:00Z" },
{ ...repo("core"), visited: "2026-09-30T00:00:00Z" }
])
);
expect(paths(alice)).toEqual(["/alice.test/core"]);
store.set(`tangled.recents.${alice}`, "{not json");
expect(listRecents(alice)).toEqual([]);
});
it("does not throw when the browser refuses storage", () => {
vi.stubGlobal("localStorage", {
getItem: () => {
throw new Error("denied");
},
setItem: () => {
throw new Error("denied");
}
});
expect(() => rememberVisit(alice, repo("core"), noPocket)).not.toThrow();
expect(listRecents(alice)).toEqual([]);
});
});
describe("mergeRecents", () => {
const at = (name: string, visited: string) => ({ ...repo(name), visited });
it("keeps the latest visit of each page, newest first", () => {
const merged = mergeRecents(
[at("a", "2026-10-01T00:00:01Z"), at("b", "2026-10-01T00:00:05Z")],
[at("a", "2026-10-01T00:00:09Z"), at("c", "2026-10-01T00:00:03Z")]
);
expect(merged.map((item) => [recentPath(item), item.visited])).toEqual([
["/alice.test/a", "2026-10-01T00:00:09Z"],
["/alice.test/b", "2026-10-01T00:00:05Z"],
["/alice.test/c", "2026-10-01T00:00:03Z"]
]);
});
it("keeps the five newest of both lists", () => {
const day = (n: number) => `2026-10-0${n}T00:00:00Z`;
const merged = mergeRecents(
[1, 3, 5, 7].map((n) => at(`l${n}`, day(n))),
[2, 4, 6, 8].map((n) => at(`r${n}`, day(n)))
);
expect(merged.map(recentPath)).toEqual(
["r8", "l7", "r6", "l5", "r4"].map((name) => `/alice.test/${name}`)
);
});
});
describe("syncing with pocket", () => {
it("pulls the account's list from pocket into this browser", async () => {
rememberVisit(alice, repo("local"), noPocket);
const { pocket } = pocketWith([
KNOTS,
{
$type: RECENTS,
items: [remoteRepo("remote", "2026-10-02T00:00:00.000Z")]
}
]);
const merged = await syncRecents(pocket, alice);
expect(merged.map(recentPath)).toEqual(["/alice.test/remote", "/alice.test/local"]);
expect(paths(alice)).toEqual(["/alice.test/remote", "/alice.test/local"]);
});
it("ignores rows from pocket it does not understand", async () => {
const { pocket } = pocketWith([
{
$type: RECENTS,
items: [
{ $type: "org.example.future#recentThing", path: "/x" },
remoteRepo("kept", "2026-10-02T00:00:00.000Z")
]
}
]);
expect((await syncRecents(pocket, alice)).map(recentPath)).toEqual(["/alice.test/kept"]);
});
it("an account with nothing in pocket keeps this browser's list", async () => {
rememberVisit(alice, repo("local"), noPocket);
const { pocket } = pocketWith();
expect((await syncRecents(pocket, alice)).map(recentPath)).toEqual(["/alice.test/local"]);
});
it("saves visits once, a few seconds after the first, keeping the other preferences", async () => {
const { pocket, puts, recents } = pocketWith([KNOTS, ONBOARDING]);
rememberVisit(alice, repo("core"), pocket);
vi.advanceTimersByTime(1000);
rememberVisit(alice, issue, pocket);
vi.advanceTimersByTime(1000);
rememberVisit(alice, pull, pocket);
await settle();
expect(puts).toHaveLength(0);
await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS);
await vi.waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0].preferences).toContainEqual(KNOTS);
expect(puts[0].preferences).toContainEqual(ONBOARDING);
expect(recents()).toMatchObject({
items: [
{ $type: "org.tangled.temp.actor.defs#recentPull", rkey: "3kpull" },
{ $type: "org.tangled.temp.actor.defs#recentIssue", rkey: "3kissue" },
{ $type: "org.tangled.temp.actor.defs#recentRepo", name: "core" }
]
});
});
it("does not write when pocket already has the same list", async () => {
rememberVisit(alice, repo("core"), noPocket);
const { pocket, puts } = pocketWith([
{ $type: RECENTS, items: [remoteRepo("core", "2026-10-01T00:00:00.000Z")] }
]);
await syncRecents(pocket, alice);
await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS);
await settle();
expect(puts).toHaveLength(0);
});
it("sends visits pocket has not seen when the list loads", async () => {
rememberVisit(alice, repo("offline"), noPocket);
const { pocket, puts } = pocketWith([
{ $type: RECENTS, items: [remoteRepo("core", "2026-09-30T00:00:00.000Z")] }
]);
await syncRecents(pocket, alice);
await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS);
await vi.waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0].preferences[0]).toMatchObject({
items: [{ name: "offline" }, { name: "core" }]
});
});
it("reads pocket again before writing, so another device's newer rows survive", async () => {
const { pocket, puts, replace } = pocketWith([KNOTS]);
rememberVisit(alice, repo("here"), pocket);
vi.advanceTimersByTime(1000);
replace([
KNOTS,
{ $type: RECENTS, items: [remoteRepo("there", "2026-10-01T00:00:03.000Z")] }
]);
await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS);
await vi.waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0].preferences).toContainEqual(KNOTS);
expect(puts[0].preferences.find((pref) => pref.$type === RECENTS)).toMatchObject({
items: [{ name: "there" }, { name: "here" }]
});
expect(paths(alice)).toEqual(["/alice.test/there", "/alice.test/here"]);
});
it("never keeps a visit pocket would refuse, here or there", async () => {
const { pocket, puts } = pocketWith([]);
rememberVisit(alice, { ...repo("core"), ownerDid: "not a did" }, pocket);
rememberVisit(alice, repo("fine"), pocket);
expect(paths(alice)).toEqual(["/alice.test/fine"]);
await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS);
await vi.waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0].preferences[0]).toMatchObject({ items: [{ name: "fine" }] });
});
it("gives up quietly when every write loses its swap", async () => {
const { pocket, puts, fetch } = pocketWith([KNOTS]);
fetch.mockImplementation(async (input, init) => {
if (String(input).includes("putPreferences")) {
puts.push(JSON.parse(String(init?.body)));
return json(
{ error: "InvalidSwap", message: "stored prefs do not match swapRev" },
400
);
}
return json({ preferences: [KNOTS], rev: "stale" });
});
rememberVisit(alice, repo("core"), pocket);
await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS);
await vi.waitFor(() => expect(puts).toHaveLength(3));
await settle();
expect(fetch).toHaveBeenCalledTimes(6);
expect(paths(alice)).toEqual(["/alice.test/core"]);
});
it("a failing pocket keeps the visit here and tries again on the next one", async () => {
const { pocket, puts, fail, fetch } = pocketWith([KNOTS]);
fail(true);
expect(() => rememberVisit(alice, repo("core"), pocket)).not.toThrow();
await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS);
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
await settle();
expect(paths(alice)).toEqual(["/alice.test/core"]);
await expect(syncRecents(pocket, alice)).rejects.toThrow();
fail(false);
rememberVisit(alice, issue, pocket);
await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS);
await vi.waitFor(() => expect(puts).toHaveLength(1));
expect(puts[0].preferences.find((pref) => pref.$type === RECENTS)).toMatchObject({
items: [{ rkey: "3kissue" }, { name: "core" }]
});
});
it("sends nothing while the account has no session to reach pocket with", async () => {
const { fetch } = pocketWith([]);
rememberVisit(alice, repo("core"), noPocket);
await vi.advanceTimersByTimeAsync(PUSH_DELAY_MS);
await settle();
expect(fetch).not.toHaveBeenCalled();
});
});