Seed a follow graph in the e2e harness
This commit is contained in:
parent
c401cd8bd8
commit
214ef0f50c
13 changed files with 557 additions and 139 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) : [],
|
||||
})
|
||||
|
|
|
|||
115
e2e/harness/seed/openRelay.ts
Normal file
115
e2e/harness/seed/openRelay.ts
Normal 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}
|
||||
}
|
||||
83
e2e/harness/seed/publish.ts
Normal file
83
e2e/harness/seed/publish.ts
Normal 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>
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]}/`
|
||||
|
|
|
|||
43
e2e/harness/zooid/docker/config/indexer.toml
Normal file
43
e2e/harness/zooid/docker/config/indexer.toml
Normal 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
|
||||
41
e2e/harness/zooid/docker/config/outbox.toml
Normal file
41
e2e/harness/zooid/docker/config/outbox.toml
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue