OverviewFilesCommitsIssuesDocsReleasesPeers

favela-surface / test / pages.test.js

926 lines · javascript ref:
// The pages are pure, so nearly everything worth asserting is testable with no socket and no server.

import test from 'brittle'
import fs from '#node:fs'
import { timeline, search, following, knocks, post, esc, layout, who, person, settings, handleSettings, namespaceSettings, composer, mentions, notifications, thread as pages_thread, withUpdateBanner, updateSettings, discover, status } from '../lib/pages.js'

const row = (over = {}) => ({
  author: 'ab'.repeat(32), collection: 'post', name: 'p1',
  createdAt: '2026-09-06T12:00:00Z', body: 'hello **world**', ...over
})

test('a post renders its markdown, not its source', (t) => {
  const html = post(row())
  t.ok(html.includes('<strong>world</strong>'), 'rendered')
  t.absent(html.includes('**world**'), 'not raw')
})

test('⛔ a hostile body cannot inject markup', (t) => {
  const html = post(row({ body: '<img src=x onerror=alert(1)>' }))
  // ⚠ Assert STRUCTURE, not substrings. `onerror=` DOES appear in the output — as escaped text
  // inside `&lt;img src=x onerror=alert(1)&gt;`, which is exactly right and completely inert. I made
  // the substring mistake twice today (once in render.test.js) and it fails correct code both times.
  t.ok(html.includes('&lt;img'), 'the tag is escaped into text')
  t.absent(html.includes('<img'), 'and no img element exists')
  t.is(html.match(/<[a-z]/g).join('').length > 0, true, 'there is real markup around it')
})

test('⛔ a hostile author key or name cannot escape an attribute', (t) => {
  const html = post(row({ author: '"><script>x</script>', name: '"><b>' }))
  t.absent(html.includes('<script>'), 'escaped')
  // One form per reaction button now, so the count is a FUNCTION of the palette rather than a
  // constant. What must hold is that a hostile value injected none of them.
  const forms = html.match(/<form /g).length
  t.is(html.match(/action="\/(un)?(react|repost)"/g).length, forms, 'every form is one we emitted')
  // ⚠ NOT a substring hunt for `"><` — that sequence occurs legitimately in every attribute
  // followed by a tag (`class="who"><a`). Assert the hostile text is present only in ESCAPED form
  // and that it produced no element. Substring-vs-structure has failed correct code here twice.
  t.ok(html.includes('&quot;&gt;&lt;script&gt;'), 'the hostile author is escaped text')
  t.absent(/class="handle"/.test(html), 'and the hostile NAME was not promoted into a name slot')
})

test('★ an empty timeline and an UNREADABLE one do not look alike', (t) => {
  const empty = timeline([])
  const gap = timeline([], { unreadable: 12 })
  t.ok(empty.includes('Nothing yet.'))
  t.ok(gap.includes('12 record(s) could not be read'), 'the gap is named')
  t.absent(empty.includes('could not be read'), 'and not claimed when there is none')
})

test('search says what it could not read, and an empty query shows no results', (t) => {
  t.ok(search('', [], 0).includes('<form'), 'just the form')
  t.absent(search('', [], 0).includes('No matches'), 'an empty box is not a search')
  t.ok(search('x', [], 0).includes('No matches.'))
  t.ok(search('x', [], 3).includes('3 record(s) in scope could not be read'))
})

test('★ a followed feed we cannot read is NAMED, not silently listed as normal', (t) => {
  const k = 'cd'.repeat(32)
  const html = following([k], [k])
  t.ok(html.includes('not replicated'), 'the gap is visible')
  t.absent(following([k], []).includes('not replicated'), 'and absent when there is none')
})

test('⛔ the knock button says FOLLOW, because that is what accepting does', (t) => {
  const html = knocks([{ feed: 'ef'.repeat(32), device: 'ab' }])
  t.ok(html.includes('follow them'), 'never "accept" — accepting does not grant them anything')
  t.absent(html.toLowerCase().includes('grant'))
})

test('layout escapes the title and always emits a charset', (t) => {
  const html = layout('<script>', '')
  t.absent(html.includes('<script>'))
  t.ok(html.includes('charset="utf-8"') || html.includes('charset=utf-8'))
})

test('esc covers the five', (t) => {
  t.is(esc(`&<>"'`), '&amp;&lt;&gt;&quot;&#39;')
})

// ── handles in a byline ───────────────────────────────────────────────────────────────────────
//
// Three names, three kinds of claim, and the page has to keep them apart:
//
//   @alice0      HANDLE      claimed in a namespace, unique within it, attested to the feed's owner
//   Alice Smith  SELF-LABEL  what the feed calls itself; unique to nobody, provable by nobody
//   fd9ba4c6     KEY         the only unforgeable thing on the row

const A = 'ab'.repeat(32)

test('★ a byline shows the HANDLE, the self-label and the key — all three', (t) => {
  const html = who(A, { [A]: { handle: 'alice0', name: 'Alice Smith', avatar: null } })
  t.ok(html.includes('@alice0'), 'the handle')
  t.ok(html.includes('Alice Smith'), 'the self-label')
  t.ok(html.includes('<code>' + A.slice(0, 8) + '</code>'), 'and the key, always')
})

test('⛔ the handle and the self-label are not the same WEIGHT', (t) => {
  const html = who(A, { [A]: { handle: 'alice0', name: 'Alice Smith' } })
  // Two names in one weight read as one name, and these are different kinds of claim. The weight
  // now comes from CSS, so the structural assertion is that they carry DIFFERENT classes and only
  // one of them is the promoted slot.
  t.ok(/class="handle">@alice0</.test(html), 'the handle takes the promoted slot')
  t.ok(/class="label">Alice Smith</.test(html), 'and the self-label its own, quieter one')
  t.is((html.match(/class="handle"/g) || []).length, 1, 'only one name is promoted')
})

test('with no handle the self-label takes the bold slot', (t) => {
  const html = who(A, { [A]: { name: 'Alice Smith' } })
  t.ok(/class="handle">Alice Smith</.test(html), 'promoted, since nothing outranks it')
  t.absent(html.includes('@'), 'and no empty handle is invented')
})

test('⛔ with neither, the key stands alone — and is still a link', (t) => {
  const html = who(A, null)
  t.ok(html.includes('<code>' + A.slice(0, 8) + '</code>'))
  t.ok(html.includes('href="/u/' + A + '"'), 'the key is how you look someone up')
  t.absent(html.includes('class="handle"'), 'nothing is promoted that was never claimed')
})

