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
|
publish a claimless join and the relay refuses it — so a scenario there seeds admin-created rooms
|
||||||
and lets the spec do the joining.
|
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`
|
### Why the relays are called `<name>.test`
|
||||||
|
|
||||||
The container listens on plaintext loopback, but a url that is local or insecure is dropped from
|
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
|
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
|
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
|
conservative gate, leaves the suite green. The public relays under "The follow graph" are the
|
||||||
in for an indexer.
|
standing-in indexer that covering it would need.
|
||||||
|
|
||||||
Each user is a separate `BrowserContext`, which also gives each one its own IndexedDB and
|
Each user is a separate `BrowserContext`, which also gives each one its own IndexedDB and
|
||||||
localStorage, so nothing bleeds between users.
|
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
|
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.
|
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
|
## Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -297,8 +359,10 @@ e2e/
|
||||||
webln.ts a window.webln that enables and reports what it supports
|
webln.ts a window.webln that enables and reports what it supports
|
||||||
seed/
|
seed/
|
||||||
scenario.ts the `seed()` builder and relative-time helpers
|
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,
|
space.ts one space's fixtures: rooms, members, messages, replies, profiles,
|
||||||
direct messages, and anything a domain writer renders
|
direct messages, and anything a domain writer renders
|
||||||
|
openRelay.ts one public relay's fixtures: relay lists, follow lists, profiles, notes
|
||||||
specs/
|
specs/
|
||||||
*.spec.ts
|
*.spec.ts
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1569,7 +1569,9 @@ that home is worth opening when nothing is waiting for me.
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
- The Network section lists notes from her follows, resolved through the relays
|
- 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
|
- 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.
|
drawn underneath it, and never appears as an item of its own.
|
||||||
- Every note carries its reply count, including the ones with no replies.
|
- 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
|
// Every relay list the app reads at startup is pointed here, so it can only dial relays the
|
||||||
// scenario created.
|
// scenario created.
|
||||||
relays: string[]
|
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[]
|
spaces?: string[]
|
||||||
user?: TestUser
|
user?: TestUser
|
||||||
// What this user's client already has in local storage, e.g. their room list.
|
// 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 (
|
export const boot = async (
|
||||||
context: BrowserContext,
|
context: BrowserContext,
|
||||||
{relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions,
|
{relays, indexers = relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions,
|
||||||
) => {
|
) => {
|
||||||
const urls = relays.join(",")
|
const urls = relays.join(",")
|
||||||
|
|
||||||
|
|
@ -48,7 +51,7 @@ export const boot = async (
|
||||||
TEST_ENV_READ_KEY,
|
TEST_ENV_READ_KEY,
|
||||||
{
|
{
|
||||||
VITE_DEFAULT_RELAYS: urls,
|
VITE_DEFAULT_RELAYS: urls,
|
||||||
VITE_INDEXER_RELAYS: urls,
|
VITE_INDEXER_RELAYS: indexers.join(","),
|
||||||
VITE_DEFAULT_SEARCH_RELAYS: urls,
|
VITE_DEFAULT_SEARCH_RELAYS: urls,
|
||||||
VITE_DEFAULT_MESSAGING_RELAYS: urls,
|
VITE_DEFAULT_MESSAGING_RELAYS: urls,
|
||||||
VITE_SIGNER_RELAYS: urls,
|
VITE_SIGNER_RELAYS: urls,
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,10 @@ export {expect}
|
||||||
export {makeTestUser, users} from "./keys"
|
export {makeTestUser, users} from "./keys"
|
||||||
export type {TestUser} from "./keys"
|
export type {TestUser} from "./keys"
|
||||||
export type {Scenario} from "./seed/scenario"
|
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 {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 type {TranscriptEntry} from "./net/websocket"
|
||||||
export {forgetRelay, formatTranscript, getTranscript} from "./net/websocket"
|
export {forgetRelay, formatTranscript, getTranscript} from "./net/websocket"
|
||||||
export {readCachedEvents} from "./app/cache"
|
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 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
|
// The project's own `use` first, so a viewport or device descriptor set in
|
||||||
// playwright.config.ts reaches the context rather than being dropped.
|
// playwright.config.ts reaches the context rather than being dropped.
|
||||||
//
|
//
|
||||||
|
|
@ -209,6 +211,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
||||||
path,
|
path,
|
||||||
env: options.env,
|
env: options.env,
|
||||||
relays: urls,
|
relays: urls,
|
||||||
|
indexers: indexerUrls,
|
||||||
spaces: urls,
|
spaces: urls,
|
||||||
events: user ? cache(user) : [],
|
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 {ROOMS, makeEvent} from "@welshman/util"
|
||||||
import type {SignedEvent} from "@welshman/util"
|
import type {SignedEvent} from "@welshman/util"
|
||||||
import type {Zooid} from "../zooid/relay"
|
import type {Zooid} from "../zooid/relay"
|
||||||
import type {TenantName} from "../zooid/config"
|
import type {OpenRelayName, SpaceName} from "../zooid/config"
|
||||||
import {users} from "../keys"
|
import {users} from "../keys"
|
||||||
import type {TestUser} from "../keys"
|
import type {TestUser} from "../keys"
|
||||||
import {seedSpace} from "./space"
|
import {seedSpace} from "./space"
|
||||||
import type {SeededSpace} 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
|
// 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.
|
// hours before the test began, count-first like int and ago.
|
||||||
export type At = (count: number, unit: number) => number
|
export type At = (count: number, unit: number) => number
|
||||||
|
|
||||||
export type SeedTools = {
|
export type SeedTools = {
|
||||||
// Names a relay the container already serves. Its policy is its toml in zooid/docker/config.
|
// Names a space the container already serves. Its policy is its toml in zooid/docker/config.
|
||||||
relay: (name: TenantName) => SeededSpace
|
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
|
user: typeof users
|
||||||
at: At
|
at: At
|
||||||
}
|
}
|
||||||
|
|
@ -23,11 +29,16 @@ export type SeedTools = {
|
||||||
export type Scenario = {
|
export type Scenario = {
|
||||||
readonly startedAt: number
|
readonly startedAt: number
|
||||||
readonly at: At
|
readonly at: At
|
||||||
|
// The spaces this scenario seeded, which are the relays the app is handed as its own.
|
||||||
readonly urls: string[]
|
readonly urls: string[]
|
||||||
space(name: TenantName): SeededSpace
|
// What a pubkey's own lists are resolved from: the open indexer when a scenario declared one,
|
||||||
// The events a returning user's client would already have on disk, which is just the room list.
|
// and the spaces otherwise, which is what a scenario that knows nothing about open relays gets.
|
||||||
// A members-only relay won't serve the list that would tell authPolicy it may identify to it. See
|
readonly indexerUrls: string[]
|
||||||
// ARCHITECTURE.md, "Users and sessions".
|
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[]
|
cache(user: TestUser): SignedEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,23 +49,30 @@ export const seed = async (
|
||||||
const startedAt = now()
|
const startedAt = now()
|
||||||
const at: At = (count, unit) => startedAt - int(count, unit)
|
const at: At = (count, unit) => startedAt - int(count, unit)
|
||||||
const spaces = new Map<string, SeededSpace>()
|
const spaces = new Map<string, SeededSpace>()
|
||||||
|
const opened: SeededOpenRelay[] = []
|
||||||
const writes: (() => Promise<void>)[] = []
|
const writes: (() => Promise<void>)[] = []
|
||||||
const roomLists = new Map<string, SignedEvent>()
|
const roomLists = new Map<string, SignedEvent>()
|
||||||
|
const relayLists = new Map<string, SignedEvent>()
|
||||||
|
|
||||||
const relay = (name: TenantName) => {
|
const enqueue = (write: () => Promise<void>) => writes.push(write)
|
||||||
const space = seedSpace({
|
|
||||||
zooid,
|
const relay = (name: SpaceName) => {
|
||||||
startedAt,
|
const space = seedSpace({zooid, startedAt, name, enqueue})
|
||||||
name,
|
|
||||||
enqueue: write => writes.push(write),
|
|
||||||
})
|
|
||||||
|
|
||||||
spaces.set(name, space)
|
spaces.set(name, space)
|
||||||
|
|
||||||
return 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
|
// 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.
|
// 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)
|
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)
|
const space = spaces.get(name)
|
||||||
|
|
||||||
if (space) return space
|
if (space) return space
|
||||||
|
|
@ -96,17 +120,35 @@ export const seed = async (
|
||||||
throw new Error(`No space named "${name}" was seeded`)
|
throw new Error(`No space named "${name}" was seeded`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = (user: TestUser) => {
|
const getOpenRelay = (name: OpenRelayName) => {
|
||||||
const roomList = roomLists.get(user.pubkey)
|
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 {
|
return {
|
||||||
startedAt,
|
startedAt,
|
||||||
at,
|
at,
|
||||||
cache,
|
cache,
|
||||||
urls: seeded.map(space => space.url),
|
urls,
|
||||||
|
indexerUrls: indexers.length > 0 ? indexers : urls,
|
||||||
space: getSpace,
|
space: getSpace,
|
||||||
|
open: getOpenRelay,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import {neventEncode} from "nostr-tools/nip19"
|
import {neventEncode} from "nostr-tools/nip19"
|
||||||
import type {Maybe, MaybeAsync} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import {
|
import {
|
||||||
MESSAGE,
|
MESSAGE,
|
||||||
ROOM_ADD_MEMBER,
|
ROOM_ADD_MEMBER,
|
||||||
|
|
@ -10,28 +10,24 @@ import {
|
||||||
tagValue,
|
tagValue,
|
||||||
toNostrURI,
|
toNostrURI,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
import type {EventTemplate, HashedEvent, SignedEvent, StampedEvent} from "@welshman/util"
|
import type {HashedEvent} from "@welshman/util"
|
||||||
import {Nip59} from "@welshman/signer"
|
import {Nip59} from "@welshman/signer"
|
||||||
import {DirectMessage, EventWriter, Profile} from "@welshman/domain"
|
import {DirectMessage, EventWriter, Profile} from "@welshman/domain"
|
||||||
import type {BaseEventReader, ConfiguredKind, EventQuery, KindFactory} from "@welshman/domain"
|
import type {BaseEventReader, ConfiguredKind, EventQuery, KindFactory} from "@welshman/domain"
|
||||||
import type {RoomOptions, TestRelay} from "../zooid/types"
|
import type {RoomOptions, TestRelay} from "../zooid/types"
|
||||||
import type {Zooid} from "../zooid/relay"
|
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 {users} from "../keys"
|
||||||
import type {TestUser} 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,
|
// @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
|
// 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.
|
// room message carries: `h` via setRoom, `q` and `p` via addQuote and addMention.
|
||||||
class MessageWriter extends EventWriter<BaseEventReader> {}
|
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
|
// 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.
|
// inside a gift wrap, so this is what a spec asserts on.
|
||||||
export type SeededRumor = {
|
export type SeededRumor = {
|
||||||
|
|
@ -39,21 +35,6 @@ export type SeededRumor = {
|
||||||
readonly id: string
|
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
|
// 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.
|
// per user once every space has been seeded.
|
||||||
export type SeededMembership = {
|
export type SeededMembership = {
|
||||||
|
|
@ -62,8 +43,7 @@ export type SeededMembership = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SeededSpace = {
|
export type SeededSpace = {
|
||||||
readonly name: TenantName
|
readonly name: SpaceName
|
||||||
// The url is read off the relay handle, so this only reads once seeding has run.
|
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly memberships: SeededMembership[]
|
readonly memberships: SeededMembership[]
|
||||||
room(h: string, options?: RoomOptions): void
|
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
|
// The moment the scenario began. A fixture declared without a timestamp is stamped with it
|
||||||
// rather than with the wall clock.
|
// rather than with the wall clock.
|
||||||
startedAt: number
|
startedAt: number
|
||||||
name: TenantName
|
name: SpaceName
|
||||||
}
|
}
|
||||||
|
|
||||||
export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => {
|
export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => {
|
||||||
const memberships: SeededMembership[] = []
|
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>
|
let testRelay: Maybe<TestRelay>
|
||||||
|
|
||||||
|
|
@ -110,55 +93,21 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
|
||||||
testRelay = await zooid.relay(name)
|
testRelay = await zooid.relay(name)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Every fixture is published to this space, so a relay hint always resolves to its url.
|
const {seeded, publish, event} = makePublisher({
|
||||||
const context = {resolver: new Resolver(() => [relay().url])}
|
name,
|
||||||
|
enqueue,
|
||||||
// Queues a write and hands back a getter for whatever it produced, which only reads once the
|
startedAt,
|
||||||
// scenario's queue has drained.
|
sign: (user, template) => relay().event(user, template),
|
||||||
const seeded = <T>(write: () => Promise<T>) => {
|
|
||||||
let value: Maybe<T>
|
|
||||||
|
|
||||||
enqueue(async () => {
|
|
||||||
value = await write()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
// Every fixture is published to this space, so a relay hint always resolves to its url.
|
||||||
if (value) return value
|
const context = {resolver: new Resolver(() => [url])}
|
||||||
|
|
||||||
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 room = (h: string, roomOptions: RoomOptions = {}) =>
|
const room = (h: string, roomOptions: RoomOptions = {}) =>
|
||||||
enqueue(() => relay().room(h, roomOptions, startedAt))
|
enqueue(() => relay().room(h, roomOptions, startedAt))
|
||||||
|
|
||||||
const member = (user: TestUser, h?: string) => enqueue(() => relay().member(user, h, 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[]) => {
|
const join = (user: TestUser, ...roomIds: string[]) => {
|
||||||
memberships.push({user, rooms: roomIds})
|
memberships.push({user, rooms: roomIds})
|
||||||
member(user)
|
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
|
// content rather than from the q tag, so the uri is prepended as prependParent does in
|
||||||
// src/app/rooms.ts.
|
// src/app/rooms.ts.
|
||||||
const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) =>
|
const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) =>
|
||||||
publishTemplate(user, async () => {
|
event(
|
||||||
|
user,
|
||||||
|
async () => {
|
||||||
const h = tagValue(tagSpec("h"), parent.event.tags)
|
const h = tagValue(tagSpec("h"), parent.event.tags)
|
||||||
|
|
||||||
if (h) {
|
if (h) {
|
||||||
const url = relay().url
|
|
||||||
const nevent = neventEncode({...parent.event, relays: [url]})
|
const nevent = neventEncode({...parent.event, relays: [url]})
|
||||||
const template = await new MessageWriter(MESSAGE, context)
|
|
||||||
|
return new MessageWriter(MESSAGE, context)
|
||||||
.setRoom(url, h)
|
.setRoom(url, h)
|
||||||
.addQuote(parent.event)
|
.addQuote(parent.event)
|
||||||
.addMention(parent.event.pubkey)
|
.addMention(parent.event.pubkey)
|
||||||
.setContent(toNostrURI(nevent) + "\n\n" + content)
|
.setContent(toNostrURI(nevent) + "\n\n" + content)
|
||||||
.renderTemplate()
|
.renderTemplate()
|
||||||
|
|
||||||
return {...template, created_at: createdAt}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) =>
|
const profile = (user: TestUser, values: ProfileValues, createdAt = startedAt) =>
|
||||||
publishTemplate(user, async () => ({
|
event(
|
||||||
...(await Profile.configure(context).writer().update(values).renderTemplate()),
|
user,
|
||||||
created_at: createdAt,
|
() => Profile.configure(context).writer().update(values).renderTemplate(),
|
||||||
}))
|
createdAt,
|
||||||
|
)
|
||||||
|
|
||||||
const kind = <R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>(
|
const kind = <R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>(
|
||||||
factory: KindFactory<R, W, Q>,
|
factory: KindFactory<R, W, Q>,
|
||||||
|
|
@ -247,9 +199,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
get url() {
|
url,
|
||||||
return relay().url
|
|
||||||
},
|
|
||||||
memberships,
|
memberships,
|
||||||
room,
|
room,
|
||||||
member,
|
member,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* The virtual relays the container serves, and the only place their names are written down.
|
* 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
|
* zooid binds a config to a Host header and serves any number of them from one process, so another
|
||||||
* space costs a toml in docker/config and nothing else. The names are a union rather than a string
|
* 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
|
* so that a scenario naming a relay that has no config fails to compile instead of hanging on a 404
|
||||||
* from the dispatcher.
|
* from the dispatcher.
|
||||||
*
|
*
|
||||||
|
|
@ -10,7 +10,10 @@
|
||||||
* process fails to connect rather than reaching a host. transport.ts covers why the container is
|
* 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.
|
* 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",
|
space: "space.test",
|
||||||
other: "other.test",
|
other: "other.test",
|
||||||
// Policy space.toml cannot express at the same time. `closed` refuses a join without an invite,
|
// 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",
|
unsigned: "unsigned.test",
|
||||||
} as const
|
} 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 type TenantName = keyof typeof tenants
|
||||||
|
|
||||||
export const tenantUrl = (name: TenantName) => `wss://${tenants[name]}/`
|
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 {neventEncode, npubEncode} from "nostr-tools/nip19"
|
||||||
import {HOUR, MINUTE} from "@welshman/lib"
|
import {HOUR, MINUTE} from "@welshman/lib"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
|
import {RelayMessageType} from "@welshman/net"
|
||||||
import {Classified, FollowList, MessagingRelayList, Note, RelayList, Thread} from "@welshman/domain"
|
import {Classified, FollowList, MessagingRelayList, Note, RelayList, Thread} from "@welshman/domain"
|
||||||
import type {Locator, Page} from "@playwright/test"
|
import type {Locator, Page} from "@playwright/test"
|
||||||
import {expect, roomPath, spacePath, test, users} from "../harness"
|
import {expect, getTranscript, roomPath, spacePath, test, users} from "../harness"
|
||||||
import type {SeededSpace, TestUser} from "../harness"
|
import type {SeededEvent, 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}
|
|
||||||
|
|
||||||
// A literal as a pattern, for a url that carries a query string alongside the path being matched,
|
// 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.
|
// 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()
|
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}) => {
|
test("US-106 share text into the app", async ({seed, as}) => {
|
||||||
const shared = "the offsite is moving to the 14th"
|
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}) => {
|
test("US-107 open a nostr link", async ({seed, as}) => {
|
||||||
let posted!: Seeded
|
let posted!: SeededEvent
|
||||||
|
|
||||||
const scenario = await seed(({relay, user, at}) => {
|
const scenario = await seed(({relay, user, at}) => {
|
||||||
const space = relay("space")
|
const space = relay("space")
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,7 @@ import {
|
||||||
test,
|
test,
|
||||||
users,
|
users,
|
||||||
} from "../harness"
|
} from "../harness"
|
||||||
import type {SeededSpace, TestUser} from "../harness"
|
import type {SeededEvent, 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}}
|
|
||||||
|
|
||||||
// A path as a pattern, for a url that carries a query string or a modal's hash alongside it.
|
// 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, "\\$&"))
|
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,
|
seed,
|
||||||
as,
|
as,
|
||||||
}) => {
|
}) => {
|
||||||
const tied: Seeded[] = []
|
const tied: SeededEvent[] = []
|
||||||
|
|
||||||
const scenario = await seed(({relay, user, at}) => {
|
const scenario = await seed(({relay, user, at}) => {
|
||||||
const space = relay("space")
|
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}) => {
|
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 scenario = await seed(({relay, user, at}) => {
|
||||||
const space = relay("space")
|
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}) => {
|
test("US-027 find a past message and jump to it", async ({seed, as}) => {
|
||||||
let lastWeek!: Seeded
|
let lastWeek!: SeededEvent
|
||||||
let older!: Seeded
|
let older!: SeededEvent
|
||||||
|
|
||||||
const scenario = await seed(({relay, user, at}) => {
|
const scenario = await seed(({relay, user, at}) => {
|
||||||
const space = relay("space")
|
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
|
// 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.
|
// 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}) => {
|
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 scenario = await seed(({relay, user, at}) => {
|
||||||
const space = relay("space")
|
const space = relay("space")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue