import assert from "node:assert/strict"; import { after, before, test } from "node:test"; import { configure, type Session } from "@nibling/sdk"; import { openHref, Platform, type Auth, type KeyValue, type SavedProject } from "../src/platform/state.ts"; import { createFakePds, fakeNetwork } from "../../sdk/tests/fake-pds.ts"; import { startGitServer, type GitServer } from "../../sdk/tests/git-server.ts"; const pds = createFakePds({ url: "https://pds.test" }); const alice = pds.createAccount("alice"); const bob = pds.createAccount("bob"); configure(fakeNetwork([pds])); const bytes = (text: string) => new TextEncoder().encode(text); const PASSWORD = "push-token"; const REPO_DID = "did:plc:hellorepo"; /** OAuth stand-in: "signing in" as a handle records the DID that resume and complete hand back. */ function fakeAuth(): Auth { let pending: Session["did"] | undefined; return { signIn: async (handle) => void (pending = handle === "bob.test" ? bob : alice), complete: async () => pds.session(pending!), resume: async (did) => (pds.repos.has(did) ? pds.session(did) : undefined), signOut: async () => {}, }; } function memoryStorage(): KeyValue { const map = new Map(); return { getItem: (k) => map.get(k) ?? null, setItem: (k, v) => void map.set(k, v), removeItem: (k) => void map.delete(k) }; } async function signedIn(storage = memoryStorage(), handle = "alice.test") { const platform = new Platform({ auth: fakeAuth(), storage, starter: () => new Map([["index.html", bytes("

starter

")]]), resolveRepo: async (ref) => { assert.equal(ref, "alice.test/hello"); return { owner: alice, name: "hello", repoDid: REPO_DID, knot: knot(), cloneUrl: `${knot()}/${REPO_DID}` }; }, pushToken: async () => PASSWORD, }); await platform.signIn(handle); await platform.completeSignIn(new URLSearchParams()); return platform; } /** The open project, which must be a saved one. */ function saved(platform: Platform): SavedProject { const open = platform.state.project; assert.equal(open?.kind, "project"); return open as SavedProject; } let server: GitServer; const knot = () => new URL(server.url).origin; before(async () => { server = await startGitServer({ name: REPO_DID, password: PASSWORD, seed: { "README.md": "hello\n", "src/main.ts": "console.log(1);\n" }, branches: { feature: { "src/main.ts": "console.log(2);\n", "src/feature.ts": "export const x = 1;\n" } }, }); pds.docs.set(REPO_DID, { "@context": ["https://www.w3.org/ns/did/v1"], id: REPO_DID, service: [{ id: "#tangled_knot", type: "TangledKnot", serviceEndpoint: knot() }], } as never); }); after(() => server.close()); test("a new project saves an edit that a fresh boot reopens by its address", async () => { const storage = memoryStorage(); const platform = await signedIn(storage); assert.equal(platform.state.account?.handle, "alice.test"); await platform.newProject("demo"); const open = saved(platform); assert.equal(open.project.record.name, "demo"); assert.equal(open.project.record.slug, "demo"); assert.equal(open.actor, "alice.test"); assert.deepEqual(open.files.list(), ["index.html"]); open.files.write("index.html", "

edited

"); assert.deepEqual(platform.state.project!.files.dirty(), ["index.html"]); await platform.saveProject(); assert.deepEqual(platform.state.notice, { tone: "info", text: "Saved" }); assert.deepEqual(platform.state.project!.files.dirty(), []); const rebooted = new Platform({ auth: fakeAuth(), storage }); await rebooted.boot({ project: { actor: "alice.test", name: "demo" } }); assert.equal(rebooted.state.account?.session.did, alice); assert.equal(saved(rebooted).project.uri, open.project.uri); assert.equal(saved(rebooted).actor, "alice.test"); assert.equal(rebooted.state.project?.files.read("index.html"), "

edited

"); }); test("a save that loses the swap keeps the edits and says why", async () => { const first = await signedIn(); await first.newProject("contested"); const uri = saved(first).project.uri; const second = await signedIn(); await second.openProject(uri); second.state.project!.files.write("index.html", "second"); await second.saveProject(); first.state.project!.files.write("index.html", "first"); await first.saveProject(); assert.equal(first.state.notice?.tone, "error"); assert.match(first.state.notice!.text, /^Someone else saved this project after you opened it/); assert.deepEqual(first.state.project!.files.dirty(), ["index.html"]); assert.equal(first.state.busy, undefined); }); test("another account's project only saves after a fork", async () => { const owner = await signedIn(); await owner.newProject("shared"); const uri = saved(owner).project.uri; const visitor = await signedIn(memoryStorage(), "bob.test"); await visitor.openProject(uri); visitor.state.project!.files.write("index.html", "bob's"); await visitor.saveProject(); assert.equal(visitor.state.notice?.text, "This project belongs to someone else. Fork it to save your changes."); await visitor.forkProject(); const fork = saved(visitor); assert.equal(fork.project.did, bob); assert.deepEqual(fork.project.record.forkOf, { uri, cid: saved(owner).project.cid }); assert.deepEqual(fork.files.dirty(), ["index.html"]); await visitor.saveProject(); assert.equal(visitor.state.notice?.text, "Saved"); }); test("an opened repo writes no record, and its owner pushes edits back to it", async () => { const seed = server.git("rev-parse", "main"); const platform = await signedIn(); pds.resetCounts(); await platform.openRepo("alice.test/hello"); const open = platform.state.project!; assert.equal(platform.state.notice, undefined); assert.equal(open.kind, "repo"); assert.deepEqual(open.kind === "repo" && open.repo, { owner: "alice.test", ownerDid: alice, name: "hello" }); assert.deepEqual(open.base?.ref, { repo: REPO_DID, ref: "main", commit: seed }); assert.deepEqual(open.files.list(), ["README.md", "src/main.ts"]); open.files.write("README.md", "hello from nibling\n"); await platform.pushToTangled("Edit the readme"); const head = server.git("rev-parse", "main"); assert.deepEqual(platform.state.notice, { tone: "info", text: `Pushed ${head.slice(0, 7)} to main` }); assert.equal(server.git("log", "-1", "--format=%s %an", "main"), "Edit the readme alice.test"); assert.equal(server.git("show", "main:README.md"), "hello from nibling"); assert.equal(server.git("rev-parse", "main^"), seed); assert.deepEqual(platform.state.project!.base?.ref, { repo: REPO_DID, ref: "main", commit: head }); assert.deepEqual(platform.state.project!.files.dirty(), []); assert.equal(pds.count("com.atproto.repo.createRecord") + pds.count("com.atproto.repo.putRecord"), 0); }); test("saving an opened repo makes it the saver's project on the repo's commit, and a push rebases it", async () => { const platform = await signedIn(); await platform.openRepo("alice.test/hello"); const seed = platform.state.project!.base!.ref.commit; platform.state.project!.files.write("README.md", "saved first\n"); await platform.saveProject(); assert.deepEqual(platform.state.notice, { tone: "info", text: "Saved" }); const open = saved(platform); assert.equal(open.actor, "alice.test"); assert.equal(open.project.record.name, "hello"); assert.deepEqual(open.project.record.base, { repo: REPO_DID, ref: "main", commit: seed }); assert.deepEqual(open.files.dirty(), []); await platform.pushToTangled("Push the saved project"); const head = server.git("rev-parse", "main"); const pushed = saved(platform); assert.deepEqual(pushed.project.record.base, { repo: REPO_DID, ref: "main", commit: head }); assert.deepEqual(pushed.project.record.files, []); const reopened = await signedIn(); await reopened.openProject(pushed.project.uri); assert.equal(reopened.state.project?.files.read("README.md"), "saved first\n"); }); test("a repo opens signed out, and someone else's repo saves as the viewer's project but does not push", async () => { const anonymous = new Platform({ auth: fakeAuth(), resolveRepo: async () => ({ owner: alice, name: "hello", repoDid: REPO_DID, knot: knot(), cloneUrl: `${knot()}/${REPO_DID}` }) }); await anonymous.openRepo("alice.test/hello"); assert.equal(anonymous.state.project?.kind, "repo"); await anonymous.saveProject(); assert.deepEqual(anonymous.state.notice, { tone: "error", text: "Sign in first." }); const visitor = await signedIn(memoryStorage(), "bob.test"); await visitor.openRepo("alice.test/hello"); await visitor.pushToTangled("not mine"); assert.deepEqual(visitor.state.notice, { tone: "error", text: "This repo belongs to someone else. Save it as your project to keep your changes." }); await visitor.forkProject(); assert.equal(saved(visitor).project.did, bob); assert.equal(saved(visitor).project.record.base?.repo, REPO_DID); }); test("openRepo at a branch opens that branch's files, and a push there leaves main alone", async () => { const platform = await signedIn(); const seed = server.git("rev-parse", "feature"); const mainBefore = server.git("rev-parse", "main"); await platform.openRepo("alice.test/hello", { ref: "feature" }); const open = platform.state.project!; assert.equal(open.kind, "repo"); assert.deepEqual(open.kind === "repo" && open.repo.ref, "feature"); assert.deepEqual(open.files.list(), ["README.md", "src/feature.ts", "src/main.ts"]); assert.equal(open.files.read("src/main.ts"), "console.log(2);\n"); assert.deepEqual(open.base?.ref, { repo: REPO_DID, ref: "feature", commit: seed }); // The route's own address, not the repo's resolved default, is what names the open branch. assert.equal(openHref(open), "/tangled.org/alice.test/hello/tree/feature"); open.files.write("src/main.ts", "console.log(4);\n"); await platform.pushToTangled("Edit on feature"); assert.equal(server.git("show", "feature:src/main.ts"), "console.log(4);"); assert.equal(server.git("rev-parse", "main"), mainBefore); assert.deepEqual(platform.state.project!.base?.ref, { repo: REPO_DID, ref: "feature", commit: server.git("rev-parse", "feature") }); }); test("openRepo scoped to a path re-roots the project, and a push there touches only that subtree", async () => { const platform = await signedIn(); const seed = server.git("rev-parse", "main"); const readmeBefore = server.git("show", "main:README.md"); await platform.openRepo("alice.test/hello", { path: "src" }); const open = platform.state.project!; assert.equal(open.kind, "repo"); assert.deepEqual(open.kind === "repo" && open.repo.path, "src"); assert.deepEqual(open.files.list(), ["main.ts"]); assert.equal(open.files.read("main.ts"), "console.log(1);\n"); assert.deepEqual(open.base?.ref, { repo: REPO_DID, ref: "main", commit: seed, path: "src" }); assert.equal(openHref(open), "/tangled.org/alice.test/hello/tree/main/src"); open.files.write("main.ts", "console.log('edited');\n"); await platform.pushToTangled("Edit scoped to src"); const head = server.git("rev-parse", "main"); assert.equal(server.git("show", "main:src/main.ts"), "console.log('edited');"); assert.equal(server.git("show", "main:README.md"), readmeBefore); assert.deepEqual(platform.state.project!.base?.ref, { repo: REPO_DID, ref: "main", commit: head, path: "src" }); await platform.saveProject(); const savedProject = saved(platform); assert.deepEqual(savedProject.project.record.base, { repo: REPO_DID, ref: "main", commit: head, path: "src" }); const reopened = await signedIn(); await reopened.openProject(savedProject.project.uri); assert.deepEqual(reopened.state.project?.files.list(), ["main.ts"]); assert.equal(reopened.state.project?.files.read("main.ts"), "console.log('edited');\n"); }); test("two concurrent saves perform exactly one putRecord", async () => { const platform = await signedIn(); await platform.newProject("concurrent"); platform.state.project!.files.write("index.html", "one"); pds.resetCounts(); await Promise.all([platform.saveProject(), platform.saveProject()]); assert.equal(pds.count("com.atproto.repo.putRecord"), 1); assert.deepEqual(platform.state.notice, { tone: "info", text: "Saved" }); assert.deepEqual(platform.state.project!.files.dirty(), []); }); test("a non-owner's push is refused with the exact message", async () => { const owner = await signedIn(); await owner.openRepo("alice.test/hello"); await owner.saveProject(); const uri = saved(owner).project.uri; const visitor = await signedIn(memoryStorage(), "bob.test"); await visitor.openProject(uri); await visitor.pushToTangled("not mine"); assert.deepEqual(visitor.state.notice, { tone: "error", text: "This project belongs to someone else. Fork it to push your changes." }); }); test("a push that lands but fails to save still records the pushed commit, and a retry Save succeeds", async () => { const platform = await signedIn(); await platform.openRepo("alice.test/hello"); await platform.saveProject(); platform.state.project!.files.write("README.md", "pushed but not saved\n"); const session = platform.state.account!.session; const realHandler = session.client.handler; let failed = false; session.client.handler = (pathname, init) => { if (!failed && pathname.includes("com.atproto.repo.putRecord")) { failed = true; return Promise.reject(new Error("network blip")); } return realHandler(pathname, init); }; await platform.pushToTangled("Edit the readme"); const head = server.git("rev-parse", "main"); assert.deepEqual(platform.state.notice, { tone: "error", text: `Pushed ${head.slice(0, 7)} to main, but saving the project failed; Save again.` }); assert.deepEqual(platform.state.project!.base?.ref, { repo: REPO_DID, ref: "main", commit: head }); assert.deepEqual(platform.state.project!.files.dirty(), ["README.md"]); session.client.handler = realHandler; await platform.saveProject(); assert.deepEqual(platform.state.notice, { tone: "info", text: "Saved" }); assert.deepEqual(saved(platform).project.record.base, { repo: REPO_DID, ref: "main", commit: head }); assert.deepEqual(platform.state.project!.files.dirty(), []); const reopened = await signedIn(); await reopened.openProject(saved(platform).project.uri); assert.equal(reopened.state.project?.base?.ref.commit, head); }); test("operations that need an account or a project say so", async () => { const platform = new Platform({ auth: fakeAuth() }); await platform.newProject("x"); assert.deepEqual(platform.state.notice, { tone: "error", text: "Sign in first." }); await platform.signIn("alice.test"); await platform.completeSignIn(new URLSearchParams()); await platform.pushToTangled("m"); assert.deepEqual(platform.state.notice, { tone: "error", text: "Open a project first." }); }); test("opening a project no one has flags the notice notFound; opening a name that does not resolve at all does not", async () => { const platform = await signedIn(); await platform.openProject({ actor: "alice.test", name: "no-such-project" }); assert.equal(platform.state.notice?.tone, "error"); assert.equal(platform.state.notice?.notFound, true); assert.equal(platform.state.project, undefined); await platform.openProject({ actor: "ghost.test", name: "anything" }); assert.equal(platform.state.notice?.tone, "error"); assert.equal(platform.state.notice?.notFound, undefined); }); /** OAuth as one browser keeps it: completed sign-ins store a session per DID until that DID signs out. */ function browserAuth(stored = new Set()): Auth & { stored: Set } { let pending: Session["did"] | undefined; return { stored, signIn: async (handle) => void (pending = handle === "bob.test" ? bob : alice), complete: async () => (stored.add(pending!), pds.session(pending!)), resume: async (did) => (stored.has(did) ? pds.session(did) : undefined), signOut: async (did) => void stored.delete(did), }; } async function signInAs(platform: Platform, handle: string) { await platform.signIn(handle); await platform.completeSignIn(new URLSearchParams()); } const handles = (platform: Platform) => platform.state.accounts.map((a) => a.handle); test("a second sign-in adds an account, and switching resumes the stored session without signing in again", async () => { const auth = browserAuth(); const platform = new Platform({ auth, storage: memoryStorage() }); await signInAs(platform, "alice.test"); await signInAs(platform, "bob.test"); assert.equal(platform.state.account?.handle, "bob.test"); assert.deepEqual(handles(platform), ["alice.test", "bob.test"]); let redirected = false; const signIn = auth.signIn; auth.signIn = async (handle) => ((redirected = true), signIn(handle)); await platform.switchAccount(alice); assert.equal(platform.state.account?.session.did, alice); assert.equal(platform.state.account?.handle, "alice.test"); assert.equal(redirected, false); assert.deepEqual(handles(platform), ["alice.test", "bob.test"]); }); test("finishing a sign-in on a fresh page, as after the OAuth redirect, keeps the accounts already signed in", async () => { const storage = memoryStorage(); const auth = browserAuth(); await signInAs(new Platform({ auth, storage }), "alice.test"); const returned = new Platform({ auth, storage }); await returned.signIn("bob.test"); await returned.completeSignIn(new URLSearchParams()); assert.equal(returned.state.account?.session.did, bob); assert.deepEqual(handles(returned), ["alice.test", "bob.test"]); await returned.switchAccount(alice); assert.equal(returned.state.account?.session.did, alice); }); test("switching to an account whose session lapsed sends it through sign-in", async () => { const auth = browserAuth(); const platform = new Platform({ auth, storage: memoryStorage() }); await signInAs(platform, "alice.test"); await signInAs(platform, "bob.test"); auth.stored.delete(alice); const asked: string[] = []; auth.signIn = async (handle) => void asked.push(handle); await platform.switchAccount(alice); assert.deepEqual(asked, ["alice.test"]); assert.equal(platform.state.account?.session.did, bob); }); test("signing out one account keeps the other signed in, and the project stays open", async () => { const auth = browserAuth(); const platform = new Platform({ auth, storage: memoryStorage(), starter: () => new Map([["a.txt", bytes("a")]]) }); await signInAs(platform, "alice.test"); await signInAs(platform, "bob.test"); await platform.signOut(alice); assert.equal(platform.state.account?.session.did, bob); assert.deepEqual(handles(platform), ["bob.test"]); assert.deepEqual([...auth.stored], [bob]); await signInAs(platform, "alice.test"); await platform.newProject("kept"); await platform.signOut(); assert.equal(platform.state.account?.session.did, bob); assert.equal(saved(platform).project.record.name, "kept"); await platform.signOut(); assert.equal(platform.state.account, undefined); assert.deepEqual(platform.state.accounts, []); assert.equal(platform.state.project, undefined); }); test("the remembered accounts survive a fresh boot, with the current one resumed", async () => { const storage = memoryStorage(); const auth = browserAuth(); const first = new Platform({ auth, storage }); await signInAs(first, "alice.test"); await signInAs(first, "bob.test"); await first.switchAccount(alice); const rebooted = new Platform({ auth: browserAuth(auth.stored), storage }); await rebooted.boot(); assert.equal(rebooted.state.account?.session.did, alice); assert.deepEqual(handles(rebooted), ["alice.test", "bob.test"]); await rebooted.switchAccount(bob); assert.equal(rebooted.state.account?.session.did, bob); }); test("unreadable or throwing storage only costs the remembered accounts", async () => { const garbage = memoryStorage(); garbage.setItem("nib.platform.accounts", "{not json"); const booted = new Platform({ auth: browserAuth(), storage: garbage }); await booted.boot(); assert.deepEqual(booted.state.accounts, []); const throwing: KeyValue = { getItem: () => { throw new Error("denied"); }, setItem: () => { throw new Error("denied"); }, removeItem: () => { throw new Error("denied"); }, }; const platform = new Platform({ auth: browserAuth(), storage: throwing }); await platform.boot(); await signInAs(platform, "alice.test"); assert.deepEqual(handles(platform), ["alice.test"]); assert.equal(platform.state.notice, undefined); });