OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface / test / keys.test.js

125 lines · javascript ref:
// ⛔ A KEY IS AN IDENTIFIER, NOT A SENTENCE.
//
// Five pages printed a full 64-character key as body text — person, knocks, room, search, settings —
// where it wraps across the column and dominates everything a reader came for. Neither Bluesky nor
// Mastodon shows an identifier anywhere in reading flow, and measured against them this was the
// single most consistent difference across our twelve pages.
//
// ⛔ BUT IT IS NEVER REMOVED. `who()` documents why the byline key exists: a self-label is a claim,
// nothing enforces uniqueness, and hiding the key behind a name would make impersonation a matter
// of typing. That argument applies to the full key too — it is how you verify, follow, and join. So
// the key is DEMOTED, not deleted: out of the reading flow, one click away, still selectable.
//
// ⛔ AND NOTHING BECOMES NAMELESS. Where the key is the ONLY identity we have — a stranger who
// knocked, a feed with no profile — a short prefix stays visible. Collapsing it there would hide the
// only thing on the row that identifies anybody.

import test from 'brittle'
import { person, knocks, room, search, settings, keyline } from '../lib/pages.js'

const A = 'ab'.repeat(32)
const B = 'cd'.repeat(32)
const profiles = { [A]: { handle: 'tabertron0', name: 'tabertron', root: 'ef'.repeat(32) } }

// What a reader SEES.
//
// ⛔ THE STYLESHEET COMES OUT FIRST. Stripping tags alone leaves the whole of CSS behind as "text",
// and a CSS COMMENT here contains the literal string "<details>" — so the disclosure-stripping regex
// ran from inside the stylesheet to the first real </details> and swallowed the markup in between,
// failing a page that was correct. Substring for structure, for the fifth time in this suite.
const body = (html) => html.replace(/<style[\s\S]*?<\/style>/g, ' ')
const visible = (html) => body(html).replace(/<[^>]+>/g, ' ')
// The same, with every <details> body removed — what is on screen before anyone clicks.
const atRest = (html) => body(html).replace(/<details[\s\S]*?<\/details>/g, ' ').replace(/<[^>]+>/g, ' ')
const longestKey = (s) => {
  const hits = s.match(/[0-9a-f]{16,}/g) || []
  return hits.length ? Math.max(...hits.map((h) => h.length)) : 0
}

const PAGES = {
  person: () => person(A, profiles[A], [], null, { held: true, following: true }),
  knocks: () => knocks([{ feed: B }]),
  room: () => room(A, { role: 'admin', config: { title: 'the fleet' }, members: [], messages: [], requests: [], listings: [] }, profiles),
  search: () => search('x', [], 0, [], profiles, null, [{ handle: 'tabertron0', name: 'tabertron', feed: A, verdict: 'confirmed' }]),
  settings: () => settings(profiles[A], null, 'tabertron0', { key: B, claims: 1, founded: true, admins: [], writers: [], requests: [], carrying: true })
}

test('★★ no page puts a FULL key in the reading flow', async (t) => {
  for (const name of Object.keys(PAGES)) {
    const html = PAGES[name]()
    t.ok(longestKey(atRest(html)) < 64, name + ': nothing 64 chars long is on screen at rest')
  }
})

test('⛔ but the full key is STILL THERE, one click away', async (t) => {
  // Demoted, not deleted. It is how you verify a stranger, follow a feed and join a room, and a
  // page that dropped it would make those things impossible from the interface.
  for (const name of Object.keys(PAGES)) {
    const html = PAGES[name]()
    t.ok(longestKey(visible(html)) >= 64, name + ': the key is on the page')
    t.ok(html.includes('<details'), name + ': behind a disclosure, which needs no JavaScript')
  }
})

test('⛔ where the key is the ONLY name, a prefix stays visible', async (t) => {
  // A stranger who knocked has no profile: we hold nothing of theirs. Collapsing their key hides
  // the only thing on the row that says who knocked.
  const html = knocks([{ feed: B }])
  t.ok(/cdcdcdcd/.test(atRest(html)), 'a short prefix is on screen')
  // Same for a feed we hold nothing about.
  const nameless = person(B, null, [], null, { held: false })
  t.ok(/cdcdcdcd/.test(atRest(nameless)), 'a profile with no name still shows one')
})

test('⚠ keyline is the ONE way this is done', async (t) => {
  // A helper rather than five hand-rolled disclosures, because the next page to print a key will
  // copy whatever the last one did.
  const out = keyline('feed key', A)
  t.ok(out.startsWith('<details'))
  t.ok(out.includes(A), 'carries the whole key')
  t.ok(out.includes('<summary>'), 'and says what it is before you open it')
})

// ── every row that names a person has a face ──────────────────────────────────────────────────
//
// Mastodon draws a 40px avatar on every notification row; Bluesky does the same on follow lists.
// Ours drew one only when the person had uploaded a picture, and NOTHING when they had not — while
// post rows always draw a grey circle. So the timeline had a left edge and every list did not, and
// a list of people read as loose text rather than as rows.

test('★★ a person with NO picture still occupies the picture', async (t) => {
  const { who } = await import('../lib/pages.js')
  const withPic = who(A, { [A]: { handle: 'a0', avatar: 'ff'.repeat(32) } })
  const noPic = who(A, { [A]: { handle: 'a0' } })
  t.ok(withPic.includes('<img'), 'a picture is shown when there is one')
  // ⛔ Not an empty string. The placeholder is what gives every row the same left edge; without it
  // the rows that happen to have pictures are indented and the rest are not.
  t.ok(/class="av[- ]/.test(noPic) || noPic.includes('av-dot'),
    'and a placeholder stands in its place when there is not')
})

test('★ the lists that name people all use the SAME renderer', async (t) => {
  const { following, notifications } = await import('../lib/pages.js')
  const bare = { [A]: { handle: 'a0' } }
  for (const [name, html] of [
    ['following', following([A], [], bare)],
    ['notifications', notifications([{ kind: 'follow', author: A, target: null }], bare)]
  ]) {
    // One renderer, so a change to how a person looks lands everywhere at once rather than in the
    // one place somebody remembered.
    t.ok(html.includes('class="byline"'), name + ' renders people through who()')
    t.ok(/av-dot|class="av"/.test(html), name + ' shows a face or its placeholder')
  }
})

test('⛔ a People result NAMES somebody', async (t) => {
  const { search } = await import('../lib/pages.js')
  // ⛔ The daemon's shape, pinned. `searchProfiles` returns {author, ...profile}; the harness fixture
  // passed bare keys, so every People row rendered a placeholder circle, a bullet and no name — and
  // it looked exactly like a styling bug rather than a fixture that did not match the daemon.
  const html = search('a', [], 0, [{ author: A, handle: 'tabertron0', name: 'tabertron' }], null, null, [])
  const section = html.slice(html.indexOf('<h2>People</h2>'))
  t.ok(section.includes('tabertron0'), 'the row says who it found')
  t.ok(section.includes('/u/' + A), 'and links to them')
})