Seed a follow graph in the e2e harness

This commit is contained in:
Coracle-Bot 2026-09-10 03:28:02 +00:00
parent c401cd8bd8
commit 214ef0f50c
13 changed files with 557 additions and 139 deletions

View file

@ -22,6 +22,9 @@ this space?". Seeding a membership on `closed` is therefore not possible — `jo
publish a claimless join and the relay refuses it — so a scenario there seeds admin-created rooms
and lets the spec do the joining.
`indexer` and `outbox` are not spaces: groups are off, anyone may read and write, and nothing seeded
on them carries an `h` tag. They are what "The follow graph" below is made of.
### Why the relays are called `<name>.test`
The container listens on plaintext loopback, but a url that is local or insecure is dropped from
@ -244,8 +247,8 @@ have on disk anyway.
The price is that no spec exercises the bootstrap: every one of them starts already knowing which
relays it belongs to, so a regression in the chain from relay list to room list, or in `authPolicy`'s
conservative gate, leaves the suite green. Covering it needs a scenario with a public relay standing
in for an indexer.
conservative gate, leaves the suite green. The public relays under "The follow graph" are the
standing-in indexer that covering it would need.
Each user is a separate `BrowserContext`, which also gives each one its own IndexedDB and
localStorage, so nothing bleeds between users.
@ -269,6 +272,65 @@ hosted spaces are created under — or the app dials a host nothing serves and t
leak. The NIP-07 provider is a real signer rather than a stub, because the session it produces has to
sign the NIP-42 challenges the members-only relays send.
## The follow graph
Everything `/home`'s network column is made of sits outside a space: a follow list, each followed
pubkey's relay list, and an indexer to resolve those from. `open("indexer")` and `open("outbox")`
are the two public relays that hold it, and a scenario says which of them a given fixture lands on:
```ts
const scenario = await seed(({relay, open, user, at}) => {
const space = relay("space")
const indexer = open("indexer")
const outbox = open("outbox")
space.room("general", {name: "General"})
space.join(user.alice, "general")
indexer.relayList(user.alice, {
read: [space.url, indexer.url],
write: [space.url, indexer.url, outbox.url],
})
indexer.follows(user.alice, [user.bob])
indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url]})
outbox.profile(user.bob, {name: "Bob Barker"})
outbox.note(user.bob, "the lighthouse has been dark since tuesday", at(30, MINUTE))
})
```
Bob is in none of alice's spaces, so the only thing that can put his note on her screen is his relay
list. Keeping the indexer and the outbox apart is what makes that assertable: a client that ignored
the list would ask the indexer, find nothing, and fail the spec, where one relay serving both roles
would pass either way. `VITE_INDEXER_RELAYS` points at the indexer as soon as a scenario opens one,
and at the scenario's spaces otherwise, which is what every spec written before there was one still
gets.
An open relay's url reads before seeding has run, unlike a space's, because a relay list has to name
the relay a note is seeded on.
### Why a reader has to name the relays it reads from
zooid answers no REQ without NIP-42, whatever `public_read` says, and `authPolicy` is conservative:
it identifies only to relays the user's own room list or relay list names. So a relay a spec expects
the client to read from has to appear in that user's list, and a relay list is seeded for the reader
as well as for the people they follow.
Read and write are separate there, and the difference is what a routing assertion hangs on. Above,
alice writes to bob's relay and reads from her space and the indexer: the write url is enough for
her client to identify to it, while a feed's context, which is built from her read urls, has no
reason to ask it for anything. A reply that only lives on bob's relay therefore counts only if the
feed asked the relay the note itself came from.
The write urls have a second job. A user's own follow list is loaded through their outbox, so the
relay a scenario seeds that list on has to be one of the relays their list says they write to — the
indexer, here. Seed it somewhere they only read from and the feed comes up empty with nothing to say
why.
Her own relay list reaches her client as cache alongside her room list, for the same reason the room
list does: it is the list that says which relays may be identified to, so it cannot be the thing
that has to be fetched first.
## Layout
```
@ -297,8 +359,10 @@ e2e/
webln.ts a window.webln that enables and reports what it supports
seed/
scenario.ts the `seed()` builder and relative-time helpers
publish.ts the queue every seeding call goes through, and what it hands back
space.ts one space's fixtures: rooms, members, messages, replies, profiles,
direct messages, and anything a domain writer renders
openRelay.ts one public relay's fixtures: relay lists, follow lists, profiles, notes
specs/
*.spec.ts
```

View file

@ -1569,7 +1569,9 @@ that home is worth opening when nothing is waiting for me.
Acceptance:
- The Network section lists notes from her follows, resolved through the relays
those people publish to.
those people publish to. A follow who is in none of her spaces reads the same
way, replies included, since a note's replies are counted from the relay the
note itself came from.
- It is a list of notes: a reply is counted on the note it answers rather than
drawn underneath it, and never appears as an item of its own.
- Every note carries its reply count, including the ones with no replies.

View file

@ -15,6 +15,9 @@ export type BootOptions = {
// Every relay list the app reads at startup is pointed here, so it can only dial relays the
// scenario created.
relays: string[]
// What a pubkey's own lists are resolved from, which is a relay of its own only when the
// scenario opened one. Defaults to `relays`.
indexers?: string[]
spaces?: string[]
user?: TestUser
// What this user's client already has in local storage, e.g. their room list.
@ -27,7 +30,7 @@ export type BootOptions = {
export const boot = async (
context: BrowserContext,
{relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions,
{relays, indexers = relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions,
) => {
const urls = relays.join(",")
@ -48,7 +51,7 @@ export const boot = async (
TEST_ENV_READ_KEY,
{
VITE_DEFAULT_RELAYS: urls,
VITE_INDEXER_RELAYS: urls,
VITE_INDEXER_RELAYS: indexers.join(","),
VITE_DEFAULT_SEARCH_RELAYS: urls,
VITE_DEFAULT_MESSAGING_RELAYS: urls,
VITE_SIGNER_RELAYS: urls,

View file

@ -27,8 +27,10 @@ export {expect}
export {makeTestUser, users} from "./keys"
export type {TestUser} from "./keys"
export type {Scenario} from "./seed/scenario"
export type {SeededEvent} from "./seed/publish"
export type {SeededOpenRelay} from "./seed/openRelay"
export type {SeededRumor, SeededSpace} from "./seed/space"
export type {TenantName} from "./zooid/config"
export type {OpenRelayName, SpaceName, TenantName} from "./zooid/config"
export type {TranscriptEntry} from "./net/websocket"
export {forgetRelay, formatTranscript, getTranscript} from "./net/websocket"
export {readCachedEvents} from "./app/cache"
@ -152,7 +154,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
}
const open = async (path: string, options: PageOptions, user?: TestUser) => {
const {urls, cache} = requireScenario()
const {urls, indexerUrls, cache} = requireScenario()
// The project's own `use` first, so a viewport or device descriptor set in
// playwright.config.ts reaches the context rather than being dropped.
//
@ -209,6 +211,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
path,
env: options.env,
relays: urls,
indexers: indexerUrls,
spaces: urls,
events: user ? cache(user) : [],
})

View file

@ -0,0 +1,115 @@
import {Resolver} from "@welshman/util"
import {EventWriter, FollowList, Note, Profile, RelayList} from "@welshman/domain"
import type {BaseEventReader, ConfiguredKind, EventQuery, KindFactory} from "@welshman/domain"
import type {Zooid} from "../zooid/relay"
import {tenantUrl} from "../zooid/config"
import type {OpenRelayName} from "../zooid/config"
import type {TestUser} from "../keys"
import {makePublisher} from "./publish"
import type {Enqueue, ProfileValues, SeededEvent, SeededTemplate} from "./publish"
// A nip-65 relay list, as the two sets a client reads off it: `write` is what an outbox-routed load
// for this pubkey resolves to, `read` is what a feed asks for that pubkey's context.
export type RelayListUrls = {
read?: string[]
write?: string[]
}
/**
* One public relay's fixtures. Unlike a space it has no rooms, no members and nothing behind an
* `h` tag: it holds the things a client reaches for by pubkey rather than by space — a relay list,
* a follow list, a profile, a note.
*/
export type SeededOpenRelay = {
readonly name: OpenRelayName
// Known before seeding runs, since a relay list has to name the relay a note is seeded on.
readonly url: string
// Every relay list seeded here, which the scenario hands back to its own author as cache.
readonly relayLists: SeededEvent[]
note(user: TestUser, content: string, createdAt?: number): SeededEvent
profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent
// Where this user reads and writes. A relay a scenario expects the client to read from has to
// appear in the reader's own list: zooid answers no REQ without nip-42, and Flotilla only
// identifies to relays that list names.
relayList(user: TestUser, urls: RelayListUrls, createdAt?: number): SeededEvent
follows(user: TestUser, follows: TestUser[], createdAt?: number): SeededEvent
event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent
// This relay's domain kinds, bound to a resolver that answers with its url, the same way a
// space's `kind()` does.
kind<R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>(
factory: KindFactory<R, W, Q>,
): ConfiguredKind<R, W, Q>
}
export type SeedOpenRelayOptions = {
zooid: Zooid
enqueue: Enqueue
startedAt: number
name: OpenRelayName
}
export const seedOpenRelay = ({
zooid,
enqueue,
startedAt,
name,
}: SeedOpenRelayOptions): SeededOpenRelay => {
const url = tenantUrl(name)
const relay = zooid.relay(name)
const relayLists: SeededEvent[] = []
const context = {resolver: new Resolver(() => [url])}
const {event} = makePublisher({
name,
enqueue,
startedAt,
sign: (user, template) => relay.event(user, template),
})
const kind = <R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>(
factory: KindFactory<R, W, Q>,
) => factory.configure(context)
const note = (user: TestUser, content: string, createdAt = startedAt) =>
event(user, () => kind(Note).writer().setContent(content).renderTemplate(), createdAt)
const profile = (user: TestUser, values: ProfileValues, createdAt = startedAt) =>
event(
user,
() => Profile.configure(context).writer().update(values).renderTemplate(),
createdAt,
)
const relayList = (
user: TestUser,
{read = [], write = []}: RelayListUrls,
createdAt = startedAt,
) => {
const seeded = event(
user,
() => kind(RelayList).writer().setReadUrls(read).setWriteUrls(write).renderTemplate(),
createdAt,
)
relayLists.push(seeded)
return seeded
}
const follows = (user: TestUser, followed: TestUser[], createdAt = startedAt) =>
event(
user,
() => {
const writer = kind(FollowList).writer()
for (const {pubkey} of followed) {
writer.follow(pubkey)
}
return writer.renderTemplate()
},
createdAt,
)
return {name, url, relayLists, note, profile, relayList, follows, event, kind}
}

View file

@ -0,0 +1,83 @@
import type {Maybe, MaybeAsync} from "@welshman/lib"
import type {EventTemplate, SignedEvent, StampedEvent} from "@welshman/util"
import type {TestUser} from "../keys"
// A queued write, drained in declaration order by `seed` in scenario.ts.
export type Enqueue = (write: () => Promise<void>) => void
// A handle to an event the scenario is going to publish. Seeding calls record what to write and
// return before anything is written, so the event is filled in when its turn in the queue comes up.
export type SeededEvent = {
readonly event: SignedEvent
readonly id: string
}
// An event to seed, either already rendered or built when its turn comes up. A domain writer needs
// a relay url to resolve its hints against, and a relay has none until the queue has drained, so
// anything built by one has to be deferred.
export type SeededTemplate = StampedEvent | (() => MaybeAsync<EventTemplate>)
export type ProfileValues = {
name?: string
about?: string
picture?: string
nip05?: string
}
export type PublisherOptions = {
// The relay these events are seeded into, for the error a read-too-early raises.
name: string
enqueue: Enqueue
// The moment the scenario began. A fixture declared without a timestamp is stamped with it
// rather than with the wall clock, so two fixtures describing the same thing cannot land
// seconds apart and decide which of them wins.
startedAt: number
// How an event this process signs reaches the relay, deferred because a space's relay handle
// only exists once the queue has started draining.
sign: (user: TestUser, template: StampedEvent) => Promise<SignedEvent>
}
// The queue every seeding call goes through: what to write is recorded now and published when
// `seed()` drains, and what it produced only reads back after that.
export const makePublisher = ({name, enqueue, startedAt, sign}: PublisherOptions) => {
// Queues a write and hands back a getter for whatever it produced.
const seeded = <T>(write: () => Promise<T>) => {
let value: Maybe<T>
enqueue(async () => {
value = await write()
})
return () => {
if (value) return value
throw new Error(`An event seeded into "${name}" was read before seed() published it`)
}
}
const publish = (write: () => Promise<SignedEvent>): SeededEvent => {
const event = seeded(write)
return {
get event() {
return event()
},
get id() {
return event().id
},
}
}
const event = (user: TestUser, template: SeededTemplate, createdAt = startedAt) =>
publish(async () => {
if (typeof template === "function") {
return sign(user, {...(await template()), created_at: createdAt})
}
return sign(user, template)
})
return {seeded, publish, event}
}
export type Publisher = ReturnType<typeof makePublisher>

View file

@ -3,19 +3,25 @@ import type {MaybeAsync} from "@welshman/lib"
import {ROOMS, makeEvent} from "@welshman/util"
import type {SignedEvent} from "@welshman/util"
import type {Zooid} from "../zooid/relay"
import type {TenantName} from "../zooid/config"
import type {OpenRelayName, SpaceName} from "../zooid/config"
import {users} from "../keys"
import type {TestUser} from "../keys"
import {seedSpace} from "./space"
import type {SeededSpace} from "./space"
import {seedOpenRelay} from "./openRelay"
import type {SeededOpenRelay} from "./openRelay"
// A fixture timestamp, as an offset from the moment the scenario started. `at(2, HOUR)` is two
// hours before the test began, count-first like int and ago.
export type At = (count: number, unit: number) => number
export type SeedTools = {
// Names a relay the container already serves. Its policy is its toml in zooid/docker/config.
relay: (name: TenantName) => SeededSpace
// Names a space the container already serves. Its policy is its toml in zooid/docker/config.
relay: (name: SpaceName) => SeededSpace
// Names one of the public relays, which is where the follow graph lives: `indexer` is what a
// pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See
// ARCHITECTURE.md, "The follow graph".
open: (name: OpenRelayName) => SeededOpenRelay
user: typeof users
at: At
}
@ -23,11 +29,16 @@ export type SeedTools = {
export type Scenario = {
readonly startedAt: number
readonly at: At
// The spaces this scenario seeded, which are the relays the app is handed as its own.
readonly urls: string[]
space(name: TenantName): SeededSpace
// The events a returning user's client would already have on disk, which is just the room list.
// A members-only relay won't serve the list that would tell authPolicy it may identify to it. See
// ARCHITECTURE.md, "Users and sessions".
// What a pubkey's own lists are resolved from: the open indexer when a scenario declared one,
// and the spaces otherwise, which is what a scenario that knows nothing about open relays gets.
readonly indexerUrls: string[]
space(name: SpaceName): SeededSpace
open(name: OpenRelayName): SeededOpenRelay
// The events a returning user's client would already have on disk: their room list, and their
// relay list when the scenario seeded one. A relay won't serve the list that would tell
// authPolicy it may identify to it. See ARCHITECTURE.md, "Users and sessions".
cache(user: TestUser): SignedEvent[]
}
@ -38,23 +49,30 @@ export const seed = async (
const startedAt = now()
const at: At = (count, unit) => startedAt - int(count, unit)
const spaces = new Map<string, SeededSpace>()
const opened: SeededOpenRelay[] = []
const writes: (() => Promise<void>)[] = []
const roomLists = new Map<string, SignedEvent>()
const relayLists = new Map<string, SignedEvent>()
const relay = (name: TenantName) => {
const space = seedSpace({
zooid,
startedAt,
name,
enqueue: write => writes.push(write),
})
const enqueue = (write: () => Promise<void>) => writes.push(write)
const relay = (name: SpaceName) => {
const space = seedSpace({zooid, startedAt, name, enqueue})
spaces.set(name, space)
return space
}
await build({relay, user: users, at})
const open = (name: OpenRelayName) => {
const relay = seedOpenRelay({zooid, startedAt, name, enqueue})
opened.push(relay)
return relay
}
await build({relay, open, user: users, at})
// Seeding is async and fixtures depend on one another, so the builder only records what to
// write. Draining the queue here publishes each fixture in the order it was declared.
@ -88,7 +106,13 @@ export const seed = async (
roomLists.set(user.pubkey, event)
}
const getSpace = (name: TenantName) => {
for (const relay of opened) {
for (const {event} of relay.relayLists) {
relayLists.set(event.pubkey, event)
}
}
const getSpace = (name: SpaceName) => {
const space = spaces.get(name)
if (space) return space
@ -96,17 +120,35 @@ export const seed = async (
throw new Error(`No space named "${name}" was seeded`)
}
const cache = (user: TestUser) => {
const roomList = roomLists.get(user.pubkey)
const getOpenRelay = (name: OpenRelayName) => {
const relay = opened.find(candidate => candidate.name === name)
return roomList ? [roomList] : []
if (relay) return relay
throw new Error(`No open relay named "${name}" was seeded`)
}
const cache = (user: TestUser) => {
const events: SignedEvent[] = []
const roomList = roomLists.get(user.pubkey)
const relayList = relayLists.get(user.pubkey)
if (roomList) events.push(roomList)
if (relayList) events.push(relayList)
return events
}
const urls = seeded.map(space => space.url)
const indexers = opened.filter(relay => relay.name === "indexer").map(relay => relay.url)
return {
startedAt,
at,
cache,
urls: seeded.map(space => space.url),
urls,
indexerUrls: indexers.length > 0 ? indexers : urls,
space: getSpace,
open: getOpenRelay,
}
}

View file

@ -1,5 +1,5 @@
import {neventEncode} from "nostr-tools/nip19"
import type {Maybe, MaybeAsync} from "@welshman/lib"
import type {Maybe} from "@welshman/lib"
import {
MESSAGE,
ROOM_ADD_MEMBER,
@ -10,28 +10,24 @@ import {
tagValue,
toNostrURI,
} from "@welshman/util"
import type {EventTemplate, HashedEvent, SignedEvent, StampedEvent} from "@welshman/util"
import type {HashedEvent} from "@welshman/util"
import {Nip59} from "@welshman/signer"
import {DirectMessage, EventWriter, Profile} from "@welshman/domain"
import type {BaseEventReader, ConfiguredKind, EventQuery, KindFactory} from "@welshman/domain"
import type {RoomOptions, TestRelay} from "../zooid/types"
import type {Zooid} from "../zooid/relay"
import type {TenantName} from "../zooid/config"
import {tenantUrl} from "../zooid/config"
import type {SpaceName} from "../zooid/config"
import {users} from "../keys"
import type {TestUser} from "../keys"
import {makePublisher} from "./publish"
import type {Enqueue, ProfileValues, SeededEvent, SeededTemplate} from "./publish"
// @welshman/domain has no writer for NIP-29 kind-9 messages, and none of its readers describe one,
// so this pairs the base writer with the base reader. The behavior tags it renders are everything a
// room message carries: `h` via setRoom, `q` and `p` via addQuote and addMention.
class MessageWriter extends EventWriter<BaseEventReader> {}
// A handle to an event the scenario is going to publish. Seeding calls record what to write and
// return before anything is written, so the event is filled in when its turn in the queue comes up.
export type SeededEvent = {
readonly event: SignedEvent
readonly id: string
}
// The kind-14 a direct message really is. It is never published, since each participant gets it
// inside a gift wrap, so this is what a spec asserts on.
export type SeededRumor = {
@ -39,21 +35,6 @@ export type SeededRumor = {
readonly id: string
}
// An event to seed, either already rendered or built when its turn comes up. A domain writer needs
// a relay url to resolve its hints against, and this space has none until the queue has drained,
// so anything built by one has to be deferred.
export type SeededTemplate = StampedEvent | (() => MaybeAsync<EventTemplate>)
export type ProfileValues = {
name?: string
about?: string
picture?: string
nip05?: string
}
// A queued write, drained in declaration order by `seed` in scenario.ts.
export type Enqueue = (write: () => Promise<void>) => void
// A user's membership as their own client sees it, which the scenario turns into one room list
// per user once every space has been seeded.
export type SeededMembership = {
@ -62,8 +43,7 @@ export type SeededMembership = {
}
export type SeededSpace = {
readonly name: TenantName
// The url is read off the relay handle, so this only reads once seeding has run.
readonly name: SpaceName
readonly url: string
readonly memberships: SeededMembership[]
room(h: string, options?: RoomOptions): void
@ -92,11 +72,14 @@ export type SeedSpaceOptions = {
// The moment the scenario began. A fixture declared without a timestamp is stamped with it
// rather than with the wall clock.
startedAt: number
name: TenantName
name: SpaceName
}
export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => {
const memberships: SeededMembership[] = []
// Known before seeding runs, unlike the relay handle below, so a fixture on another relay can
// name this one.
const url = tenantUrl(name)
let testRelay: Maybe<TestRelay>
@ -110,55 +93,21 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
testRelay = await zooid.relay(name)
})
const {seeded, publish, event} = makePublisher({
name,
enqueue,
startedAt,
sign: (user, template) => relay().event(user, template),
})
// Every fixture is published to this space, so a relay hint always resolves to its url.
const context = {resolver: new Resolver(() => [relay().url])}
// Queues a write and hands back a getter for whatever it produced, which only reads once the
// scenario's queue has drained.
const seeded = <T>(write: () => Promise<T>) => {
let value: Maybe<T>
enqueue(async () => {
value = await write()
})
return () => {
if (value) return value
throw new Error(`An event seeded into "${name}" was read before seed() published it`)
}
}
const publish = (write: () => Promise<SignedEvent>): SeededEvent => {
const event = seeded(write)
return {
get event() {
return event()
},
get id() {
return event().id
},
}
}
const publishTemplate = (user: TestUser, build: () => MaybeAsync<StampedEvent>) =>
publish(async () => relay().event(user, await build()))
const context = {resolver: new Resolver(() => [url])}
const room = (h: string, roomOptions: RoomOptions = {}) =>
enqueue(() => relay().room(h, roomOptions, startedAt))
const member = (user: TestUser, h?: string) => enqueue(() => relay().member(user, h, startedAt))
const event = (user: TestUser, template: SeededTemplate, createdAt = startedAt) =>
publishTemplate(user, async () => {
if (typeof template === "function") {
return {...(await template()), created_at: createdAt}
}
return template
})
const join = (user: TestUser, ...roomIds: string[]) => {
memberships.push({user, rooms: roomIds})
member(user)
@ -184,30 +133,33 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
// content rather than from the q tag, so the uri is prepended as prependParent does in
// src/app/rooms.ts.
const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) =>
publishTemplate(user, async () => {
const h = tagValue(tagSpec("h"), parent.event.tags)
event(
user,
async () => {
const h = tagValue(tagSpec("h"), parent.event.tags)
if (h) {
const url = relay().url
const nevent = neventEncode({...parent.event, relays: [url]})
const template = await new MessageWriter(MESSAGE, context)
.setRoom(url, h)
.addQuote(parent.event)
.addMention(parent.event.pubkey)
.setContent(toNostrURI(nevent) + "\n\n" + content)
.renderTemplate()
if (h) {
const nevent = neventEncode({...parent.event, relays: [url]})
return {...template, created_at: createdAt}
}
return new MessageWriter(MESSAGE, context)
.setRoom(url, h)
.addQuote(parent.event)
.addMention(parent.event.pubkey)
.setContent(toNostrURI(nevent) + "\n\n" + content)
.renderTemplate()
}
throw new Error(`Cannot reply to ${parent.id}, it is not in a room`)
})
throw new Error(`Cannot reply to ${parent.id}, it is not in a room`)
},
createdAt,
)
const profile = (user: TestUser, values: ProfileValues, createdAt = startedAt) =>
publishTemplate(user, async () => ({
...(await Profile.configure(context).writer().update(values).renderTemplate()),
created_at: createdAt,
}))
event(
user,
() => Profile.configure(context).writer().update(values).renderTemplate(),
createdAt,
)
const kind = <R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>(
factory: KindFactory<R, W, Q>,
@ -247,9 +199,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
return {
name,
get url() {
return relay().url
},
url,
memberships,
room,
member,

View file

@ -1,8 +1,8 @@
/**
* The virtual relays the container serves, and the only place their names are written down.
*
* zooid binds a config to a Host header and serves any number of them from one process, so a second
* space costs a toml in docker/config and nothing else. The names are a union rather than a string
* zooid binds a config to a Host header and serves any number of them from one process, so another
* relay costs a toml in docker/config and nothing else. The names are a union rather than a string
* so that a scenario naming a relay that has no config fails to compile instead of hanging on a 404
* from the dispatcher.
*
@ -10,7 +10,10 @@
* process fails to connect rather than reaching a host. transport.ts covers why the container is
* told to call itself this rather than its loopback address.
*/
export const tenants = {
// The relays a space is seeded on: members-only, with nip-29 groups on, which is what a Flotilla
// space is.
export const spaceTenants = {
space: "space.test",
other: "other.test",
// Policy space.toml cannot express at the same time. `closed` refuses a join without an invite,
@ -19,6 +22,20 @@ export const tenants = {
unsigned: "unsigned.test",
} as const
// Public relays with no groups, which is where anything outside a space lives: `indexer` is what a
// pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See
// ARCHITECTURE.md, "The follow graph".
export const openTenants = {
indexer: "indexer.test",
outbox: "outbox.test",
} as const
export const tenants = {...spaceTenants, ...openTenants} as const
export type SpaceName = keyof typeof spaceTenants
export type OpenRelayName = keyof typeof openTenants
export type TenantName = keyof typeof tenants
export const tenantUrl = (name: TenantName) => `wss://${tenants[name]}/`

View file

@ -0,0 +1,43 @@
# A public relay rather than a space: no groups, and anyone may read and write. It stands in for the
# indexers a client resolves a pubkey's own lists from, so a relay list, a follow list or a profile
# seeded here is reachable by a user who is a member of nothing.
#
# zooid answers no REQ without nip-42 whatever `public_read` says, and Flotilla only identifies to
# relays the user's own lists name, so a scenario expecting the client to read from this one seeds
# it into that user's relay list. See ARCHITECTURE.md, "The follow graph".
#
# `host` must equal the Host header the harness sends, which is the hostname of the url the client
# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with
# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name
# that resolves nowhere.
host = "indexer.test"
schema = "e2e_indexer"
secret = "10de7e40000000000000000000000000000000000000000000000000000000e8"
[info]
# Served to the client as nip-11. `pubkey` is the owner, as it is on every other relay here, so a
# scenario that does reach for management gets the same identity back.
name = "indexer"
pubkey = "6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"
description = "Throwaway relay for Flotilla's end-to-end suite."
[policy]
public_read = true
public_write = true
public_join = false
strip_signatures = false
# Not a space: nothing seeded here carries an `h` tag, and the client is never offered it as one.
[groups]
enabled = false
[management]
enabled = false
# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have
# the relay or the client talk to a service the test did not create.
[blossom]
enabled = false
[push]
enabled = false

View file

@ -0,0 +1,41 @@
# The other public relay, identical in policy to indexer.toml. It is a followed pubkey's own write
# relay: somewhere to seed a note that the reader reaches only by resolving that pubkey's relay
# list, which is the whole of the cross-space half of the app.
#
# Keeping it apart from the indexer is what makes routing assertable. A feed that ignored a relay
# list would ask the indexer, find nothing, and fail — where one relay serving both roles would
# pass either way.
#
# `host` must equal the Host header the harness sends, which is the hostname of the url the client
# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with
# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name
# that resolves nowhere.
host = "outbox.test"
schema = "e2e_outbox"
secret = "07b0c0000000000000000000000000000000000000000000000000000000000a"
[info]
name = "outbox"
pubkey = "6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"
description = "Throwaway relay for Flotilla's end-to-end suite."
[policy]
public_read = true
public_write = true
public_join = false
strip_signatures = false
# Not a space: nothing seeded here carries an `h` tag, and the client is never offered it as one.
[groups]
enabled = false
[management]
enabled = false
# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have
# the relay or the client talk to a service the test did not create.
[blossom]
enabled = false
[push]
enabled = false

View file

@ -1,14 +1,11 @@
import {neventEncode, npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {RelayMessageType} from "@welshman/net"
import {Classified, FollowList, MessagingRelayList, Note, RelayList, Thread} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {expect, roomPath, spacePath, test, users} from "../harness"
import type {SeededSpace, TestUser} from "../harness"
// A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id is ever
// read back here.
type Seeded = {readonly id: string}
import {expect, getTranscript, roomPath, spacePath, test, users} from "../harness"
import type {SeededEvent, SeededSpace, TestUser} from "../harness"
// A literal as a pattern, for a url that carries a query string alongside the path being matched,
// or a host whose dots would otherwise be wildcards.
@ -472,6 +469,68 @@ test("US-117 read the network feed on home", async ({seed, as}) => {
await expect(network.getByRole("button", {name: /\d+\/\d+\/\d+/}).first()).toBeVisible()
})
test("US-117 read a follow who is in none of your spaces", async ({seed, as}) => {
const note = "the lighthouse has been dark since tuesday"
const answer = "the keeper is on holiday"
const scenario = await seed(({relay, open, user, at}) => {
const space = relay("space")
const indexer = open("indexer")
const outbox = open("outbox")
space.room("general", {name: "General"})
space.join(user.alice, "general")
// Alice reads from her space and the indexer. Bob's relay is somewhere she writes and nowhere
// she reads, which is enough for her client to identify to it -- zooid answers no REQ without
// nip-42, and Flotilla only identifies to relays her own lists name -- while leaving it out of
// the read urls a feed's context is asked of.
indexer.relayList(user.alice, {
read: [space.url, indexer.url],
write: [space.url, indexer.url, outbox.url],
})
indexer.follows(user.alice, [user.bob])
// Bob is in none of her spaces and writes nowhere she reads, so his relay list is the only
// thing that can point the feed at his notes.
indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url]})
outbox.profile(user.bob, {name: "Bob Barker"})
const posted = outbox.note(user.bob, note, at(30, MINUTE))
outbox.event(
user.bob,
() => outbox.kind(Note).writer().setParent(posted.event).setContent(answer).renderTemplate(),
at(20, MINUTE),
)
})
const space = scenario.space("space")
const page = await as(users.alice, "/home")
const network = page
.locator("section")
.filter({has: page.getByRole("heading", {name: "Network"})})
await expect(network.getByText(note)).toBeVisible()
await expect(network.getByText("Bob Barker")).toBeVisible()
// The reply is on bob's relay too, and alice reads from her space rather than from that relay,
// so a context asked of her own read relays comes back with nothing.
await expect(network.getByRole("button", {name: "1 reply", exact: true})).toBeVisible()
// Her space never held any of it.
const fromSpace = getTranscript(page.context()).filter(
({url, direction, message}) =>
url === space.url &&
direction === "toClient" &&
message[0] === RelayMessageType.Event &&
[note, answer].includes(message[2].content),
)
expect(fromSpace).toEqual([])
})
test("US-106 share text into the app", async ({seed, as}) => {
const shared = "the offsite is moving to the 14th"
@ -526,7 +585,7 @@ test("US-106 share text into the app", async ({seed, as}) => {
})
test("US-107 open a nostr link", async ({seed, as}) => {
let posted!: Seeded
let posted!: SeededEvent
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")

View file

@ -13,11 +13,7 @@ import {
test,
users,
} from "../harness"
import type {SeededSpace, TestUser} from "../harness"
// A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id and
// timestamp are ever read back here.
type Seeded = {readonly id: string; readonly event: {readonly created_at: number}}
import type {SeededEvent, SeededSpace, TestUser} from "../harness"
// A path as a pattern, for a url that carries a query string or a modal's hash alongside it.
const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
@ -192,7 +188,7 @@ test("US-118 messages sent in the same second are in one order for everyone", as
seed,
as,
}) => {
const tied: Seeded[] = []
const tied: SeededEvent[] = []
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
@ -687,7 +683,7 @@ test("US-025 react to a message", async ({seed, as}) => {
})
test("US-026 pin a message and browse pins", async ({seed, as}) => {
let pinned!: Seeded
let pinned!: SeededEvent
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
@ -733,8 +729,8 @@ test("US-026 pin a message and browse pins", async ({seed, as}) => {
})
test("US-027 find a past message and jump to it", async ({seed, as}) => {
let lastWeek!: Seeded
let older!: Seeded
let lastWeek!: SeededEvent
let older!: SeededEvent
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
@ -792,7 +788,7 @@ test("US-027 find a past message and jump to it", async ({seed, as}) => {
// event in the room" is the wrong question to hang the button on; whether the loaded window has
// caught up to the present is the right one.
test("US-027a a permalink near the newest end lands at the bottom", async ({seed, as}) => {
let recent!: Seeded
let recent!: SeededEvent
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")