test('⛔ a hostile handle cannot inject markup', (t) => {
  // A handle comes from a namespace ANYONE may write to, so it is attacker-controlled text that
  // reached the daemon, passed apply, and is now being rendered.
  const html = who(A, { [A]: { handle: '"><script>x</script>' } })
  t.absent(html.includes('<script>'), 'escaped')
  t.ok(html.includes('&lt;script&gt;'), 'as text')
  // ⚠ TWO anchors by design now — the name and the key link separately, so the key stops being
  // underlined as part of one run-on name. What must hold is that a hostile handle added none.
  t.is((html.match(/<a /g) || []).length, 2, 'the two we emit, and no more')
})

test('★ a profile page is headed by the HANDLE when there is one', (t) => {
  const withHandle = person(A, { handle: 'alice0', name: 'Alice Smith' }, [])
  t.ok(/<h1>@alice0<\/h1>/.test(withHandle), 'the handle heads the page')
  t.ok(withHandle.includes('calls itself Alice Smith'), 'and the self-label is named as such')
  t.ok(withHandle.includes('confirmed against'), 'with what makes the handle different, said plainly')

  const labelOnly = person(A, { name: 'Alice Smith' }, [])
  t.ok(/<h1>Alice Smith<\/h1>/.test(labelOnly), 'otherwise the self-label heads it')
  t.ok(labelOnly.includes('not a unique handle'), 'and is still marked as a claim')

  const neither = person(A, {}, [])
  t.ok(neither.includes('no published name'), 'and a feed with neither says so')
  t.ok(neither.includes(A), 'the full key is on the profile page, not a prefix')
})

// ── the door ──────────────────────────────────────────────────────────────────────────────────
//
// ⛔ The daemon could claim a name from the start and nothing on the surface could ask it to. With
// no installed CLI launcher either, that meant no person could claim a name at all. These tests
// exist so the form cannot quietly go missing again.

test('★ with no handle, settings offers to CLAIM one', (t) => {
  const html = handleSettings(null, { key: 'cd'.repeat(32), claims: 0 })
  t.ok(/action="\/handle"/.test(html), 'there is a form, pointed at a real route')
  t.ok(/name="name"/.test(html), 'and a field to type a name into')
  t.ok(html.includes('alice0'), 'the numbering rule is stated BEFORE submitting, not after')
  t.ok(html.includes('may not END in a digit'), 'and so is the constraint that surprises people')
})

test('★ with a handle, settings offers to RELEASE it — and says what that costs', (t) => {
  const html = handleSettings('alice0', { key: 'cd'.repeat(32), claims: 1 })
  t.ok(html.includes('<b>@alice0</b>'), 'the handle is shown')
  t.ok(/name="release" value="alice0"/.test(html), 'the release form names it exactly')
  t.absent(/name="name"/.test(html), 'and no claim field — one handle at a time')
  // ⚠ NAMING.md: a number is never reused, because a familiar handle pointing at someone new is
  // the one failure this design can actually cause. Saying so is part of the feature.
  t.ok(html.includes('retires that number'), 'the cost of releasing is stated')
})

test('⛔ the NAMESPACE KEY is shown IN FULL, or nobody could ever share it', (t) => {
  const key = 'cd'.repeat(32)
  // The guarantee is about the PAGE, not about one function on it: the full key moved into the
  // namespace section when that section gained the join form, and truncating it anywhere that is
  // the only place it appears would put the fleet back into separate worlds.
  for (const ns of [{ key, claims: 0 }, { key, claims: 1, carrying: true, admins: ['x'], writers: [] }]) {
    t.ok(settings({}, null, null, ns).includes(key), 'the whole 64 characters, copyable')
  }
  t.ok(handleSettings(null, { key, claims: 0 }).includes('same namespace'),
    'and the handle section still says why a handle only resolves for people on it')
  // ⚠ Not the word "namespace" — the claim form's own prose says it. What must be absent is any
  // FACT about a namespace when there is none to report.
  t.absent(handleSettings(null, null).includes('Claimed in namespace'), 'absent rather than fabricated')
  t.is(namespaceSettings(null), '', 'and no namespace section at all without one')
})

test('★ the JOIN FORM is on the settings page — the door an installed node has no CLI for', (t) => {
  const key = 'cd'.repeat(32)
  const html = settings({}, null, null, { key, claims: 0, carrying: true, admins: ['a'], writers: [], requests: [] })
  t.ok(html.includes('action="/namespace/join"'), 'joining another namespace is reachable')
  t.ok(html.includes('action="/namespace/invite"'), 'an admin can mint an invite')
  // ⛔ Joining destroys nothing, and the page has to say so — it is the one thing a person would
  // reasonably fear about pressing this button.
  t.ok(html.includes('stays on disk'), 'and it says the namespace you leave is kept')
})

test('a node that does not carry the log is offered the ASK, not the invite button', (t) => {
  const key = 'cd'.repeat(32)
  const html = settings({}, null, null, { key, claims: 0, carrying: false, admins: ['a'], writers: [] })
  t.ok(html.includes('action="/namespace/request"'), 'it can ask to carry it')
  t.absent(html.includes('action="/namespace/invite"'),
    'and is not shown a button that would fail — a weightless node can admit nobody')
})

test('⚠ a minted invite is rendered once, and pending asks are answerable', (t) => {
  const key = 'cd'.repeat(32)
  const token = 'ab'.repeat(32)
  const ns = {
    key,
    claims: 0,
    carrying: true,
    admins: ['a'],
    writers: [],
    requests: [{ writer: 'ef'.repeat(32), invited: true }],
    invites: [{ hash: '12'.repeat(32), used: false }]
  }
  const html = namespaceSettings(ns, token)
  t.ok(html.includes(token), 'the token is on the page that minted it')
  t.ok(html.includes('Shown once'), 'and says so, because it is stored nowhere')
  t.ok(html.includes('action="/namespace/admit"'), 'the pending ask has an admit button')
  t.ok(html.includes('action="/namespace/withdraw"'), 'and the unspent invite can be withdrawn')
  t.absent(namespaceSettings(ns).includes(token), 'a later page load does not know it')
})

test('the settings page carries the handle section', (t) => {
  const html = settings({ name: 'Alice' }, null, 'alice0', { key: 'cd'.repeat(32), claims: 1 })
  t.ok(html.includes('Display name'), 'the profile half is still there')
  t.ok(html.includes('@alice0'), 'and the handle half now is too')
  t.is((html.match(/action="\/handle"/g) || []).length, 1, 'exactly one handle form')
})

test('⛔ a hostile handle cannot escape the release form attribute', (t) => {
  const html = handleSettings('"><script>x</script>', { key: 'cd'.repeat(32), claims: 1 })
  t.absent(html.includes('<script>'), 'escaped')
  t.is((html.match(/<form /g) || []).length, 1, 'exactly one form — nothing injected another')
})

// ── embedded files ────────────────────────────────────────────────────────────────────────────

