~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
371 lines · 12.2 KB Raw History
/**
 * 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<Record<string, unknown>>} 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<Record<string, unknown>>} 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<Record<string, unknown>>} 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,
    );
  });
});