Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";import { createBobbinClient } from "#lib/api/client.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 backconst 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 savedconst pocketWith = (preferences?: unknown[]) => { let stored = preferences && { preferences }; let failing = false; const puts: { preferences: Record<string, unknown>[] }[] = []; const fetch = vi.fn<typeof globalThis.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(); });});