const OID = 'a1b2'.repeat(8)
const withFile = (body, files) => post({ ...row(), body }, null, files)

test('★ a post renders an embedded image, video and PDF by their TYPE', (t) => {
  const img = withFile('![cat](favela://blob/' + OID + ')', { [OID]: 'image/png' })
  t.ok(img.includes('<img class="embed" loading="lazy" src="/blob/' + OID + '" alt="cat">'), 'an image')

  const vid = withFile('![clip](favela://blob/' + OID + ')', { [OID]: 'video/mp4' })
  t.ok(vid.includes('<video'), 'the same syntax, video bytes, a video element')
  t.absent(vid.includes('<img'), 'not a broken image')

  const doc = withFile('[report](favela://blob/' + OID + ')', { [OID]: 'application/pdf' })
  t.ok(doc.includes('type="application/pdf"'), 'a PDF is shown inline')
})

test('⛔ an unresolved file renders as NOTHING, never as a guess', (t) => {
  // We could not establish what it is — it may not have replicated yet. Guessing is how a text
  // file becomes an <img>.
  const html = withFile('![x](favela://blob/' + OID + ')', {})
  t.absent(html.includes('<img'), 'no element')
  t.absent(html.includes('/blob/'), 'and no src pointing at bytes we could not identify')
})

test('the composer offers a file, and says the type comes from the bytes', (t) => {
  const html = composer()
  t.ok(/enctype="multipart\/form-data"/.test(html), 'multipart, because it carries a file')
  t.ok(/type="file" name="file"/.test(html))
  t.ok(html.includes('never its name'), 'the typing rule is stated where a person chooses a file')
  t.ok(html.includes('SVG is refused'))
})

test('⛔ a hostile alt text cannot inject markup through an embed', (t) => {
  // The alt comes from the uploader's filename — attacker-controlled text that reached a record.
  const html = withFile('![" onerror=alert(1) x="](favela://blob/' + OID + ')', { [OID]: 'image/png' })
  t.absent(/onerror=alert\(1\)/.test(html.replace(/&quot;/g, '')) && html.includes('<img src="/blob/' + OID + '" alt="" '),
    'the quote is escaped, so it cannot close the attribute')
  t.is((html.match(/<img /g) || []).length, 1, 'exactly one img — nothing injected another')
})

test('★ an embedded image is a constrained PREVIEW that opens full size', (t) => {
  const html = withFile('![cat](favela://blob/' + OID + ')', { [OID]: 'image/png' })
  t.ok(html.includes('class="embed"'), 'the class the stylesheet constrains')
  t.ok(html.includes('<a class="lightbox" href="/blob/' + OID + '">'), 'wrapped in a link to the file')
  t.ok(html.includes('loading="lazy"'), 'a timeline should not fetch every picture at once')
  // ⛔ The cap is on the STYLESHEET, not on the bytes — the full file is one click away and no
  // resized copy is produced or stored anywhere.
  t.ok(layout('x', html).includes('max-height:60vh'), 'and the stylesheet actually caps it')
  t.ok(layout('x', html).includes('object-fit:contain'), 'without cropping')
})

test('★ several attachments render as one lighttable, in order', (t) => {
  const A = 'a1'.repeat(16); const B = 'b2'.repeat(16)
  const files = { [A]: 'image/png', [B]: 'image/jpeg' }
  const body = '![one](favela://blob/' + A + ')\n\n![two](favela://blob/' + B + ')'
  const html = post({ ...row(), body }, null, files)

  t.is((html.match(/<div class="lighttable">/g) || []).length, 1, 'one block, not a vertical stack')
  t.ok(html.indexOf('/blob/' + A) < html.indexOf('/blob/' + B), 'in the order they were written')

  const css = layout('x', html)
  t.ok(css.includes('.lighttable{display:grid'), 'a grid — order is preserved')
  t.absent(css.includes('.lighttable{columns'), 'not CSS columns, which flow down-then-across')
  t.ok(css.includes('aspect-ratio:1;max-height:none;object-fit:cover'),
    'square thumbnails; the anchor gives back the whole picture')
})

// ── the like button carries STATE ─────────────────────────────────────────────────────────────

test('★ a liked post offers UNLIKE, an unliked one offers LIKE', async (t) => {
  const row = { author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'hi', createdAt: '2026-09-07T00:00:00Z' }
  const off = post({ ...row, liked: false }, null)
  const on = post({ ...row, liked: true }, null)

  // ⛔ Like is now a reaction VALUE, so it posts to /react|/unreact carrying value=like — but it is
  // still offered as itself rather than as one emoji among several, because it is the only value
  // both target networks can carry.
  t.ok(off.includes('action="/react"') && off.includes('value="like"'), 'not liked yet')
  t.ok(on.includes('action="/unreact"') && on.includes('value="like"'),
    'liked, and reversible from the same place')
  // ⛔ The two states must not render identically. They did: a bare `like` button on every post,
  // so a reader could not tell, and pressing twice looked like pressing once.
  t.not(off, on, 'the two states are distinguishable')
  // ⚠ Not by colour alone — a reader who cannot see it gets the same information from the glyph.
  t.ok(on.includes('♥'), 'and the difference survives without CSS')
  t.ok(off.includes('♡'))
})

test('⛔ an absent `liked` field renders the offer, never a false claim', async (t) => {
  const row = { author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'hi' }
  // A daemon that did not stamp the field must not make the page assert something about it.
  t.ok(post(row, null).includes('action="/react"') && post(row, null).includes('value="like"'))
})

test('a like carries where it came from, so it does not throw the page away', async (t) => {
  const row = { author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'hi' }
  const html = search('hello', [row], 0, [], null)
  t.ok(html.includes('name="from"'), 'the search page says where to come back to')
  t.ok(html.includes('/search?q=hello'))
})

// ── handles reach the search box ──────────────────────────────────────────────────────────────

test('★ searching finds HANDLES, not only feeds you already hold', async (t) => {
  // ⛔ The gap this closes: `search` looks at records and profiles of feeds you HOLD. A peer you
  // have never followed has neither, so a fleet that shares a namespace could not find itself —
  // the handle was resolvable the whole time and nothing asked.
  const handles = [{ handle: 'ashatron0', feed: 'ab'.repeat(32), verdict: 'unheld' }]
  const html = search('ashatron', [], 0, [], null, null, handles)
  t.ok(html.includes('@ashatron0'), 'the handle is a result')
  t.ok(html.includes('action="/follow"'), 'and you can act on it without pasting a key')
  t.ok(html.includes('ab'.repeat(32)), 'the key is shown too — it is the only unforgeable name')
})

