/** * Placing a collaborator's branch against canonical main, and the statements * other accounts publish about it. Everything here is decidable from commit * lists and records alone; the reads that gather them are exercised in the * browser. */ import { afterEach, describe, expect, it } from 'vitest'; import { diffHref, loadPulls, parsePullPath } from '../src/lib/collab.js'; import { anchorKey, inSpan, lineKey, placeComments, threadStack, threadSummaries, } from '../src/lib/comments.js'; import { account } from '../src/lib/git.js'; /** @param {string} name */ const sha = (name) => name.repeat(40).slice(0, 40); describe('diffHref and parsePullPath', () => { const DID = 'did:plc:w4i54sx7ci3skbfzcsq3xrbw'; /** * The trip an address actually makes: built by a card, put through a * browser, split and decoded by the router the way app.jsx does. * @param {string} branch @param {string} [tab] */ const around = (branch, tab) => { const href = diffHref('demo-app', { author: { did: DID }, branch }, tab); // What the router does with window.location.pathname. const rest = new URL(href, 'https://site.example').pathname .split('/') .filter(Boolean) .map(decodeURIComponent) .slice(2); return { href, ...parsePullPath(rest) }; }; it('carries a branch back whole, slashes and all', () => { expect(around('fix/contributing')).toMatchObject({ did: DID, branch: 'fix/contributing', tab: '', }); expect(around('agent/rename/the/ports').branch).toBe( 'agent/rename/the/ports', ); expect(around('main').branch).toBe('main'); }); it('carries a branch whose name needs escaping', () => { // Git allows these; a path does not carry them raw. expect(around('fix/a b').branch).toBe('fix/a b'); expect(around('fix/#1').branch).toBe('fix/#1'); expect(around('fix/100%').branch).toBe('fix/100%'); expect(around('feature/über').branch).toBe('feature/über'); }); it('keeps the DID whole, which is what the branch is paired with', () => { expect(around('fix/contributing').did).toBe(DID); // A did:web with a port carries a literal %3A of its own, so the trip // has to survive being encoded twice and decoded once. const web = 'did:web:example.com%3A3000'; const href = diffHref( 'demo-app', { author: { did: web }, branch: 'fix' }, '', ); expect(href).toContain('%253A'); const rest = new URL(href, 'https://site.example').pathname .split('/') .filter(Boolean) .map(decodeURIComponent) .slice(2); expect(parsePullPath(rest).did).toBe(web); }); it('reads a tab at the end without eating the branch', () => { expect(around('fix/contributing', 'files')).toMatchObject({ branch: 'fix/contributing', tab: 'files', }); expect(around('fix/contributing', 'checks')).toMatchObject({ branch: 'fix/contributing', tab: 'checks', }); }); it('keeps a branch whose own name holds a tab word', () => { // The divider is what makes these readable. Without it each of them read // as a tab and the branch was unreachable. expect(around('fix/files').branch).toBe('fix/files'); expect(around('fix/checks').branch).toBe('fix/checks'); expect(around('files/fix').branch).toBe('files/fix'); expect(around('checks').branch).toBe('checks'); expect(around('fix/checks', 'checks')).toMatchObject({ branch: 'fix/checks', tab: 'checks', }); }); it('divides on a segment git refuses in a ref name', () => { // The whole scheme rests on this: `~` cannot be part of a branch, so a // segment of it is always the divider. `git check-ref-format` refuses // `refs/heads/fix/~/checks` and allows `refs/heads/fix/-/checks`, which // is why the divider is not GitLab's `-`. expect(around('fix/contributing', 'checks').href).toContain('/~/checks'); // Unreserved in a URL, so it passes through without being escaped. expect(around('fix', 'files').href).not.toContain('%7E'); }); it('reads one of its commits, addressed under it', () => { const sha = 'a'.repeat(40); const href = around('fix/contributing', `commit/${sha}`); expect(href.href).toContain(`/~/commit/${sha}`); expect(href).toMatchObject({ branch: 'fix/contributing', tab: 'commit', commit: sha, }); }); it('answers empty for an address naming nothing', () => { expect(parsePullPath([])).toEqual({ did: '', branch: '', tab: '', commit: '', }); }); }); describe('threadStack', () => { /** @param {Partial>} value */ const entry = (value) => ({ verdict: 'comment', ...value }); it('orders oldest first, each reply under what it answers', () => { const stack = threadStack([ entry({ uri: 'at://b', reviewedAt: '2026-08-02T00:00:00Z' }), entry({ uri: 'at://c', reviewedAt: '2026-08-03T00:00:00Z', replyTo: { uri: 'at://a' }, }), entry({ uri: 'at://a', reviewedAt: '2026-08-01T00:00:00Z' }), ]); expect(stack.map((s) => s.uri)).toEqual(['at://a', 'at://c', 'at://b']); expect(stack.map((s) => s.depth)).toEqual([0, 1, 0]); }); it('keeps a reply whose parent is not in the stack, at the top level', () => { const stack = threadStack([ entry({ uri: 'at://c', reviewedAt: '2026-08-03T00:00:00Z', replyTo: { uri: 'at://gone' }, }), ]); expect(stack).toHaveLength(1); expect(stack[0].depth).toBe(0); }); }); describe('threadSummaries', () => { /** @param {Partial>} value */ const entry = (value) => ({ verdict: 'comment', ...value }); it('shows each thread once, sorted by its latest word', () => { const summaries = threadSummaries([ entry({ uri: 'at://old', reviewedAt: '2026-08-01T00:00:00Z' }), entry({ uri: 'at://young', reviewedAt: '2026-08-02T00:00:00Z' }), // A fresh reply bubbles the older thread up without becoming its face. entry({ uri: 'at://answer', reviewedAt: '2026-08-03T00:00:00Z', replyTo: { uri: 'at://old' }, }), ]); expect(summaries.map((t) => t.root.uri)).toEqual([ 'at://old', 'at://young', ]); expect(summaries[0].replies).toBe(1); expect(summaries[1].replies).toBe(0); // The whole conversation rides along, for the page that shows it. expect(summaries[0].thread.map((s) => s.uri)).toEqual([ 'at://old', 'at://answer', ]); }); it('roots a reply whose parent is missing at itself', () => { const summaries = threadSummaries([ entry({ uri: 'at://orphan', reviewedAt: '2026-08-01T00:00:00Z', replyTo: { uri: 'at://gone' }, }), ]); expect(summaries).toHaveLength(1); expect(summaries[0].replies).toBe(0); }); }); describe('placeComments, on a range', () => { const version = { sha: sha('t'), author: null, ref: null }; const review = (n, anchor) => ({ uri: `at://x/dev.pdsjs.git.review/${n}`, sha: sha('t'), verdict: 'comment', reviewedAt: `2026-08-0${n}T00:00:00Z`, anchor, by: { did: 'did:plc:a' }, }); it('hangs a range thread on its last line and marks the span between', () => { const { byFile, spans } = placeComments( [ review(1, { path: 'a.js', line: 3, endLine: 6, side: 'new' }), review(2, { path: 'a.js', line: 9, side: 'new' }), ], version, ); const lines = byFile.get('a.js'); // The range sits on line 6, the single comment on line 9. expect([...lines.keys()].sort()).toEqual(['new:6', 'new:9']); expect(spans.get('a.js')).toEqual([{ side: 'new', start: 3, end: 6 }]); }); it('reads a row as inside a span on its own side only', () => { const spans = [{ side: 'new', start: 3, end: 6 }]; expect(inSpan(spans, { oldLine: null, newLine: 4 })).toBe(true); expect(inSpan(spans, { oldLine: null, newLine: 6 })).toBe(true); expect(inSpan(spans, { oldLine: null, newLine: 7 })).toBe(false); // Same number, other side: not in the span. expect(inSpan(spans, { oldLine: 4, newLine: null })).toBe(false); expect(inSpan(undefined, { oldLine: null, newLine: 4 })).toBe(false); }); }); describe('placeComments', () => { const version = { sha: sha('a'), author: 'did:plc:bob', ref: 'refs/heads/agent/docs', }; /** @param {Partial>} value */ const statement = (value) => ({ sha: sha('a'), author: 'did:plc:bob', ref: 'refs/heads/agent/docs', verdict: 'comment', by: { did: 'did:plc:nova', handle: 'nova' }, ...value, }); it('places a comment under its line, keyed like a diff row', () => { const { byFile } = placeComments( [statement({ anchor: { path: 'index.js', line: 2, side: 'new' } })], version, ); expect(byFile.get('index.js')?.get('new:2')).toHaveLength(1); expect(anchorKey({ line: 2 })).toBe('new:2'); expect(lineKey({ oldLine: 7, newLine: null })).toBe('old:7'); }); it('counts a comment on another version rather than placing it', () => { const { byFile, elsewhere } = placeComments( [ statement({ sha: sha('b'), anchor: { path: 'index.js', line: 2 }, }), ], version, ); expect(byFile.size).toBe(0); expect(elsewhere).toBe(1); }); it('places on one commit only the reviews written against it', () => { // A commit read under its pull request names no branch of its own, so // the account-and-ref match is off and the sha is the whole test. That is // the rule a forge uses: a review shows on the commit it answered. const onThisCommit = { sha: sha('a'), author: null, ref: null }; const { byFile, elsewhere } = placeComments( [ statement({ anchor: { path: 'index.js', line: 2 } }), // Written against a different commit of the same branch. Its line // number is true of that commit and says nothing about this one. statement({ sha: sha('b'), anchor: { path: 'index.js', line: 2 } }), ], onThisCommit, ); expect(byFile.get('index.js')?.get('new:2')).toHaveLength(1); // The other review is passed over rather than counted as behind. Behind // is a fact about a branch that moved on; a commit does not move, so a // review of a different commit is simply about something else. expect(elsewhere).toBe(0); }); it('ignores unanchored reviews and other pull requests entirely', () => { const { byFile, elsewhere } = placeComments( [ statement({}), statement({ sha: sha('z'), ref: 'refs/heads/agent/other', anchor: { path: 'x', line: 1 }, }), ], version, ); expect(byFile.size).toBe(0); expect(elsewhere).toBe(0); }); }); describe('loadPulls', () => { const original = globalThis.fetch; const originalLocation = globalThis.location; afterEach(() => { globalThis.fetch = original; globalThis.location = originalLocation; account.did = ''; account.pds = ''; account.handle = ''; }); it('reads the list from the server, with the client and not its promise', async () => { // serverClient() is async. Calling a method on it without awaiting it // first asks the promise, which holds no methods. globalThis.location = new URL('https://forge.example/'); account.pds = 'https://pds.example'; /** @type {string[]} */ const asked = []; globalThis.fetch = async (/** @type {string|URL|Request} */ input) => { const url = String(input); asked.push(url); if (url.includes('dev.pdsjs.git.listPulls')) { return Response.json({ open: [ { branch: 'refs/heads/fix', sha: 'a'.repeat(40), author: { did: 'did:plc:friend' }, }, ], merged: [], collaborators: [], reviews: [], checks: [], configured: true, }); } if (url.includes('app.bsky.actor.getProfiles')) { return Response.json({ profiles: [] }); } return Response.json({ repos: [] }); }; const pulls = await loadPulls('demo-app'); expect(pulls.open).toHaveLength(1); expect(asked.some((url) => url.includes('dev.pdsjs.git.listPulls'))).toBe( true, ); }); });