test('⚠ an unheld handle says so rather than looking confirmed', async (t) => {
  const html = search('x', [], 0, [], null, null,
    [{ handle: 'a0', feed: 'ab'.repeat(32), verdict: 'unheld' },
      { handle: 'b0', feed: 'cd'.repeat(32), verdict: 'confirmed' }])
  // A claim points at a feed; it does not prove the feed agrees. Rendering both identically is the
  // display-layer half of the rename attack `confirmedHandles` refuses upstream.
  t.ok(html.includes('unheld'), 'the verdict is visible')
  t.ok(html.includes('@a0') && html.includes('@b0'))
})

test('⛔ a MISMATCHED handle is not offered at all', async (t) => {
  const html = search('x', [], 0, [], null, null,
    [{ handle: 'liar0', feed: 'ab'.repeat(32), verdict: 'MISMATCH' }])
  // This is the attack: a name claimed at someone else's feed key. It must never be a row a person
  // can click follow on.
  t.absent(html.includes('@liar0'), 'a claim the feed contradicts is refused, not annotated')
})

// ── the person page, for someone you do not hold ──────────────────────────────────────────────

test('★ a feed you do not hold offers a LOOK, and does not claim they posted nothing', async (t) => {
  const html = person('ab'.repeat(32), {}, [], null, { held: false, peeked: false, following: false })
  t.ok(html.includes('action="/peek"'), 'you can look without following')
  // ⛔ "Nothing from them yet" on a feed we have no copy of is a lie — the same silent-absence
  // failure the daemon refuses everywhere else.
  t.absent(html.includes('Nothing from them yet'), 'empty-because-unheld is said, not implied')
  t.ok(html.includes('discovery topic'), 'and the cost of looking is stated before you press it')
})

test('a peeked feed offers FOLLOW and says why the timeline is quiet', async (t) => {
  const html = person('ab'.repeat(32), {}, [], null, { held: true, peeked: true, following: false })
  t.ok(html.includes('action="/follow"'))
  t.ok(html.includes('not in your timeline'), 'peeking is not following, and the page says so')
  t.absent(html.includes('action="/peek"'), 'no offer to do what is already done')
})

test('a followed feed offers neither', async (t) => {
  const html = person('ab'.repeat(32), {}, [], null, { held: true, peeked: false, following: true })
  t.absent(html.includes('action="/peek"'))
  t.absent(html.includes('action="/follow"'))
  t.ok(html.includes('Nothing from them yet'), 'and an empty followed feed IS empty')
})

test('⚠ a feed still ARRIVING says so — not "nothing", not "take a look"', async (t) => {
  const html = person('ab'.repeat(32), {}, [], null,
    { held: false, peeked: false, following: false, opening: true })
  t.ok(html.includes('Fetching'), 'the fourth state has its own page')
  // ⛔ Offering a look that is already underway invites a second one; claiming they posted nothing
  // is the silent-absence lie. Both are wrong for the same reason.
  t.absent(html.includes('action="/peek"'), 'no offer to start what has started')
  t.absent(html.includes('Nothing from them yet'), 'and no claim about their posting')
})

// ── repost and quote ──────────────────────────────────────────────────────────────────────────

test('★ repost is its own button, and reversible', (t) => {
  const r = { author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'x' }
  const off = post({ ...r, reposted: false }, null)
  const on = post({ ...r, reposted: true }, null)
  t.ok(off.includes('action="/repost"'))
  t.ok(on.includes('action="/unrepost"'), 'undoing it is offered where doing it was')
  // ⛔ Three verbs, three controls. A repost is not a like and not a reply, and each maps to a
  // different thing outward — collapsing any two loses a mapping.
  t.ok(off.includes('action="/react"'), 'and it did not replace the reaction')
})

test('⛔ quote is a LINK to the composer, not an action', (t) => {
  const html = post({ author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'x' }, null)
  // "quotes are just links" — there is no quote record to create, so there is no form to post. It
  // seeds the composer and what results is an ordinary post.
  t.ok(html.includes('href="/?quote='), 'a link')
  t.absent(html.includes('action="/quote"'), 'never a route that would imply a quote record exists')
})

test('the composer carries a quote as EDITABLE text', (t) => {
  const html = composer(null, 'favela://ab/post/p1')
  t.ok(html.includes('favela://ab/post/p1'), 'seeded into the box')
  t.ok(html.includes('you can change it'), 'and said to be the author\'s own text')
  t.absent(composer(null).includes('favela://'), 'absent when not quoting')
})

test('★ the mentions page states its own limits rather than implying none', (t) => {
  const empty = mentions([])
  // ⛔ An empty list must not read as "nobody has mentioned you". Only feeds this node HOLDS are
  // searched, which is the structural limit of the whole model, and only a real link counts.
  t.ok(empty.includes('Nobody you hold has named you'), 'scoped to what we hold, in the wording')
  t.ok(empty.includes('bare'), 'and a bare @name is said not to count')
  const one = mentions([{ author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'hi [@me](favela://x)' }])
  t.ok(one.includes('<article'), 'a mention renders as the post it is')
})

// ── threads ───────────────────────────────────────────────────────────────────────────────────
//
// ⛔ `reply` and `replies` have been in the daemon since the beginning and nothing on the surface
// could reach either — carried open in #62 and #63. A conversation you can start from a CLI and not
// from the product is not shipped.

test('★ a post links to its thread', (t) => {
  const html = post({ author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'x' }, null)
  t.ok(html.includes('href="/t/'), 'every post is a way into its own conversation')
})

test('★ a thread shows the subject, then its replies oldest-first', (t) => {
  const A = 'ab'.repeat(32)
  const subject = { author: A, collection: 'post', name: 'p1', body: 'the question' }
  const replies = [
    { author: A, collection: 'reply', name: 'r1', body: 'first', createdAt: '2026-09-07T01:00:00Z' },
    { author: A, collection: 'reply', name: 'r2', body: 'second', createdAt: '2026-09-07T02:00:00Z' }
  ]
  // ⚠ Index over the BODY, not the document. The stylesheet is part of the output and its comments
  // are prose — an `indexOf` across the whole page finds words in the CSS and orders them against
  // content. Substring-vs-structure, in a test I wrote.
  const html = pages_thread(subject, replies, null, null).split('</style>').pop()
  t.ok(html.indexOf('the question') < html.indexOf('first'), 'the subject comes first')
  // ⚠ A thread reads FORWARDS where a timeline reads backwards — `peer.repliesTo` already sorts
  // oldest-first for exactly this, and the page must not re-sort it.
  t.ok(html.indexOf('first') < html.indexOf('second'), 'and replies in the order they were written')
  t.ok(html.includes('action="/reply"'), 'with a way to add one')
})

test('⛔ a thread for a subject we cannot read says so', (t) => {
  const html = pages_thread(null, [], null, null)
  // "no replies" and "we do not hold the post being replied to" are different facts. The second
  // used to be indistinguishable from an empty conversation.
  t.ok(html.includes('cannot be read'), 'the gap is named rather than rendered as emptiness')
})

// ── who somebody is ───────────────────────────────────────────────────────────────────────────

test('★ the following list shows PEOPLE, not keys', (t) => {
  const A = 'ab'.repeat(32)
  const html = following([A], [], { [A]: { handle: 'alice0', name: 'Alice', bio: 'builds p2p things' } })
  t.ok(html.includes('@alice0') && html.includes('Alice'), 'the same byline the timeline uses')
  t.ok(html.includes('builds p2p things'), 'and a sentence about who they are')
  // ⛔ The key is still there. It is the only unforgeable name on the row, and a list that dropped
  // it would make a self-label look authoritative.
  t.ok(html.includes(A.slice(0, 8)))
})

test('⚠ a followed feed we cannot read is still named as such', (t) => {
  const A = 'ab'.repeat(32)
  const html = following([A], [A], {})
  t.ok(html.includes('not replicated'), 'the gap survives the prettier rendering')
})

test('a bio is squeezed to one line in a list and RENDERED on the profile', (t) => {
  const A = 'ab'.repeat(32)
  const long = 'line one\n\n# a heading\n\n' + 'x'.repeat(300)
  const list = following([A], [], { [A]: { bio: long } })
  t.absent(list.includes('<h1'), 'a listing wants a sentence, not a heading')
  t.ok(list.includes('…'), 'and it is truncated')

  const page = person(A, { bio: 'builds **p2p** things' }, [], null, { held: true, following: true })
  t.ok(page.includes('<strong>p2p</strong>'), 'the profile page renders the markdown the author wrote')
})

test('★ settings offers a bio, prefilled with what is already published', (t) => {
  const html = settings({ name: 'Alice', bio: 'existing text' }, null, null, null)
  t.ok(html.includes('name="bio"'), 'the field exists at all')
  t.ok(html.includes('existing text'), 'and shows what is there, so saving does not silently clear it')
})

test('★ the block button says what a block actually does', (t) => {
  const A = 'ab'.repeat(32)
  const off = person(A, {}, [], null, { held: true, following: true, blocked: false })
  t.ok(off.includes('action="/block"'))
  // ⛔ The honest answer is weaker than the word. A button labelled "block" with nothing said would
  // promise enforcement this substrate cannot deliver.
  t.ok(off.includes('does not stop them'), 'the limit is stated on the button, not discovered later')

  const on = person(A, {}, [], null, { held: true, following: true, blocked: true })
  t.ok(on.includes('action="/unblock"'), 'and it is reversible')
  t.ok(on.includes('still on disk'), 'a block is not an eviction, and says so')
})

test('★ notifications name the person, the verb and the record', (t) => {
  const A = 'ab'.repeat(32)
  const target = { feed: 'cd'.repeat(32), collection: 'post', name: 'p1' }
  const html = notifications([
    { kind: 'reaction', author: A, value: 'like', target },
    { kind: 'reaction', author: A, value: '🔥', target },
    { kind: 'repost', author: A, target },
    { kind: 'reply', author: A, target, body: 'good point' },
    { kind: 'mention', author: A, target },
    { kind: 'follow', author: A, target: null },
    { kind: 'knock', author: A, target: null }
  ], { [A]: { name: 'Alice' } })

  t.ok(html.includes('liked your post'))
  t.ok(html.includes('🔥'), 'a reaction is not flattened into "liked"')
  t.ok(html.includes('reposted') && html.includes('replied') && html.includes('mentioned'))
  t.ok(html.includes('followed you') && html.includes('doorbell'))
  t.ok(html.includes('good point'), 'a reply shows what was said')
  t.ok(html.includes('href="/t/'), 'and links into the conversation')
})

test('⛔ an empty list states WHY it may be empty', (t) => {
  const html = notifications([])
  // "No notifications" here means something different from an empty inbox elsewhere: only feeds you
  // hold can appear at all. Leaving that implied would let absence read as "nobody interacted".
  t.ok(html.includes('nothing is sent to you'))
  t.ok(html.includes('doorbell'), 'and names the one thing a stranger can still do')
})

test('⛔ the stylesheet contains no BACKTICK', (t) => {
  // It lives in a template literal, so one backtick in a CSS comment terminates the string and the
  // module stops parsing — with an error pointing at a word, not at the quote. I made this mistake
  // twice in one session, the second time an hour after fixing the first.
  const src = fs.readFileSync(new URL('../lib/pages.js', import.meta.url).pathname, 'utf8')
  const css = src.slice(src.indexOf('const CSS = `') + 13, src.indexOf('\n`', src.indexOf('const CSS = `')))
  t.absent(css.includes('`'), 'a backtick in the CSS would break the module, not just the styles')
})

test('⛔ the page declares its own background, in both directions', (t) => {
  const css = layout('x', '')
  // A page that sets only `color` inherits whatever canvas the browser decided on, so forcing
  // color-scheme flips the text and leaves the background alone — which reads as "light mode does
  // not work" in any browser configured to render content dark.
  t.ok(/body\{[^}]*background:var\(--bg\)/.test(css.replace(/\n\s*/g, '')), 'body has a background')
  // ⛔ No light-dark(). One function carrying the entire palette means a browser without it loses
  // every colour at once, with no partial failure to notice — the page renders in initial values.
  // ⚠ Strip comments first. A comment mentioning a function is not a use of it, and searching raw
  // source for the name is the same substring-vs-structure mistake as the rest of today.
  const rules = css.replace(/\/\*[\s\S]*?\*\//g, '')
  t.absent(rules.includes('light-dark('), 'the palette does not depend on one modern function')
  t.ok(css.includes('[data-theme=dark]'), 'an explicit dark choice applies')
  t.ok(css.includes(':root:not([data-theme=light])'), 'and a light choice beats the device default')
})

// ── the update banner ────────────────────────────────────────────────────────────────────────────
//
// ⛔ Applied at SEND time like `withTheme`, so no page can forget it. These pin the one distinction
// that decides whether the notice means anything.
test('★ the banner appears only when a DIFFERENT bundle is staged', (t) => {
  const page = '<html lang="en"><body><h1>x</h1></body></html>'
  const on = withUpdateBanner(page, { running: 'aaaa', staged: 'bbbb' })
  t.ok(on.includes('update-bar'), 'a staged bundle that differs is an update')
  t.ok(on.includes('href="/settings"'), 'and it points at the page that can restart')

  // ⛔ The ordinary case: the node re-verified what it is already running. Announcing that as an
  // update trains people to ignore the banner, and then the real one does nothing.
  t.absent(withUpdateBanner(page, { running: 'aaaa', staged: 'aaaa' }).includes('update-bar'),
    'staged and identical is NOT an update')
  t.absent(withUpdateBanner(page, null).includes('update-bar'), 'no shell, no banner')
  t.absent(withUpdateBanner(page, { running: 'aaaa', staged: null }).includes('update-bar'),
    'nothing staged is not an update')

  // ⛔ THE CASE THAT MADE THIS USELESS ON A PHONE. `running` is null whenever the app booted the
  // APK's OWN bundle — `chooseBundle` has no hash for the asset — which is the normal state of a
  // fresh install. Requiring `running` meant a handset staged an update correctly and then rendered
  // "Up to date", with no banner. Reported as "check now does nothing", and it was this.
  t.ok(withUpdateBanner(page, { running: null, staged: 'bbbb' }).includes('update-bar'),
    '★ staged while running the APK asset IS an update waiting')
})

test('the banner sits at the top of the document, not wherever a page happens to put it', (t) => {
  const out = withUpdateBanner('<html lang="en"><body><h1>x</h1></body></html>', { running: 'a', staged: 'b' })
  t.ok(out.indexOf('update-bar') < out.indexOf('<h1>'), 'it cannot be pushed below the fold')
})

// ⛔ The version is the only line in the update section a person can compare against a release page.
// Without it, settings showed two tree hashes and "is my update installed?" was unanswerable from
// the device — which is exactly the state this section exists to prevent.
test('★ the update section names the running VERSION, not just tree hashes', (t) => {
  const html = updateSettings({ ota: true, running: 'aaaa', staged: 'aaaa', version: '0.0.4', builtAt: 1789054155366 })
  t.ok(html.includes('Running 0.0.4'), 'the version a release page would show')

  // ⚠ An older build predates the stamp. It must say so rather than render a blank or, worse, a
  // plausible-looking wrong version.
  const old = updateSettings({ ota: true, running: 'aaaa', staged: 'aaaa' })
  t.ok(old.includes('predates the stamp'), 'and an unstamped build says why it cannot tell you')
  t.absent(old.includes('Running undefined'), 'never a fabricated version')
})

// ── discovery ────────────────────────────────────────────────────────────────────────────────────
//
// ⛔ The page exists because joining a namespace and then finding nobody was the whole reported
// experience: a timeline is built only from feeds you FOLLOW, and nothing offered a follow button
// next to a name. These pin the parts that made it useless before.
test('★ discover lists everyone and offers a FOLLOW for each — the step that was missing', (t) => {
  const html = discover(
    [{ handle: 'tabertron0', feed: 'a'.repeat(64), verdict: 'unheld' },
     { handle: 'stortron0', feed: 'b'.repeat(64), verdict: 'confirmed' }],
    [], {}, { key: '2c'.repeat(32) })
  t.ok(html.includes('@tabertron0') && html.includes('@stortron0'), 'both are listed')
  t.is((html.match(/action="\/follow"/g) || []).length, 2, 'a follow button on each')
  // ⛔ `unheld` is the NORMAL state for anyone you have not followed. Hiding it would empty the very
  // page that exists to find them.
  t.ok(html.includes('unheld'), 'unheld is labelled, not hidden')
})

test('someone already followed gets no redundant button', (t) => {
  const feed = 'a'.repeat(64)
  const html = discover([{ handle: 'tabertron0', feed, verdict: 'confirmed' }], [{ feed }], {}, null)
  t.ok(html.includes('following'), 'it says so')
  t.absent(html.includes('action="/follow"'), 'and does not offer to follow again')
})

test('⛔ a MISMATCH is never offered — that claim points at someone else\'s feed', (t) => {
  const html = discover([{ handle: 'imposter0', feed: 'c'.repeat(64), verdict: 'MISMATCH' }], [], {}, null)
  t.absent(html.includes('imposter0'), 'a claim whose feed disagrees is not a person to follow')
})

// ⛔ "Nobody is here" and "you are in the WRONG namespace" look identical otherwise — and the second
// is the likely one, because an invite token and a namespace key are both 64 hex characters.
test('the empty state names the namespace, so a wrong one is diagnosable', (t) => {
  const html = discover([], [], {}, { key: 'ab'.repeat(32) })
  t.ok(html.includes('Nobody here yet'))
  t.ok(html.includes('abababab'), 'the key is shown so you can tell which namespace you are in')
})

// ── status ───────────────────────────────────────────────────────────────────────────────────────
//
// ⛔ The page exists because "the log arrived, the bytes did not" named a state without naming whose
// feed or what fixes it. These pin that the three feed states stay distinguishable — they look
// identical from a timeline and are fixed by completely different things.
test('★ status distinguishes followed-not-held from held-without-bytes', (t) => {
  const A = 'a'.repeat(64)
  const B = 'b'.repeat(64)
  const html = status({
    me: { feed: 'f'.repeat(64), linked: true },
    ns: { key: '2c'.repeat(32), claims: 4, carrying: false },
    following: [{ feed: A }, { feed: B }],
    missing: [{ feed: A }],
    profiles: { [B]: { name: 'stortron' } }
  })
  t.ok(html.includes('followed, not held'), 'a feed that has not replicated')
  t.ok(html.includes('stortron'), 'and one that has')
  t.ok(html.includes('4'), 'the namespace claim count, so a wrong namespace is visible')
})

test('⛔ status reports the daemon\'s own last OTA line, not a verdict about it', (t) => {
  const html = status({
    update: { version: '0.0.12', running: null, staged: 'abcd', checkedAt: 'now', note: 'ota: downloading 0.0.12' }
  })
  t.ok(html.includes('ota: downloading 0.0.12'), 'verbatim — it is the only line that says WHICH failure')
  // ⚠ `running` null is the normal fresh-install state, and must read as that rather than as blank.
  t.ok(html.includes("the app's own bundle"), 'a null running bundle is explained, not left empty')
})

test('an unlinked node says so, because nothing else it does will work', (t) => {
  t.ok(status({ me: { feed: 'a'.repeat(64), linked: false } }).includes('not linked'))
})

// ⛔ The banner must mirror what `chooseBundle` will ACTUALLY do. Requiring `running` hid real
// updates on fresh installs; assuming any staged bundle wins produced a notice that could not be
// dismissed — a restart button for a bundle that will never be chosen.
test('★ a staged bundle OLDER than the installed app is not an update', (t) => {
  const page = '<html lang="en"><body><h1>x</h1></body></html>'
  // The app was installed after the staged bundle was built — chooseBundle prefers the asset.
  t.absent(withUpdateBanner(page, { running: null, staged: 'bbbb', stagedBuiltAt: 100, builtAt: 200 }).includes('update-bar'),
    'the app is already newer, so there is nothing to restart for')
  t.ok(withUpdateBanner(page, { running: null, staged: 'bbbb', stagedBuiltAt: 300, builtAt: 200 }).includes('update-bar'),
    'a staged bundle built AFTER the app still counts')
  // ⚠ No ordering available on either side: announce it, matching chooseBundle's own fallback.
  t.ok(withUpdateBanner(page, { running: null, staged: 'bbbb' }).includes('update-bar'),
    'undated on both sides stays a candidate')
})

// ── the update banner must not disagree with the shell ────────────────────────────────────────────
//
// ⛔ OBSERVED ON A HANDSET: "an update is ready, restart to apply", shown for ever, while restarting
// changed nothing. This function had re-implemented a decision it does not own —
// `if (u.running) return u.staged !== u.running` — with no build-time comparison and no sight of
// failed.json, which lives on the shell side and which a page cannot read. So it answered YES on
// exactly the bundles chooseBundle refuses, and offered a button that could never work.
//
// The shell already runs chooseBundle at boot and now reports its verdict. This renders that.
import { updateWaiting, updateBlockedReason } from '../lib/pages.js'

test('a bundle the shell REFUSED is never announced as waiting', (t) => {
  const u = {
    staged: 'a'.repeat(64),
    running: 'b'.repeat(64),
    refused: { hash: 'a'.repeat(64), reason: 'the app was updated more recently than the staged bundle' }
  }
  t.absent(updateWaiting(u), 'no banner for something that cannot be applied')
  t.ok(updateBlockedReason(u).includes('more recently'), 'and the reason is available to show instead')
})

test('a refusal naming a DIFFERENT bundle does not suppress a real update', (t) => {
  // An old refusal must not hide a newly staged bundle that will apply.
  const u = {
    staged: 'c'.repeat(64),
    running: 'b'.repeat(64),
    refused: { hash: 'a'.repeat(64), reason: 'stale' }
  }
  t.ok(updateWaiting(u), 'the new one is still announced')
  t.is(updateBlockedReason(u), null)
})

test('with no verdict from the shell the old rules still apply', (t) => {
  // An older shell sends no `refused`. Behaviour must not change for it.
  t.ok(updateWaiting({ staged: 'a'.repeat(64), running: 'b'.repeat(64) }), 'differs from running')
  t.absent(updateWaiting({ staged: 'a'.repeat(64), running: 'a'.repeat(64) }), 'same as running')
  t.absent(updateWaiting({ staged: 'a'.repeat(64), stagedBuiltAt: 1000, builtAt: 9000 }), 'APK newer')
  t.ok(updateWaiting({ staged: 'a'.repeat(64), stagedBuiltAt: 9000, builtAt: 1000 }), 'staged newer')
})

test('nothing staged is never waiting and never blocked', (t) => {
  t.absent(updateWaiting({ staged: null, refused: { hash: 'a'.repeat(64), reason: 'x' } }))
  t.is(updateBlockedReason({ staged: null }), null)
})

test('a blocked update gets a line that does NOT offer a restart', (t) => {
  const u = {
    staged: 'a'.repeat(64), running: 'b'.repeat(64),
    refused: { hash: 'a'.repeat(64), reason: 'the app was updated more recently than the staged bundle' }
  }
  const html = withUpdateBanner('<body>page</body>', u)
  t.ok(html.includes('cannot be applied'), 'says so')
  t.absent(html.includes('restart to apply'), 'and never offers the button that does nothing')
})

test('a refusal for a bundle older than the RUNNING one is suppressed page-side too', (t) => {
  // ⛔ WHY BOTH SIDES CHECK. boot-plan.js is React Native — it ships in the APK. The page ships in the
  // BUNDLE and arrives over OTA. So a shell too old to suppress a stale refusal keeps sending one, and
  // on the handset that left "this update cannot be applied — staged bundle previously failed at boot"
  // pinned to every page while running a NEWER bundle. Waiting for an APK to fix a banner is exactly
  // the trap this whole effort exists to escape, so the page applies the rule as well.
  const u = {
    staged: 'a'.repeat(64), running: 'b'.repeat(64),
    stagedBuiltAt: 1000, builtAt: 9000,
    refused: { hash: 'a'.repeat(64), reason: 'staged bundle previously failed to boot' }
  }
  t.is(updateBlockedReason(u), null, 'older than what is running — nothing to say')
  t.absent(updateWaiting(u))
})

test('a refusal for a NEWER bundle is still shown', (t) => {
  const u = {
    staged: 'a'.repeat(64), running: 'b'.repeat(64),
    stagedBuiltAt: 9000, builtAt: 1000,
    refused: { hash: 'a'.repeat(64), reason: 'staged bundle previously failed to boot' }
  }
  t.ok(updateBlockedReason(u).includes('failed to boot'), 'a new release that cannot run is worth saying')
})

// ── the update section answers ONE question: why am I not on the latest? ──────────────────────────
//
// ⛔ IT USED TO ANSWER NOTHING. Two truncated tree hashes and a note. Every time an update failed to
// apply, the only way to learn why was a cable, a laptop, and somebody who knows the codebase — which
// is not a property a phone is allowed to have. These pin the three states a person can be in.
test('★★ a waiting update names the version it will apply, not a hash', (t) => {
  const h = updateSettings({
    ota: true, version: '0.0.30', running: 'aaaa', staged: 'bbbb', stagedVersion: '0.0.31'
  })
  t.ok(h.includes('Running 0.0.30'))
  t.ok(/0\.0\.31 is downloaded/.test(h), 'the version that is waiting, by name')
  t.ok(h.includes('app-restart'), 'and a way to apply it')
})

// ⛔ THE MOST IMPORTANT STATE. Something IS waiting and it CANNOT be applied — so offering a restart
// here is a button that does nothing, which is exactly the trap this section kept falling into.
test('⛔ a refused update says why, in chooseBundle\'s own words, and offers no restart', (t) => {
  const h = updateSettings({
    ota: true, version: '0.0.30', running: 'aaaa', staged: 'bbbb', stagedVersion: '0.0.31',
    refused: { hash: 'bbbb', reason: 'the staged bundle needs libbare-fs.4.9.0.so — in neither the APK nor the release' }
  })
  t.ok(/cannot be applied/.test(h))
  t.ok(h.includes('libbare-fs.4.9.0.so'), 'the actual obstacle, verbatim')
  // ⚠ The BUTTON, not the string: the inline script names both ids whether or not either is rendered.
  t.absent(/<button id="app-restart">/.test(h), 'no restart button — it would do nothing')
})

test('★ a staged bundle identical to the running one is not an update', (t) => {
  const h = updateSettings({ ota: true, version: '0.0.31', running: 'aaaa', staged: 'aaaa' })
  t.ok(h.includes('Up to date.'))
  t.absent(/<button id="app-restart">/.test(h), 'and nothing to press')
})

// ⚠ ALWAYS SHOWN. A page that prints its inputs only when it agrees with you cannot be used to find
// out that it is wrong.
test('⚠ the hashes are shown in every state, because equal versions can be different builds', (t) => {
  for (const u of [
    { ota: true, version: '0.0.30', running: 'aaaa', staged: 'bbbb', stagedVersion: '0.0.31' },
    { ota: true, version: '0.0.31', running: 'aaaa', staged: 'aaaa' },
    { ota: true, version: '0.0.30', running: 'aaaa', staged: 'bbbb', refused: { reason: 'nope' } }
  ]) {
    const h = updateSettings(u)
    t.ok(/running aaaa/.test(h), 'running hash shown')
    t.ok(/staged/.test(h), 'staged hash shown')
  }
})

test('⚠ timestamps read as ages, not ISO strings', (t) => {
  const h = updateSettings({ ota: true, version: '0.0.31', running: 'a', staged: 'a', checkedAt: new Date().toISOString() })
  t.ok(/just now|min ago/.test(h), 'a person can tell whether it is recent')
  t.absent(/T\d\d:\d\d:\d\d\.\d\d\dZ/.test(h), 'no raw ISO timestamp')
})

// ⛔ THE REASON UPDATES ARRIVE LATE, SAID OUT LOUD. A frozen app has no swarm, so the release watch is
// never told anything — and from the outside that is indistinguishable from a broken updater. It was
// read as one, repeatedly, for a whole day.
test('★★ a phone that freezes the node says so, and offers the fix', (t) => {
  const h = updateSettings({ ota: true, version: '0.0.31', running: 'a', staged: 'a', batteryExempt: false })
  t.ok(/freeze the node in the background/.test(h), 'it names the actual cause')
  t.ok(/only arrive while you have the app open/.test(h), 'and what that means for you')
  t.ok(/<button id="app-battery">/.test(h), 'with a way to fix it')
})

// ⚠ null is "nobody asked" — an older shell. Warning about a state nothing has measured is how a page
// teaches people to ignore its warnings.
test('⚠ a shell that cannot ask shows no warning at all', (t) => {
  for (const v of [undefined, null]) {
    const h = updateSettings({ ota: true, version: '0.0.31', running: 'a', staged: 'a', batteryExempt: v })
    t.absent(/freeze the node/.test(h), 'silent when unknown: ' + String(v))
  }
})

test('★ an exempt phone is not nagged', (t) => {
  const h = updateSettings({ ota: true, version: '0.0.31', running: 'a', staged: 'a', batteryExempt: true })
  t.absent(/freeze the node/.test(h))
  t.absent(/<button id="app-battery">/.test(h))
})

// ⛔ THE IDENTITY IS THE ROOT, AND THE NODE NEVER SHOWED IT. Settings and status named the FEED key (a
// locator: it changes with a feed migration) and said "linked", but never the identity root — the
// keet / hyperident public key every post is signed under and every `favela://` URI names. The daemon
// already sends it (`profiles[feed].root`, from the founder record); the pages dropped it.
test('settings shows this node\'s identity root, copyable in full', async (t) => {
  const root = 'ef'.repeat(32)
  const html = settings({ name: 'Alice', root }, null, null, null)
  t.ok(html.includes(root), 'the whole 64-character root is on the page')
  t.ok(/identity/i.test(html), 'labelled as the identity')
})

test('settings without a root says so instead of showing nothing', async (t) => {
  const html = settings({ name: 'Alice' }, null, null, null)
  t.ok(/no identity/i.test(html), 'an unlinked node is told it has no identity yet')
})

test('status shows the identity root next to "linked"', async (t) => {
  const feed = 'a'.repeat(64)
  const root = 'ef'.repeat(32)
  const html = status({ me: { feed, linked: true }, profiles: { [feed]: { root } } })
  t.ok(html.includes(root), 'the root is on the status page')
})

// ⛔ AN OUTSIDE ACTOR IS NOT A FEED (hyper-social #82, I1 S4). A notification from a bridge's observation feed names
// an ActivityPub actor URI. Rendering it through who() would link /u/<that URI> — a favela profile that does not
// exist. It is shown as the account it is, links OUT to it, and says it came from the fediverse.
test('★ a fediverse notification names the outside account and links out to it', (t) => {
  const html = notifications([
    { kind: 'reaction', value: 'like', source: 'fediverse', author: 'https://prsm.space/users/alice', at: '2026-10-14T19:21:17Z', target: { feed: 'ab'.repeat(32), collection: 'post', name: 'p1' }, name: 'o1', body: '' },
    { kind: 'reply', source: 'fediverse', author: 'https://prsm.space/users/alice', at: '2026-10-14T19:21:25Z', target: null, name: 'o2', body: 'nice post!' }
  ])
  t.ok(/alice@prsm\.space/.test(html), 'shown as user@host')
  t.ok(/href="https:\/\/prsm\.space\/users\/alice"/.test(html), 'linking out to the account')
  t.absent(/\/u\/https/.test(html), 'never as a favela profile path')
  t.ok(/fediverse/.test(html), 'and says where it came from')
  t.ok(/liked your post/.test(html) && /replied to your post/.test(html))
  t.ok(/nice post!/.test(html), 'with the reply text')
})

test('⛔ a fediverse author that is not an http(s) URL is never made a link', (t) => {
  const html = notifications([{ kind: 'follow', source: 'fediverse', author: 'javascript:alert(1)', at: '', target: null, name: 'x', body: '' }])
  t.absent(/href="javascript/i.test(html))
})

test('★ a bsky notification links to the actor\'s bsky profile and says bluesky', (t) => {
  const html = notifications([{ kind: 'reaction', value: 'like', source: 'bsky', author: 'did:plc:alicealicealice', at: '', target: null, name: 'b1', body: '' }])
  t.ok(/href="https:\/\/bsky\.app\/profile\/did:plc:alicealicealice"/.test(html), 'links to the bsky profile')
  t.ok(/bluesky/.test(html), 'and says where it came from')
  t.absent(/\/u\/did:/.test(html), 'never as a favela profile path')
})

// ⛔ Delete is offered on MY posts and replies only — the daemon marks a row `own`.
test('★ my own post offers delete; somebody else\'s does not', (t) => {
  const base = { author: 'ab'.repeat(32), collection: 'post', name: 'p1', body: 'hello', createdAt: '2026-10-14T00:00:00Z' }
  t.ok(post({ ...base, own: true }, {}).includes('href="/delete?target='), 'mine: a delete link (to the confirm page)')
  t.absent(post({ ...base, own: false }, {}).includes('/delete?'), 'theirs: none')
  t.absent(post(base, {}).includes('/delete?'), 'unknown ownership: none')
})