Add tests for all user stories

This commit is contained in:
Jon Staab 2026-08-20 13:33:20 -07:00
parent c53aaf8df8
commit aa2483fe78
28 changed files with 9744 additions and 97 deletions

View file

@ -17,7 +17,12 @@ what keeps outbox routing and cross-space isolation testable.
A relay's policy is its toml and nothing else. A scenario says what is _on_ a relay — its rooms, its
members, its messages — never what the relay _is_, so there is no policy negotiation anywhere in the
harness.
harness. `space` and `other` are the same permissive policy twice over, for the specs that need two
of them; `closed` refuses a join that carries no invite claim, which is what raises "Request
Access"; `unsigned` serves events with their signatures stripped, which is what raises "Do you trust
this space?". Seeding a membership on `closed` is therefore not possible — `join()` and `member()`
publish a claimless join and the relay refuses it — so a scenario there seeds admin-created rooms
and lets the spec do the joining.
### Why the relays are called `<name>.test`
@ -63,11 +68,11 @@ Every socket the browser opens is terminated here, in this process, and the only
is the loopback connection `zooid/transport.ts` makes to the container the test started.
`assertNoLeaks()` fails a test that touched a url the scenario never declared.
Interception is installed by `as()`, on a context it creates, so a page that came from anywhere else
has none of it. Playwright's own `context` fixture — and the `page` fixture built on it — is
therefore overridden to throw, so a spec written the ordinary way fails immediately with a message
saying so rather than quietly dialling the relays in `.env`. The built-in `request` fixture goes the
same way: an `APIRequestContext` is an http client in the node process that belongs to no browser
Interception is installed by `as()` and `visit()`, on a context each of them creates, so a page that
came from anywhere else has none of it. Playwright's own `context` fixture — and the `page` fixture
built on it — is therefore overridden to throw, so a spec written the ordinary way fails immediately
with a message saying so rather than quietly dialling the relays in `.env`. The built-in `request`
fixture goes the same way: an `APIRequestContext` is an http client in the node process that belongs to no browser
context, so the block-all below cannot see it and nothing records what it sent.
`playwright` is the one fixture left alone, and it has to be: it is where the run's own browser comes
@ -112,6 +117,15 @@ analytics script hard-coded in `src/app.html` is mocked with an empty body: it i
navigation whatever the scenario is doing, so leaving it to the block-all would make
`assertNoBlockedRequests()` a statement about the page shell rather than about the test.
Two of those answers are the scenario's own. NIP-11 fields a spec names are merged over the relay's
real document — a `redirect_to`, a `limitation`, a NIP the relay does not implement — rather than
replacing it, because `self`, `pubkey` and `supported_nips` are what room state is trusted from, and
the merge is installed with the page, since the document is read at startup and cached from then on.
The hosting API is a small stateful fake rather than fixed answers: a write mutates the record it
names and the next read sees it, so editing a space, deactivating it or paying an invoice is
observable. `getHosting(context)` changes the backend's mind between two of the user's clicks — a
custom domain that verifies, an invoice that gets paid.
## Containment
A browser has a fixed set of ways to put bytes on a wire.
@ -199,6 +213,8 @@ would have refused fails the test instead of appearing in a query.
Test identities are deterministic secp256k1 keypairs derived from fixed secrets (`alice`, `bob`,
`carol`, `admin`), so a pubkey is stable across runs and can be asserted on directly.
`makeTestUser(name)` derives a fifth, sixth or hundredth one from its name and registers it, for the
directory and search specs that need more people than there are named ones.
A logged-in user is created by injecting a NIP-01 session before the app boots. `src/app/session.ts`
carries a DEV-only hook: `restoreSession()` prefers `window.__TEST_SESSION__` when it is present, and
@ -232,6 +248,24 @@ in for an indexer.
Each user is a separate `BrowserContext`, which also gives each one its own IndexedDB and
localStorage, so nothing bleeds between users.
An injected session is re-applied on every navigation, so through `as()` a reload, a logout and a
login are all unobservable. `visit(path, options)` is the same page without one — same context, same
interception, same env — and it is what a spec that watches someone arrive, sign in and come back
starts from. Both take the same options, over and above the scenario's own relays:
| option | what it does |
| ----------- | ------------------------------------------------------------------------------------ |
| `context` | merged over the project's context options: a viewport, a colour scheme, a permission |
| `env` | `VITE_` values applied over the ones derived from the scenario's relays |
| `nip07` | a `window.nostr` backed by that identity's own signer, for an extension login |
| `relayInfo` | fields merged over a relay's own NIP-11 document, keyed by relay url |
| `hosting` | what the hosting backend already knows about this user |
Whatever `env` names still has to be something the scenario owns — a platform relay, the domain
hosted spaces are created under — or the app dials a host nothing serves and the test fails on a
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.
## Layout
```
@ -256,9 +290,11 @@ e2e/
boot.ts env overrides, navigate, wait for mount and for the app to have read
the overrides
session.ts NIP-01 session injection
nip07.ts a window.nostr backed by a test identity's own signer
seed/
scenario.ts the `seed()` builder and relative-time helpers
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
specs/
*.spec.ts
```
@ -272,13 +308,23 @@ importing anything under `src` would pull sveltekit into the node process.
`Zooid.reset()` recreates the container rather than restarting it: with no volume mounted for
`/app/data`, storage lives in the container's writable layer and a tmpfs, so a fresh container is a
fresh database. The configs are bind-mounted read-only and survive, so every test starts against the
same relays with nothing in them. The container is a worker fixture, so the docker start-up cost is
paid once per worker rather than once per test.
fresh database. What it mounts as its config is a copy of `docker/config`, staged outside the repo
and refreshed on the way up — zooid saves a relay's toml back when a NIP-86 call edits its name — so
every test starts against the same relays with nothing in them. The container is a worker fixture,
so the docker start-up cost is paid once per worker rather than once per test.
Seeding then writes over a real authenticated socket, one per identity per relay, held open for the
rest of the test. A fixture must therefore be signed by one of the identities in `harness/keys.ts`,
since zooid authenticates every write.
rest of the test. A fixture must therefore be signed by an identity `harness/keys.ts` holds, since
zooid authenticates every write.
Anything richer than a room message is built by the domain writers the app itself publishes with:
`space.kind(Article)` hands back the kind configured against this space, and `space.event(user, () =>
…renderTemplate())` defers the render until the space has a url to render hints against, which is
only true once the queue has drained. A NIP-17 conversation is `space.dm(from, to, content)`: it
gift-wraps one rumor per participant and publishes each wrap over the sender's connection, since a
wrap is signed by an ephemeral key nobody here can authenticate as. zooid stores it anyway, because
it authorizes a kind-1059 by the member named in its `p` tag — and refuses one addressed to a
stranger.
## Running

108
e2e/SUSPECTED_BUGS.md Normal file
View file

@ -0,0 +1,108 @@
# Suspected app bugs
Places where the app source contradicts a story in `USER_STORIES.md`. The spec for each asserts
the story, so it fails until the bug is fixed. None of this has been confirmed by running the
suite.
## Rooms & membership
- **US-019 / US-020 — joined and created rooms never land under "Your Rooms".** `Rooms.joinRoom`
publishes only kind-9021 and `RoomForm.submit` publishes create/edit/join — none of them write
the user's kind-10009 room list, which is what `deriveUserRooms` reads. Only the Favorite toggle
and the delete path touch the list.
- **US-019 — leaving a room's member list is silent on success.** `RoomDetailMenu.handleLoading`
only toasts when `waitForError()` returns a message.
- **US-021 — private-room card says "Join Room" where the story (and the restricted-room card)
say "Ask to Join"** (`RoomChat.svelte` isPrivate vs isRestricted branches).
- **US-028 — no Share action exists on any message.** `shareEvent` is wired only into EventMenu
("Share to Chat"), BoardMenu and PinMenu; the cross-space Share picker is reachable only from
native share intents or `/share`.
## Spaces & access
- **US-011 — the "Request Access" state is unreachable on a `public_join = false` relay.**
welshman's `RelayJoinWriter.setClaim("")` always writes a `claim` tag, so zooid answers
"failed to validate invite code" instead of "no claim tag"; `attemptRelayAccess` swallows that
error when no claim was supplied, so `SpaceJoin` shows "Join Space" and toasts "Welcome to the
space!" for a space that refused the join.
- **US-009 — "Browse Spaces" section renders as "More Spaces"** whenever the user has joined
spaces, so the story's two named sections never appear together.
## DMs & composer
- **US-035 — up-arrow in an empty DM composer edits the oldest editable message, not the newest.**
`Chat.svelte` does `messages.toReversed().find(canEditEvent)` but `chat.messages` is already
newest-first; RoomChat uses the same expression over an ascending list, so the DM copy inverted
the ordering.
- **US-059 — a cancelled edit clobbers the room draft.** RoomChat re-keys RoomCompose on
`initialValues` while passing the same `draftKey` during editing, so the draft store is
overwritten with the edited message's text. Chat.svelte avoids this by passing
`draftKey={eventToEdit ? undefined : draftKey}`.
- **US-056 — mentions render with no avatar** (composer nodeview is a bare text span;
`ContentMention` renders `@name` with no image).
- **US-056 — `~room` references render as plain text once sent.** `parseRoom` pre-empts
`parseLink` and `Content.svelte` has no `isRoom` branch, so the intended `#RoomName` link path
in `ContentLinkUrl` is unreachable.
- **US-057 — an unsupported file type is rejected silently.** `Uploader.addFile` returns false
before any upload on a mime mismatch, so `onUploadError` (the only toast in the path) never runs.
## Delivery & deletion
- **US-072 — the "Deleted" pill never replaces reactions/menus.** In Article/Comment/Thread/Poll/
Goal/CalendarEvent Actions, `ThunkStatusOrDeleted` is a sibling of `ReactionSummary`/
`EventActions` instead of wrapping them; `ClassifiedActions` is the only one wired as intended.
- **US-072 — a deleted event vanishes from lists and threads instead of staying with the pill.**
`Repository` emits the target in `removed` and `makeFeed`/`deriveEventsById` prune on it; only
`deriveEvent` (`includeDeleted: true`) retains it, so the pill is only reachable on detail pages.
- **US-071 — the publish-status popover's timeout reason reads "timed out." not "request timed
out".** `publishOne` always sets `detail: "timed out"`, making `ThunkStatusDetail`'s fallback
branch unreachable.
## People & profiles
- **US-079 — profile notes show an absolute date+clock** (`NoteCard` uses `formatTimestamp`)
where the story asks for a relative timestamp (`formatTimestampRelative` exists and is used for
"Last active").
- **US-079 — a note published live never appears in another user's open profile view without a
reload.** `syncSpace`'s CONTENT_KINDS contains no NOTE kind and `makeFeed`'s forward scroller
stops on its first tick.
- **US-008 — deleting your profile can delete the blanked kind-0 it just published.** The kind-5
requests carry an `a` tag (`0:<pubkey>`) newer than the blanked profile; zooid has no NIP-62
handling, and in a fresh session no kind-5 may be sent at all.
## Settings
- **US-091 — the zero/negative zap-amount error toast is unreachable.** The inputs carry
`min="1"`, so native constraint validation blocks submit before `onZapAmountsSubmit` runs.
- **US-084 — blocked relays still appear in relay suggestions.** `RelayAdd` filters on
matchRelay/already-selected/isIPAddress only; `BlockedRelayLists` is consumed solely by
`policies.ts` to refuse sockets.
## Content rendering
- **US-067 — a bare `lnbc1…` invoice renders as plain prose.** `parseInvoice` only matches behind
a `lightning:` scheme and `parseLink` skips it too, so the copyable chip appears only for the
prefixed form.
- **US-046 — the calendar "Today" divider keys off `created_at` instead of the event's start tag**
(`isFuture = todayDateDisplay === newDateDisplay || event.created_at > now()`), so it lands on
the wrong item for past-published events. (Not covered by the suite; the scroll effect uses the
correct `getStart`.)
## Hosting & admin
- **US-102 — payment history is not sorted client-side.** The hosting settings page renders
`listTenantInvoices` order verbatim, so "most recent first" depends on the backend.
- **US-041 — the article list card lacks the "Posted in #room" badge** the story names for both
surfaces; `ArticleItem` never passes `showRoom` and renders the room inline in the byline
instead.
- **US-092 — possible over-broad admin gate (inferred, unverified).** `deriveUserIsSpaceAdmin` is
`supportedMethods().length > 0`, and zooid's migration writes
`member_methods = ["listclaims", "createclaim"]` — if applied, every member reports as an admin.
## Known coverage gaps (not bugs)
- **US-070 bullet 3** (article/comment retry succeeds and clears the indicator) is uncoverable:
no UI path makes an article publish fail then succeed in one session without discarding
`$thunks.history`.
- **US-065's "briefly shows a loading state"** is inherently racy (sync may already hold the
quoted event); the assertion stands as the story requires but may flake.

View file

@ -20,11 +20,14 @@ export type BootOptions = {
// What this user's client already has in local storage, e.g. their room list.
events?: TrustedEvent[]
path?: string
// VITE_ values the scenario sets for itself, applied over the relay-derived ones below. Anything
// named here still has to be something the test owns, or the app will reach for it.
env?: Record<string, string>
}
export const boot = async (
context: BrowserContext,
{relays, spaces = [], user, events = [], path = "/"}: BootOptions,
{relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions,
) => {
const urls = relays.join(",")
@ -55,6 +58,7 @@ export const boot = async (
// Nothing serves this url, so a push bridge connection is reported as a leak instead of
// blending into the traffic of a relay the scenario did create.
VITE_PUSH_BRIDGE: "ws://localhost:1/",
...env,
},
] as const,
)

61
e2e/harness/app/nip07.ts Normal file
View file

@ -0,0 +1,61 @@
import type {BrowserContext} from "@playwright/test"
import type {StampedEvent} from "@welshman/util"
import type {TestUser} from "../keys"
// The function playwright installs on window for the shim below to call into. A binding is the
// only way across: the keys live in node, and a signer built in the page would be a different
// thing from the one seeding signs with.
const TEST_NIP07_KEY = "__TEST_NIP07__"
type Nip07Call =
| {method: "getPublicKey"}
| {method: "signEvent"; template: StampedEvent}
| {method: "encrypt" | "decrypt"; scheme: "nip04" | "nip44"; pubkey: string; message: string}
/**
* A NIP-07 provider backed by a test identity's real signer, so an extension login produces
* signatures the relays accept — including the NIP-42 auth events a members-only relay demands.
*
* Both halves have to be installed before the page navigates: LogIn.svelte reads `window.nostr`
* while it renders to decide whether to offer the button at all.
*/
export const injectNip07 = async (context: BrowserContext, user: TestUser) => {
await context.exposeBinding(TEST_NIP07_KEY, (source, call: Nip07Call) => {
if (call.method === "getPublicKey") {
return user.pubkey
}
if (call.method === "signEvent") {
return user.signer.sign(call.template)
}
const {encrypt, decrypt} = call.scheme === "nip04" ? user.signer.nip04 : user.signer.nip44
return call.method === "encrypt"
? encrypt(call.pubkey, call.message)
: decrypt(call.pubkey, call.message)
})
await context.addInitScript(key => {
const call = (request: Nip07Call) => Reflect.get(window, key)(request)
Object.assign(window, {
nostr: {
getPublicKey: () => call({method: "getPublicKey"}),
signEvent: (template: StampedEvent) => call({method: "signEvent", template}),
nip04: {
encrypt: (pubkey: string, message: string) =>
call({method: "encrypt", scheme: "nip04", pubkey, message}),
decrypt: (pubkey: string, message: string) =>
call({method: "decrypt", scheme: "nip04", pubkey, message}),
},
nip44: {
encrypt: (pubkey: string, message: string) =>
call({method: "encrypt", scheme: "nip44", pubkey, message}),
decrypt: (pubkey: string, message: string) =>
call({method: "decrypt", scheme: "nip44", pubkey, message}),
},
},
})
}, TEST_NIP07_KEY)
}

View file

@ -10,22 +10,32 @@ import {
mockHosting,
mockImages,
mockPushServer,
mockRelayInfo,
} from "./net/http"
import type {HostingFixtures, RelayInfoOverrides} from "./net/http"
import {assertNoLeaks, installWebSocketRoutes} from "./net/websocket"
import {boot} from "./app/boot"
import {injectNip07} from "./app/nip07"
import {seed} from "./seed/scenario"
import type {Scenario, SeedTools} from "./seed/scenario"
import type {TestUser} from "./keys"
export {expect}
export {users} from "./keys"
export {makeTestUser, users} from "./keys"
export type {TestUser} from "./keys"
export type {Scenario} from "./seed/scenario"
export type {SeededSpace} from "./seed/space"
export type {SeededRumor, SeededSpace} from "./seed/space"
export type {TenantName} from "./zooid/config"
export type {TranscriptEntry} from "./net/websocket"
export {formatTranscript, getTranscript} from "./net/websocket"
export {assertNoBlockedRequests, mockBlossom, mockLivekit} from "./net/http"
export {
assertNoBlockedRequests,
getHosting,
mockBlossom,
mockDufflepud,
mockLivekit,
} from "./net/http"
export type {DufflepudFixtures, HostingFixtures, HostingHandle, HostingRecord} from "./net/http"
// Mirrors encodeRelay in src/app/relays.ts, which can't be imported here — it reaches the app's
// module graph, and with it sveltekit.
@ -40,19 +50,40 @@ export const spacePath = (url: string) => `/spaces/${encodeRelay(url)}`
export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}`
// What a page is opened with, over and above the scenario's own relays.
export type PageOptions = {
// Overrides the project's context options, for a spec that needs a viewport, a colour scheme or
// a permission of its own.
context?: BrowserContextOptions
// VITE_ values applied over the ones derived from the scenario's relays, e.g. a platform space
// or the domain hosted relays are created under. Whatever is named here has to be something the
// scenario owns, or the app will reach for it and the test will fail on a leak.
env?: Record<string, string>
// A NIP-07 provider signing as this user, for a login that goes through an extension.
nip07?: TestUser
// Fields merged over a relay's own nip-11 document, keyed by relay url.
relayInfo?: RelayInfoOverrides
// What the hosting backend already knows about this user. Read `getHosting(page.context())` for
// the handle that changes it mid-test.
hosting?: HostingFixtures
}
export type Harness = {
zooid: Zooid
seed(build: (tools: SeedTools) => MaybeAsync<void>): Promise<Scenario>
// A logged-in page for a user: its own browser context, its own storage, its own sockets into
// the relays every other user is talking to. `options` overrides the project's context options,
// for a spec that needs a viewport or colour scheme of its own.
as(user: TestUser, path?: string, options?: BrowserContextOptions): Promise<Page>
// the relays every other user is talking to.
as(user: TestUser, path?: string, options?: PageOptions): Promise<Page>
// The same page with no session injected — the app as someone who has never logged in sees it,
// and the only way to watch a login, a reload or a logout happen.
visit(path?: string, options?: PageOptions): Promise<Page>
}
export type HarnessFixtures = {
harness: Harness
seed: Harness["seed"]
as: Harness["as"]
visit: Harness["visit"]
}
export type HarnessWorkerFixtures = {
@ -63,11 +94,13 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
// Playwright's own context — and the `page` fixture built on it — is unrouted: no websocket
// interception, no http block-all, no injected env, and nothing collecting leaks from it. A page
// born there boots the app against the relays baked into .env, which is the one thing this suite
// exists to prevent, so it is refused outright and `as()` is the only way to get a page.
// exists to prevent, so it is refused outright and `as()`/`visit()` are the only ways to get a
// page.
context: async () => {
throw new Error(
"The built-in `context` and `page` fixtures reach the real network. Open a page with the " +
"harness's `as(user, path)` fixture, which installs interception before it navigates.",
"harness's `as(user, path)` or `visit(path)` fixture, which install interception before " +
"they navigate.",
)
},
// Playwright builds this one with `playwright.request.newContext()`, so it is an http client in
@ -111,7 +144,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
throw new Error("Seed a scenario before opening a page for a user")
}
const as = async (user: TestUser, path = "/", options: BrowserContextOptions = {}) => {
const open = async (path: string, options: PageOptions, user?: TestUser) => {
const {urls, cache} = requireScenario()
// The project's own `use` first, so a viewport, colour scheme or device descriptor set in
// playwright.config.ts reaches the context rather than being silently dropped.
@ -123,7 +156,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
const context = await browser.newContext({
...testInfo.project.use,
serviceWorkers: "block",
...options,
...options.context,
})
contexts.push(context)
@ -133,18 +166,31 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
// recognize, so the mocks have to be registered last to be reachable at all.
await installHttpRoutes(context)
await installWebSocketRoutes(context, zooid)
await mockRelayInfo(context, options.relayInfo ?? {})
await mockAnalytics(context)
await mockDufflepud(context)
await mockHosting(context)
await mockHosting(context, options.hosting)
await mockPushServer(context)
await mockImages(context)
return boot(context, {user, path, relays: urls, spaces: urls, events: cache(user)})
if (options.nip07) {
await injectNip07(context, options.nip07)
}
return boot(context, {
user,
path,
env: options.env,
relays: urls,
spaces: urls,
events: user ? cache(user) : [],
})
}
await use({
zooid,
as,
as: (user, path = "/", options = {}) => open(path, options, user),
visit: (path = "/", options = {}) => open(path, options),
seed: async build => {
scenario = await seed(zooid, build)
@ -162,4 +208,5 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
},
seed: async ({harness}, use) => use(harness.seed),
as: async ({harness}, use) => use(harness.as),
visit: async ({harness}, use) => use(harness.visit),
})

View file

@ -1,3 +1,4 @@
import {createHash} from "node:crypto"
import {getPubkey} from "@welshman/util"
import {Nip01Signer} from "@welshman/signer"
@ -8,12 +9,17 @@ export type TestUser = {
signer: Nip01Signer
}
const makeUser = (name: string, secret: string): TestUser => ({
name,
secret,
pubkey: getPubkey(secret),
signer: new Nip01Signer(secret),
})
// Every identity this process can sign as. zooid authenticates each write and refuses an event
// whose author is not the authenticated pubkey, so seeding looks a fixture's author up here.
export const testUsersByPubkey = new Map<string, TestUser>()
const makeUser = (name: string, secret: string): TestUser => {
const user = {name, secret, pubkey: getPubkey(secret), signer: new Nip01Signer(secret)}
testUsersByPubkey.set(user.pubkey, user)
return user
}
// The secrets are near-zero entropy on purpose: they never leave the test process, they are
// stable across runs so a pubkey can be asserted on directly, and the leading nibbles make an
@ -24,3 +30,19 @@ export const users = {
carol: makeUser("carol", "ca20100000000000000000000000000000000000000000000000000000000003"),
admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"),
}
// secp256k1's group order. A secret is a scalar in [1, n), and a hash lands outside that range
// only astronomically rarely, but reducing rather than rejecting keeps the derivation total.
const CURVE_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")
/**
* A fifth, sixth, hundredth identity, named rather than listed. The secret is derived from the
* name, so the pubkey is as stable across runs as the four above, and minting one registers it —
* a scenario can seed a profile, a message or a follow for it like any other test user.
*/
export const makeTestUser = (name: string) => {
const digest = BigInt("0x" + createHash("sha256").update(name).digest("hex"))
const secret = ((digest % (CURVE_ORDER - 1n)) + 1n).toString(16).padStart(64, "0")
return makeUser(name, secret)
}

View file

@ -1,6 +1,6 @@
import {createHash} from "node:crypto"
import type {BrowserContext} from "@playwright/test"
import {now} from "@welshman/lib"
import {HOUR, int, now, omit} from "@welshman/lib"
import type {Handle} from "@welshman/util"
import type {ZapperValues} from "@welshman/domain"
import {tenantByUrl} from "../zooid/config"
@ -16,6 +16,10 @@ const HOSTING_ORIGIN = "https://api.hosting.coracle.social"
// Hard-coded in src/app.html, so every navigation asks for it whatever the scenario is doing.
const PLAUSIBLE_ORIGIN = "https://plausible.coracle.social"
// Where the hosting api sends a browser to pay. Nothing serves it — `.test` resolves nowhere and
// the block-all aborts the navigation — so a spec sees the redirect without one leaving.
const CHECKOUT_ORIGIN = "https://checkout.test"
// Relay-hosted livekit lives under a well-known path rather than an origin of its own.
const LIVEKIT_PATH = "/.well-known/nip29/livekit"
@ -31,6 +35,13 @@ const PNG = Buffer.from(
export const isDevServerUrl = (url: URL) =>
url.port === "1847" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
// A relay is reached over wss and its own http origin is where nip-11 and nip-86 live.
const relayOrigin = (url: string) => new URL(url.replace(/^ws/, "http")).origin
const hostByOrigin = new Map(
Array.from(tenantByUrl, ([url, host]): [string, string] => [relayOrigin(url), host]),
)
export type BlockedRequest = {
method: string
url: string
@ -57,12 +68,6 @@ export const getBlockedRequests = (context: BrowserContext) => {
*/
export const installHttpRoutes = (context: BrowserContext) => {
const blocked: BlockedRequest[] = []
const hostByOrigin = new Map(
Array.from(tenantByUrl, ([url, host]): [string, string] => [
new URL(url.replace(/^ws/, "http")).origin,
host,
]),
)
blockedByContext.set(context, blocked)
@ -114,6 +119,56 @@ export const assertNoBlockedRequests = (context: BrowserContext) => {
}
}
// Fields to merge over a relay's own nip-11 document, keyed by its url.
export type RelayInfoOverrides = Record<string, object>
/**
* A relay's real document with a few fields replaced: a `redirect_to`, a `limitation`, a NIP the
* relay does not implement. Merged rather than fabricated, because `self`, `pubkey`, `name` and
* `supported_nips` are what room state is trusted from — a hand-written document breaks every
* room on the page.
*
* Install it before the page navigates: the document is read at startup and cached from then on.
*/
export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverrides) => {
const overrideByOrigin = new Map(
Object.entries(overrides).map(([url, override]): [string, object] => [
relayOrigin(url),
override,
]),
)
return context.route(
url => url.pathname === "/" && overrideByOrigin.has(url.origin),
async route => {
const request = route.request()
const {origin} = new URL(request.url())
const host = hostByOrigin.get(origin)
const override = overrideByOrigin.get(origin)
// The nip-86 management api posts to this same path, and what it answers decides what the
// admin ui offers, so only the document request is touched.
if (request.method() === "GET" && host && override) {
const {status, headers, body} = await requestZooid(host, "GET", "/", {
...request.headers(),
// The merge has to read the document, and khatru will compress it if invited to.
"accept-encoding": "identity",
})
return route.fulfill({
status,
// khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's own
// headers are kept — all but the length of a body that is about to change.
headers: omit(["content-length"], headers),
body: JSON.stringify({...JSON.parse(body.toString()), ...override}),
})
}
return route.fallback()
},
)
}
/**
* The analytics script src/app.html loads on every page. Answering with an empty body leaves
* `window.plausible` as the queueing shim src/app/analytics.ts installs, so pageviews accumulate
@ -135,6 +190,12 @@ export type DufflepudFixtures = {
zappers?: {lnurl: string; info?: Omit<ZapperValues, "lnurl">}[]
}
/**
* Dufflepud, whose origin is the one service url the app hard-codes rather than reading from a
* `VITE_` value. `as()` installs it with no fixtures, so a spec that needs one — a zapper for a zap
* receipt, a link preview — calls this again with its own: playwright matches the most recently
* registered route first, so the spec's answers win over the empty defaults.
*/
export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtures = {}) =>
context.route(`${DUFFLEPUD_ORIGIN}/**`, route => {
const {pathname} = new URL(route.request().url())
@ -232,48 +293,211 @@ export const mockPushServer = (context: BrowserContext) =>
return route.fallback()
})
// Records straight off the hosting api, whose shapes live in src/app/hosting.ts. They're typed as
// plain objects here so that mocking a payment flow doesn't pull the app's module graph — and
// with it import.meta.env — into the node process.
// One record straight off the hosting api, whose shapes live in src/app/hosting.ts. They're left
// as loose objects here so that mocking a payment flow doesn't pull the app's module graph — and
// with it import.meta.env — into the node process. A relay record may also carry `members` and
// `activity`, and an invoice `items` and `bolt11`, which is where those endpoints answer from.
export type HostingRecord = Record<string, unknown>
// The backend's state when the page opens. Everything after that is what the app wrote, plus
// whatever the handle below changed.
export type HostingFixtures = {
plans?: object[]
plans?: HostingRecord[]
// Provisioning runs on every login and ignores what it gets back, so the empty default carries
// any spec that isn't actually exercising the hosting ui.
tenant?: object
relays?: object[]
invoices?: object[]
draftInvoice?: object
tenant?: HostingRecord
relays?: HostingRecord[]
invoices?: HostingRecord[]
draftInvoice?: HostingRecord
}
export const mockHosting = (context: BrowserContext, fixtures: HostingFixtures = {}) =>
context.route(`${HOSTING_ORIGIN}/**`, route => {
const [resource, id, sub, detail] = new URL(route.request().url()).pathname
.split("/")
.filter(Boolean)
// The backend changing its mind between two of the user's clicks — a custom domain that verifies,
// an invoice that gets paid — which is the half of those flows no click can reach.
export type HostingHandle = {
setTenant(patch: HostingRecord): void
setRelay(id: string, patch: HostingRecord): void
setInvoice(id: string, patch: HostingRecord): void
}
const hostingByContext = new WeakMap<BrowserContext, HostingHandle>()
export const getHosting = (context: BrowserContext) => {
const handle = hostingByContext.get(context)
if (handle) {
return handle
}
throw new Error("mockHosting was never called for this browser context")
}
/**
* The hosting api as a small stateful fake: a write mutates the record it names and the next read
* sees it, which is what makes editing a space's details, deactivating it, changing its plan or
* saving a custom domain observable at all. Each browser context gets its own store, so one user's
* spaces are not another's.
*/
export const mockHosting = async (context: BrowserContext, fixtures: HostingFixtures = {}) => {
const plans = fixtures.plans ?? []
const relays = new Map((fixtures.relays ?? []).map(relay => [String(relay.id), relay]))
const invoices = new Map((fixtures.invoices ?? []).map(invoice => [String(invoice.id), invoice]))
let tenant: HostingRecord = {
pubkey: "",
return_url: "",
nwc_is_set: false,
stripe_customer_id: "",
created_at: now(),
...fixtures.tenant,
}
const handle: HostingHandle = {
setTenant: patch => {
tenant = {...tenant, ...patch}
},
setRelay: (id, patch) => {
relays.set(id, {...relays.get(id), ...patch})
},
setInvoice: (id, patch) => {
invoices.set(id, {...invoices.get(id), ...patch})
},
}
hostingByContext.set(context, handle)
await context.route(`${HOSTING_ORIGIN}/**`, route => {
const request = route.request()
const method = request.method()
const body: HostingRecord = request.postDataJSON() ?? {}
const [resource, id, sub, detail] = new URL(request.url()).pathname.split("/").filter(Boolean)
const missing = (what: string) => route.fulfill({status: 404, json: {error: `No such ${what}`}})
if (resource === "plans") {
return route.fulfill({json: {data: fixtures.plans ?? []}})
return route.fulfill({json: {data: plans}})
}
if (resource === "tenants") {
if (sub) {
if (sub === "relays") {
return route.fulfill({json: {data: fixtures.relays ?? []}})
}
if (sub === "invoices") {
const data = detail === "draft" ? fixtures.draftInvoice : (fixtures.invoices ?? [])
return route.fulfill({json: {data}})
}
// Provisioning is the one tenant route with no pubkey in the path.
if (id) {
tenant = {...tenant, pubkey: id}
} else {
return route.fulfill({json: {data: {pubkey: id, ...fixtures.tenant}}})
tenant = {...tenant, ...body}
return route.fulfill({json: {data: tenant}})
}
if (sub === "relays") {
return route.fulfill({json: {data: Array.from(relays.values())}})
}
if (sub === "invoices") {
const data = detail === "draft" ? fixtures.draftInvoice : Array.from(invoices.values())
return route.fulfill({json: {data}})
}
if (sub === "stripe") {
return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/portal/${id}`}}})
}
// A wallet is the only thing the app updates a tenant with, so saving one is what sets it.
if (method === "PUT") {
tenant = {...tenant, ...body, nwc_is_set: Boolean(body.nwc_url)}
}
// GET and reconcile both answer with the tenant as it now stands.
return route.fulfill({json: {data: tenant}})
}
if (resource === "relays") {
if (!id) {
const relay = {
id: `relay-${relays.size + 1}`,
status: "active",
sync_error: "",
synced: now(),
custom_domain: "",
custom_domain_verified: 0,
...body,
// A created space is opened straight away, so its url has to be one the container
// serves. The client names the domain from VITE_HOSTING_RELAY_DOMAIN.
zooid_domain: body.zooid_domain || "test",
}
relays.set(String(relay.id), relay)
return route.fulfill({json: {data: relay}})
}
const relay = relays.get(id)
if (!relay) {
return missing(`relay ${id}`)
}
if (sub === "members") {
return route.fulfill({json: {data: {members: relay.members ?? []}}})
}
if (sub === "activity") {
return route.fulfill({json: {data: {activity: relay.activity ?? []}}})
}
const updated = {...relay, ...(method === "PUT" ? body : {})}
if (sub === "deactivate") {
updated.status = "inactive"
}
if (sub === "reactivate") {
updated.status = "active"
}
relays.set(id, updated)
return route.fulfill({json: {data: updated}})
}
if (resource === "invoices") {
const invoice = invoices.get(id)
if (!invoice) {
return missing(`invoice ${id}`)
}
if (sub === "items") {
return route.fulfill({json: {data: invoice.items ?? []}})
}
if (sub === "bolt11") {
const bolt11 = invoice.bolt11 ?? {
id: `bolt11-${id}`,
invoice_id: id,
lnbc: `lnbc${id}`,
msats: 0,
created_at: now(),
expires_at: now() + int(1, HOUR),
}
return route.fulfill({json: {data: bolt11}})
}
if (sub === "checkout") {
return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/invoices/${id}`}}})
}
// GET and reconcile both answer with the invoice as it now stands, which is how a spec
// marks one paid: flip `paid_at` with the handle and let the dialog's next poll find it.
return route.fulfill({json: {data: invoice}})
}
return route.fallback()
})
return handle
}
export type LivekitOptions = {
// Where the client is told to connect. Point it at something the test owns — the token this
// hands out is accepted by nothing else.

View file

@ -5,13 +5,15 @@ import {
ROOM_ADD_MEMBER,
Resolver,
makeEvent,
prep,
tagSpec,
tagValue,
toNostrURI,
} from "@welshman/util"
import type {SignedEvent, StampedEvent} from "@welshman/util"
import {EventWriter, Profile} from "@welshman/domain"
import type {BaseEventReader} from "@welshman/domain"
import type {EventTemplate, HashedEvent, SignedEvent, StampedEvent} from "@welshman/util"
import {Nip59} from "@welshman/signer"
import {DirectMessage, EventWriter, Profile} from "@welshman/domain"
import type {BaseEventReader, ConfiguredKind, KindFactory} from "@welshman/domain"
import type {RoomOptions, TestRelay} from "../zooid/types"
import type {Zooid} from "../zooid/relay"
import type {TenantName} from "../zooid/config"
@ -30,6 +32,18 @@ export type SeededEvent = {
readonly id: string
}
// The kind-14 a direct message really is. It is never published — each participant gets it inside
// a gift wrap — so this, rather than anything on the wire, is what a spec asserts on.
export type SeededRumor = {
readonly rumor: HashedEvent
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
@ -61,7 +75,16 @@ export type SeededSpace = {
message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent
reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent
profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent
event(user: TestUser, template: StampedEvent): SeededEvent
event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent
// A nip-17 conversation: one kind-14 rumor, gift-wrapped once per participant — the sender
// included, since their own copy is the half of the thread their client reads back.
dm(from: TestUser, to: TestUser[], content: string, createdAt?: number): SeededRumor
// This space's domain kinds, bound to a resolver that answers with its url, so a writer built
// here renders its relay hints as this space. For everything `event()` takes a template for:
// `space.event(user, () => space.kind(Article).writer().setTitle("x").renderTemplate())`.
kind<R extends BaseEventReader, W extends EventWriter<R>>(
factory: KindFactory<R, W>,
): ConfiguredKind<R, W>
}
export type SeedSpaceOptions = {
@ -91,18 +114,24 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
// Every fixture is published to this space, so a relay hint always resolves to its url.
const context = {resolver: new Resolver(() => [relay().url])}
const publish = (write: () => Promise<SignedEvent>): SeededEvent => {
let signed: Maybe<SignedEvent>
// 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>
const event = () => {
if (signed) return signed
enqueue(async () => {
value = await write()
})
return () => {
if (value) return value
throw new Error(`An event seeded into "${name}" was read before seed() published it`)
}
}
enqueue(async () => {
signed = await write()
})
const publish = (write: () => Promise<SignedEvent>): SeededEvent => {
const event = seeded(write)
return {
get event() {
@ -122,7 +151,14 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
const member = (user: TestUser, h?: string) => enqueue(() => relay().member(user, h, startedAt))
const event = (user: TestUser, template: StampedEvent) => publishTemplate(user, () => template)
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})
@ -174,6 +210,41 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
created_at: createdAt,
}))
const kind = <R extends BaseEventReader, W extends EventWriter<R>>(factory: KindFactory<R, W>) =>
factory.configure(context)
// Every wrap is published over the sender's own connection: a gift wrap is signed by an
// ephemeral key, so its author is nobody this process can authenticate as. zooid stores it
// anyway, because it authorizes a kind-1059 by the member named in its p tag.
const dm = (from: TestUser, to: TestUser[], content: string, createdAt = startedAt) => {
const rumor = seeded(async () => {
const writer = kind(DirectMessage).writer().setContent(content)
for (const user of to) {
writer.addRecipient(user.pubkey)
}
const template = {...(await writer.renderTemplate()), created_at: createdAt}
const nip59 = Nip59.fromSigner(from.signer)
for (const {pubkey} of [from, ...to]) {
await relay().publish(await nip59.wrap(pubkey, template), {as: from})
}
// The same template the wraps were built from, so this is the id they decrypt to.
return prep(template, from.pubkey)
})
return {
get rumor() {
return rumor()
},
get id() {
return rumor().id
},
}
}
return {
name,
get url() {
@ -187,5 +258,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
reply,
profile,
event,
dm,
kind,
}
}

View file

@ -14,6 +14,10 @@
export const tenants = {
space: "space.test",
other: "other.test",
// Policy that space.toml cannot express at the same time: one that refuses a join without an
// invite, and one that serves events with their signatures stripped.
closed: "closed.test",
unsigned: "unsigned.test",
} as const
export type TenantName = keyof typeof tenants

View file

@ -21,10 +21,14 @@ services:
DATA: /app/data
MEDIA: /app/media
volumes:
# A copy of ./config that relay.ts stages outside the repo and refreshes on every recreate,
# mounted writable because zooid saves a relay's toml back when a nip-86 call edits its name,
# description or icon. There is no default: the harness is the only thing that should bring
# this container up, and mounting ./config itself would leave the repo's fixtures rewritten
# by whatever the last test did.
- type: bind
source: ./config
source: "${ZOOID_CONFIG:?staged by e2e/harness/zooid/relay.ts, which starts this container}"
target: /app/config
read_only: true
- type: tmpfs
target: /app/data
tmpfs:

View file

@ -0,0 +1,48 @@
# A relay that will not let anyone join on their own say-so: `public_join = false` makes zooid
# refuse a kind-28934 that carries no claim, which is what turns the join dialog into "Request
# Access". Everything else matches space.toml.
#
# Seeding a membership here is therefore not possible — `join()` and `member()` publish a claimless
# RELAY_JOIN and the relay refuses it — so a scenario using this relay seeds admin-created rooms
# and lets the spec drive the join itself.
#
# `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 = "closed.test"
schema = "e2e_closed"
secret = "c105ed0000000000000000000000000000000000000000000000000000000006"
[info]
# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the
# name scenarios pass to `relay()`. `pubkey` is the owner, and here it is the relay's own key (the
# pubkey for `secret`) so that [roles.admin] below is the only thing granting a test identity
# can_manage.
name = "closed"
pubkey = "9b78ed2f0054d1f6ce8307c81e7c4f5aefc78f2396416da05299caac2fb8662e"
description = "Throwaway relay for Flotilla's end-to-end suite."
[policy]
public_read = false
public_write = false
public_join = false
strip_signatures = false
[groups]
enabled = true
[management]
enabled = true
# 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
[roles.admin]
pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"]
can_manage = true

View file

@ -0,0 +1,43 @@
# A relay that serves events with their signatures stripped, which is what makes the client buffer
# them and ask whether the space is trusted. Everything else matches space.toml.
#
# `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 = "unsigned.test"
schema = "e2e_unsigned"
secret = "5169ed0000000000000000000000000000000000000000000000000000000007"
[info]
# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the
# name scenarios pass to `relay()`. `pubkey` is the owner, and here it is the relay's own key (the
# pubkey for `secret`) so that [roles.admin] below is the only thing granting a test identity
# can_manage.
name = "unsigned"
pubkey = "a3708596a61d275e9c825c09e616ff075eca5d276053dcb5e720f8863b1d84a9"
description = "Throwaway relay for Flotilla's end-to-end suite."
[policy]
public_read = false
public_write = false
public_join = true
strip_signatures = true
[groups]
enabled = true
[management]
enabled = true
# 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
[roles.admin]
pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"]
can_manage = true

View file

@ -1,4 +1,7 @@
import {execFile} from "node:child_process"
import {chmod, cp, readdir, rm} from "node:fs/promises"
import {tmpdir} from "node:os"
import {join} from "node:path"
import {fileURLToPath} from "node:url"
import {promisify} from "node:util"
import {ms, sleep} from "@welshman/lib"
@ -7,10 +10,12 @@ import {makeRelayAuth} from "@welshman/util"
import type {SignedEvent} from "@welshman/util"
import {ClientMessageType, isRelayAuth, isRelayOk} from "@welshman/net"
import type {ClientMessage} from "@welshman/net"
import {users} from "../keys"
import {testUsersByPubkey} from "../keys"
import type {TestUser} from "../keys"
import {tenantNames, tenantUrl, tenants} from "./config"
import type {TenantName} from "./config"
import {makeTestRelay} from "./testRelay"
import type {PublishOptions} from "./types"
import {connectToZooid, requestZooid} from "./transport"
import type {ZooidConnection} from "./transport"
@ -18,7 +23,14 @@ const image = "gitea.coracle.social/coracle/zooid:latest"
const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url))
const testUsersByPubkey = new Map(Object.values(users).map(user => [user.pubkey, user]))
const configSource = fileURLToPath(new URL("docker/config", import.meta.url))
// What the container mounts as /app/config. zooid saves a relay's toml back whenever a nip-86 call
// edits that relay's name, description or icon, so it is handed a copy rather than the repo's own
// directory: a read-only mount fails those calls, and a writable one would leave the fixtures every
// other test reads rewritten by whatever the last one did. The copy is staged here rather than in
// an entrypoint because the image is distroless — there is no shell in it to copy anything with.
const configDir = join(tmpdir(), "flotilla-e2e-zooid-config")
const execFileAsync = promisify(execFile)
@ -27,7 +39,11 @@ const execFileAsync = promisify(execFile)
// typed. Name the executable to drive with E2E_DOCKER in that case.
const dockerCommand = process.env.E2E_DOCKER ?? "docker"
const docker = (...args: string[]) => execFileAsync(dockerCommand, args)
// Every invocation carries ZOOID_CONFIG, `down` included: the compose file names it without a
// default, so a call that left it out would fail to interpolate rather than quietly mounting
// something else.
const docker = (...args: string[]) =>
execFileAsync(dockerCommand, args, {env: {...process.env, ZOOID_CONFIG: configDir}})
const compose = (...args: string[]) => docker("compose", "-f", composeFile, ...args)
@ -58,6 +74,17 @@ const probeDocker = async () => {
}
}
const getTestUser = (pubkey: string) => {
const user = testUsersByPubkey.get(pubkey)
if (user) return user
throw new Error(
`Cannot publish as ${pubkey}: zooid authenticates every write, so seeded events must be ` +
"signed by one of the test identities in e2e/harness/keys.ts",
)
}
let problem: Maybe<Promise<Maybe<string>>>
// Asked once a worker: docker does not come and go mid-run, and the answer is also written to the
@ -125,17 +152,17 @@ export class Zooid {
makeTestRelay({
name,
url: tenantUrl(name),
publish: event => this.publish(tenantUrl(name), event),
publish: (event, options) => this.publish(tenantUrl(name), event, options),
})
publish = async (url: string, event: SignedEvent) => {
publish = async (url: string, event: SignedEvent, {as}: PublishOptions = {}) => {
const relay = this.relays.get(url)
if (!relay) {
throw new Error(`Attempted to publish to ${url}, which is not one of this container's relays`)
}
const connection = await this.authenticate(relay.host, event.pubkey)
const connection = await this.authenticate(relay.host, as ?? getTestUser(event.pubkey))
const {ok, detail} = await this.send(connection, [ClientMessageType.Event, event], event.id)
if (!ok) {
@ -176,6 +203,19 @@ export class Zooid {
// Recreating rather than restarting is what makes this a reset: with storage in tmpfs and no
// volume mounted for it, a new container is a new database.
private up = async () => {
// A fresh copy on every recreate is what makes a reset restore pristine relay metadata after a
// test has edited some through nip-86. The modes are permissive because the container runs as
// uid 65532 while the copy belongs to whoever ran the suite, and a runtime that keeps host
// ownership — rootless podman, docker on linux — would otherwise leave zooid unable to write
// the file it was told to save.
await rm(configDir, {recursive: true, force: true})
await cp(configSource, configDir, {recursive: true})
await chmod(configDir, 0o777)
for (const name of await readdir(configDir)) {
await chmod(join(configDir, name), 0o666)
}
try {
await compose("up", "-d", "--force-recreate", "--wait")
} catch (error) {
@ -215,21 +255,12 @@ export class Zooid {
// url signed into the auth event is the one the client uses, because that is the one khatru
// rebuilds from the headers transport.ts sends — a fixture is written over the same relay the app
// talks to.
private authenticate = async (host: string, pubkey: string) => {
const key = `${host} ${pubkey}`
private authenticate = async (host: string, user: TestUser) => {
const key = `${host} ${user.pubkey}`
const session = this.sessions.get(key)
if (session) return session
const user = testUsersByPubkey.get(pubkey)
if (!user) {
throw new Error(
`Cannot publish as ${pubkey}: zooid authenticates every write, so seeded events must be ` +
"signed by one of the test identities in e2e/harness/keys.ts",
)
}
const connection = connectToZooid(host)
const [, challenge] = await connection.wait(isRelayAuth)
const event = await user.signer.sign(makeRelayAuth(`wss://${host}/`, challenge))

View file

@ -9,12 +9,12 @@ import {
import type {SignedEvent, StampedEvent} from "@welshman/util"
import {users} from "../keys"
import type {TestUser} from "../keys"
import type {TestRelay} from "./types"
import type {PublishOptions, TestRelay} from "./types"
export type TestRelayOptions = {
name: string
url: string
publish: (event: SignedEvent) => Promise<void>
publish: (event: SignedEvent, options?: PublishOptions) => Promise<void>
}
// Seeding signs the events a real client would send and publishes them over a socket. Room
@ -36,6 +36,7 @@ export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay
name,
url,
event,
publish,
room: async (h, meta, createdAt) => {
// A relay stamps the metadata it derives from these ops with the op's own created_at, so
// creation has to be strictly older than the edit or the edit is dropped as stale.

View file

@ -2,6 +2,13 @@ import type {SignedEvent, StampedEvent} from "@welshman/util"
import type {ClientMessage, RelayMessage} from "@welshman/net"
import type {TestUser} from "../keys"
export type PublishOptions = {
// Which identity the seeding connection authenticates as. Defaults to the event's own author,
// which then has to be a test identity — a gift wrap is signed by an ephemeral key, so nothing
// in this process can sign as its author and the connection has to belong to the sender.
as?: TestUser
}
export type RoomOptions = {
name?: string
about?: string
@ -23,6 +30,9 @@ export type TestRelay = {
member(user: TestUser, h: string | undefined, createdAt: number): Promise<void>
// Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs.
event(user: TestUser, event: StampedEvent): Promise<SignedEvent>
// An event this process did not sign, sent over `as`'s connection — a gift wrap, whose author
// is the ephemeral key that wrapped it.
publish(event: SignedEvent, options: PublishOptions): Promise<void>
}
// One client's connection to a relay. Every connection to a url reaches the same container, which

1120
e2e/specs/admin.spec.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,937 @@
import * as nip19 from "nostr-tools/nip19"
import {HOUR, MINUTE} from "@welshman/lib"
import {LONG_FORM, MESSAGE, NOTE, makeEvent} from "@welshman/util"
import type {SignedEvent, TrustedEvent} from "@welshman/util"
import {ClientMessageType} from "@welshman/net"
import {Article, Comment, RelayList, Thread} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {expect, getTranscript, mockBlossom, roomPath, spacePath, test, users} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string; readonly event: SignedEvent}
// Where an upload lands. getBlossomServer probes the space's own origin first — blossom is off in
// every tenant's toml, so that probe is meant to fail — then the user's kind-10063 list, and
// VITE_DEFAULT_BLOSSOM_SERVERS is what is left.
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
// A real 1x1 gif. Gif rather than png because compressFileForUpload passes gif through untouched
// instead of re-encoding it through a canvas.
const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64")
const PARTY = "🎉"
// Every event of a kind this page put on the wire, oldest first.
const publishedEvents = (page: Page, kind: number): TrustedEvent[] =>
getTranscript(page.context())
.filter(
({direction, message}) =>
direction === "toRelay" &&
message[0] === ClientMessageType.Event &&
message[1].kind === kind,
)
.map(({message}) => message[1])
// A modal is mounted alongside the page it covers, so the "Create Thread" in a room's compose menu
// and the "Create Thread" that submits the composer are both reachable at once. Everything inside
// a composer is scoped to the modal's own form to say which one is meant.
const modal = (page: Page, title: string) =>
page.locator("form").filter({has: page.getByRole("heading", {name: title})})
// The comment and thread-reply composers are the only forms on their pages carrying a rich text
// editor.
const composerForm = (page: Page) =>
page.locator("form").filter({has: page.locator(".note-editor")})
const editorOf = (scope: Locator) => scope.locator(".note-editor [contenteditable=true]")
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
// EventActions renders zap, emoji and menu into one join, in that order, and every one of them is
// an icon with no accessible name.
const emojiButton = (scope: Locator) => scope.locator(".join").getByRole("button").nth(1)
// RoomCompose's join is the upload button and then the compose menu, which is where an article or
// a thread written from inside a room is started.
const openComposeMenu = (page: Page) =>
page
.locator("form")
.filter({has: page.locator(".chat-editor")})
.locator(".join")
.getByRole("button")
.nth(1)
.click()
// The picker is a web component with an open shadow root, so its search field and its results are
// reachable through it. Searching rather than browsing avoids depending on which category tab an
// emoji happens to live under.
const pickParty = async (page: Page, opener: Locator) => {
await opener.click()
const picker = page.locator("emoji-picker").filter({visible: true})
// Tippy keeps a hidden popover mounted through its fade — a quarter of a second during which the
// picker from the last card is still visible alongside this one — so wait for there to be one
// rather than reaching into whichever resolves first.
await expect(picker).toHaveCount(1)
// A result's label is the emoji's name, its annotation and every shortcode joined together, so
// the annotation is matched rather than the whole of it.
await picker.locator("input.search").fill("party popper")
await picker
.getByRole("option", {name: /party popper/})
.first()
.click()
}
// One reaction, recorded as the reader's own, and taken back off again.
const expectReactionRoundTrip = async (page: Page, scope: Locator, opener: Locator) => {
const pill = scope.getByRole("button", {name: new RegExp(PARTY)})
await pickParty(page, opener)
await expect(pill).toHaveCount(1)
await expect(pill).toHaveClass(/button-primary/)
await pill.click()
await expect(pill).toHaveCount(0)
}
// formatTimestamp renders a short date and a short time in the environment's own locale, which node
// shares, so the date half of it is what a spec can name without pinning a format.
const shortDate = (seconds: number) =>
new Intl.DateTimeFormat(undefined, {dateStyle: "short"}).format(new Date(seconds * 1000))
const articleCards = (page: Page) => page.getByRole("link", {name: /Written by/})
const openArticle = (page: Page, title: string) =>
articleCards(page).filter({hasText: title}).getByText(title, {exact: true}).click()
test("US-037 write and publish an article", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
// An older article, so "at the top of the list" is a statement about order.
space.event(
user.bob,
() =>
space
.kind(Article)
.writer()
.setIdentifier("old-news")
.setTitle("Old News")
.setSummary("Something published a while back.")
.setPublishedAt(at(3, HOUR))
.setContent("Yesterday's headlines.")
.renderTemplate(),
at(3, HOUR),
)
})
const {url} = scenario.space("space")
const page = await as(users.alice, `${spacePath(url)}/articles`)
await expect(articleCards(page)).toHaveCount(1)
await pageBar(page).getByRole("button", {name: "Write"}).click()
const composer = modal(page, "Write an Article")
const title = composer.getByPlaceholder("What is this article about?")
const body = editorOf(composer)
await expect(composer).toBeVisible()
await composer.getByRole("button", {name: "Publish Article"}).click()
await expect(page.getByRole("alert")).toContainText("Please provide a title for your article.")
await title.fill("Half Baked")
await composer.getByRole("button", {name: "Publish Article"}).click()
await expect(page.getByRole("alert")).toContainText("Please write something for your article.")
await body.pressSequentially("Only the beginning.")
// Neither refusal put anything on the wire.
expect(publishedEvents(page, LONG_FORM)).toEqual([])
await composer.getByRole("button", {name: "Go back"}).click()
await expect(page.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
// Reopening picks up where the composer was closed rather than starting over.
await pageBar(page).getByRole("button", {name: "Write"}).click()
await expect(title).toHaveValue("Half Baked")
await expect(body).toContainText("Only the beginning.")
await title.fill("Signals in the Noise")
await composer.getByRole("button", {name: "Publish Article"}).click()
await expect(page.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
await expect(articleCards(page)).toHaveCount(2)
await expect(articleCards(page).first()).toContainText("Signals in the Noise")
expect(publishedEvents(page, LONG_FORM)).toHaveLength(1)
// A published article leaves the composer empty for the next one.
await pageBar(page).getByRole("button", {name: "Write"}).click()
await expect(title).toHaveValue("")
await expect(body).toHaveText("")
})
test("US-038 browse, filter, and read articles", async ({seed, as}) => {
const body = [
"## Why gardens matter",
"",
"Gardens are **worth the trouble**.",
"",
"- Soil",
"- Water",
"- Sunlight",
"",
`Ask ${nip19.npubEncode(users.carol.pubkey)} what she thinks.`,
].join("\n")
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.join(user.carol, "general")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
space.profile(user.carol, {name: "Carol Chen"})
space.event(
user.alice,
() =>
space
.kind(Article)
.writer()
.setIdentifier("tending-the-garden")
.setTitle("Tending the Garden")
.setSummary("A short teaser about gardens.")
.setImage("https://images.test/garden.jpg")
.setTopics(["gardening"])
.setPublishedAt(at(4, HOUR))
.setContent(body)
.renderTemplate(),
at(4, HOUR),
)
space.event(
user.alice,
() =>
space
.kind(Article)
.writer()
.setIdentifier("cooking-with-fire")
.setTitle("Cooking with Fire")
.setSummary("Heat, and what to do with it.")
.setTopics(["cooking"])
.setPublishedAt(at(3, HOUR))
.setContent("Build the fire first.")
.renderTemplate(),
at(3, HOUR),
)
space.event(
user.bob,
() =>
space
.kind(Article)
.writer()
.setIdentifier("notes-on-foraging")
.setTitle("Notes on Foraging")
.setSummary("What grows where nobody planted it.")
.setTopics(["gardening"])
.setPublishedAt(at(2, HOUR))
.setContent("Look under the hedges.")
.renderTemplate(),
at(2, HOUR),
)
space.event(
user.carol,
() =>
space
.kind(Article)
.writer()
.setIdentifier("winter-reading")
.setTitle("Winter Reading")
.setSummary("Six books for the dark months.")
.setTopics(["books"])
.setPublishedAt(at(1, HOUR))
.setContent("Start with the shortest one.")
.renderTemplate(),
at(1, HOUR),
)
})
const {at} = scenario
const {url} = scenario.space("space")
const page = await as(users.bob, `${spacePath(url)}/articles`)
const garden = articleCards(page).filter({hasText: "Tending the Garden"})
await expect(articleCards(page)).toHaveCount(4)
// Author, published date and a preview, all on the card itself.
await expect(garden).toContainText("Written by")
await expect(garden).toContainText("Alice Anderson")
await expect(garden).toContainText("A short teaser about gardens.")
await expect(garden).toContainText(shortDate(at(4, HOUR)))
const authors = page
.locator("section")
.filter({has: page.getByRole("heading", {name: "Authors"})})
const topics = page.locator("section").filter({has: page.getByRole("heading", {name: "Topics"})})
await expect(authors.getByRole("button", {name: /Alice Anderson/})).toBeVisible()
await expect(authors.getByRole("button", {name: /Carol Chen/})).toBeVisible()
await topics.getByRole("button", {name: /^#gardening/}).click()
await expect(articleCards(page)).toHaveCount(2)
await expect(articleCards(page).filter({hasText: "Notes on Foraging"})).toBeVisible()
// Author and topic together are narrower than either on its own.
await authors.getByRole("button", {name: /Alice Anderson/}).click()
await expect(articleCards(page)).toHaveCount(1)
await expect(articleCards(page).first()).toContainText("Tending the Garden")
await openArticle(page, "Tending the Garden")
await expect(page.getByRole("heading", {name: "Tending the Garden"}).first()).toBeVisible()
await expect(page.locator('img[src="https://images.test/garden.jpg"]')).toBeVisible()
await expect(page.getByText("A short teaser about gardens.")).toBeVisible()
await expect(page.getByText(/^Published /)).toContainText(shortDate(at(4, HOUR)))
const markdown = page.locator(".content-markdown")
await expect(markdown.getByRole("heading", {name: "Why gardens matter"})).toBeVisible()
await expect(markdown.locator("strong")).toHaveText("worth the trouble")
await expect(markdown.locator("li")).toHaveText(["Soil", "Water", "Sunlight"])
await expect(markdown.getByRole("link", {name: "@Carol Chen"})).toBeVisible()
})
test("US-039 comment on an article", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.join(user.carol, "general")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
space.profile(user.carol, {name: "Carol Chen"})
space.event(
user.alice,
() =>
space
.kind(Article)
.writer()
.setIdentifier("tending-the-garden")
.setTitle("Tending the Garden")
.setSummary("A short teaser about gardens.")
.setPublishedAt(at(4, HOUR))
.setContent("Gardens are worth the trouble.")
.renderTemplate(),
at(4, HOUR),
)
})
const {url} = scenario.space("space")
const articlesPath = `${spacePath(url)}/articles`
const bob = await as(users.bob, articlesPath)
await openArticle(bob, "Tending the Garden")
await bob.getByRole("button", {name: "Add a comment"}).click()
await editorOf(composerForm(bob)).pressSequentially("The soil chapter is the good one.")
await composerForm(bob).getByRole("button", {name: "Comment"}).click()
await expect(bob.getByText("The soil chapter is the good one.")).toBeVisible()
const carol = await as(users.carol, articlesPath)
await openArticle(carol, "Tending the Garden")
const bobsComment = carol
.locator(".card.z-feature")
.filter({hasText: "The soil chapter is the good one."})
await expect(bobsComment).toBeVisible()
await bobsComment.getByRole("button", {name: "Reply", exact: true}).click()
await editorOf(composerForm(carol)).pressSequentially(
"Only because you skipped the water chapter.",
)
await composerForm(carol).getByRole("button", {name: "Reply", exact: true}).click()
await expect(carol.getByText("Only because you skipped the water chapter.")).toBeVisible()
const alice = await as(users.alice, articlesPath)
await mockBlossom(alice.context(), {server: BLOSSOM_ORIGIN})
await openArticle(alice, "Tending the Garden")
await expect(alice.getByText("The soil chapter is the good one.")).toBeVisible()
await expect(alice.getByText("Only because you skipped the water chapter.")).toBeVisible()
// A reply is nested inside the comment it answers; a top level comment is not.
const nested = alice.locator(".border-l.ml-4.pl-4")
await expect(nested.getByText("Only because you skipped the water chapter.")).toBeVisible()
await expect(nested.getByText("The soil chapter is the good one.")).toHaveCount(0)
await alice.getByRole("button", {name: "Add a comment"}).click()
const composer = composerForm(alice)
await editorOf(composer).pressSequentially("Thanks both, here's the bed I meant.")
await editorOf(composer).press("Enter")
const chooser = alice.waitForEvent("filechooser")
await composer.locator('[data-tip="Add an image"]').click()
await (await chooser).setFiles({name: "bed.gif", mimeType: "image/gif", buffer: GIF})
// The attachment carries an uploading marker from the moment the request goes out until the
// blossom descriptor comes back and replaces its blob url.
await expect(composer.locator(".tiptap-object")).not.toHaveClass(/tiptap-uploading/)
await composer.getByRole("button", {name: "Comment"}).click()
const alicesComment = alice
.locator(".card.z-feature")
.filter({hasText: "Thanks both, here's the bed I meant."})
await expect(alicesComment).toBeVisible()
await expect(alicesComment.locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
})
test("US-040 react to a post with an emoji", async ({seed, as}) => {
let thread!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
// A profile's notes are loaded through its author's outbox relays, so alice needs a relay list
// for her note to be findable at all.
space.event(user.alice, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url])
.renderTemplate(),
)
const article = space.event(
user.alice,
() =>
space
.kind(Article)
.writer()
.setIdentifier("tending-the-garden")
.setTitle("Tending the Garden")
.setPublishedAt(at(4, HOUR))
.setContent("Gardens are worth the trouble.")
.renderTemplate(),
at(4, HOUR),
)
space.event(
user.alice,
() =>
space
.kind(Comment)
.writer()
.setRootFromEvent(article.event)
.setParentFromEvent(article.event)
.setContent("A note about the soil chapter.")
.renderTemplate(),
at(3, HOUR),
)
const topic = space.event(
user.alice,
() =>
space
.kind(Thread)
.writer()
.setRoom(space.url, "general")
.setTitle("Bed rotation")
.setContent("How often do you move things around?")
.renderTemplate(),
at(3, HOUR),
)
space.event(
user.alice,
() =>
space
.kind(Comment)
.writer()
.setRootFromEvent(topic.event)
.setParentFromEvent(topic.event)
.setContent("Every other season, usually.")
.renderTemplate(),
at(2, HOUR),
)
space.event(
user.alice,
makeEvent(NOTE, {content: "A short note from the garden.", created_at: at(2, HOUR)}),
)
thread = topic
})
const {url} = scenario.space("space")
const page = await as(users.bob, `${spacePath(url)}/articles`)
await openArticle(page, "Tending the Garden")
const articleCard = page.locator(".card.z-feature").filter({hasText: "Gardens are worth"})
const commentCard = page.locator(".card.z-feature").filter({hasText: "A note about the soil"})
await expect(articleCard).toBeVisible()
await expectReactionRoundTrip(page, articleCard, emojiButton(articleCard))
await expect(commentCard).toBeVisible()
await expectReactionRoundTrip(page, commentCard, emojiButton(commentCard))
await page.goto(`${spacePath(url)}/threads/${thread.id}`)
const threadPost = page.locator("article").filter({hasText: "Every other season, usually."})
await expect(threadPost).toBeVisible()
await expectReactionRoundTrip(page, threadPost, emojiButton(threadPost))
await page.goto(`/people/${nip19.npubEncode(users.alice.pubkey)}`)
const noteCard = page
.locator(".card.card-interactive")
.filter({hasText: "A short note from the garden."})
await expect(noteCard).toBeVisible()
// NoteItem's picker is the last control on the card rather than part of an EventActions join.
await expectReactionRoundTrip(page, noteCard, noteCard.getByRole("button").last())
})
test("US-041 publish an article from a room", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("lounge", {name: "Lounge"})
space.join(user.alice, "lounge")
space.profile(user.alice, {name: "Alice Anderson"})
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "lounge"))
await openComposeMenu(page)
await page.getByRole("button", {name: "Write an Article"}).click()
const composer = modal(page, "Write an Article")
await composer.getByPlaceholder("What is this article about?").fill("Repotting in Winter")
await editorOf(composer).pressSequentially("Wait for a warm week.")
await composer.getByRole("button", {name: "Publish Article"}).click()
await expect(page.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
// The room hears about the article without alice posting it a second time.
await expect(page.getByText("Repotting in Winter")).toBeVisible()
await page.goto(`${spacePath(url)}/articles`)
const card = articleCards(page).filter({hasText: "Repotting in Winter"})
await expect(card).toBeVisible()
await expect(card.getByRole("link", {name: "#Lounge"})).toBeVisible()
await openArticle(page, "Repotting in Winter")
const badge = page.getByRole("link", {name: "Posted in #Lounge"})
await expect(badge).toBeVisible()
await badge.click()
await expect(page).toHaveURL(new RegExp(`${roomPath(url, "lounge")}$`))
await expect(page.locator(".chat-editor")).toBeVisible()
})
test("US-042 start a thread and see it filed under its room", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("lounge", {name: "Lounge"})
space.join(user.alice, "lounge")
space.join(user.bob, "lounge")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
// An older topic with replies, so the board's reply count and last-post time are statements
// about the thread rather than about an empty row.
const topic = space.event(
user.bob,
() =>
space
.kind(Thread)
.writer()
.setRoom(space.url, "lounge")
.setTitle("Chair procurement")
.setContent("The old ones are done for.")
.renderTemplate(),
at(5, HOUR),
)
const replies = ["Mesh, obviously.", "Anything but mesh."]
replies.forEach((content, index) => {
space.event(
index === 0 ? user.alice : user.bob,
() =>
space
.kind(Comment)
.writer()
.setRootFromEvent(topic.event)
.setParentFromEvent(topic.event)
.setContent(content)
.renderTemplate(),
at(4, HOUR) + index * 60,
)
})
})
const {at} = scenario
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "lounge"))
await openComposeMenu(page)
await page.getByRole("button", {name: "Create Thread"}).click()
const fromRoom = modal(page, "Create a Thread")
await fromRoom.getByPlaceholder("What is this thread about?").fill("Bike shed colors")
await editorOf(fromRoom).pressSequentially("Blue, surely?")
await fromRoom.getByRole("button", {name: "Create Thread"}).click()
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
// The room gets a quote of the thread rather than a second post written by hand.
await expect(page.getByText("Bike shed colors")).toBeVisible()
await page.goto(`${spacePath(url)}/threads`)
const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})})
const chairs = lounge.getByRole("row").filter({hasText: "Chair procurement"})
await expect(lounge.getByRole("row").filter({hasText: "Bike shed colors"})).toBeVisible()
// Topic, author, reply count and last post, in that order.
await expect(chairs.getByRole("cell").nth(0)).toContainText("Chair procurement")
await expect(chairs.getByRole("cell").nth(1)).toContainText("Bob Barker")
await expect(chairs.getByRole("cell").nth(2)).toHaveText("2")
await expect(chairs.getByRole("cell").nth(3)).toContainText(shortDate(at(4, HOUR) + 60))
await pageBar(page).getByRole("button", {name: "Create", exact: true}).click()
const fromThreads = modal(page, "Create a Thread")
await fromThreads.getByPlaceholder("What is this thread about?").fill("Open floor")
await editorOf(fromThreads).pressSequentially("Anything goes in here.")
await fromThreads.getByRole("button", {name: "Create Thread"}).click()
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
const general = page
.locator("section")
.filter({has: page.getByRole("heading", {name: "General", exact: true})})
await expect(general.getByRole("row").filter({hasText: "Open floor"})).toBeVisible()
})
test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
let thread!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("lounge", {name: "Lounge"})
space.join(user.alice, "lounge")
space.join(user.bob, "lounge")
space.join(user.carol, "lounge")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
space.profile(user.carol, {name: "Carol Chen"})
const topic = space.event(
user.alice,
() =>
space
.kind(Thread)
.writer()
.setRoom(space.url, "lounge")
.setTitle("Bed rotation")
.setContent("How often do you move things around?")
.renderTemplate(),
at(3, HOUR),
)
// Twenty replies plus the opening post is one post past the first page, and the last of them
// is alice's, so her OP badge has to survive the page change.
for (let i = 1; i <= 20; i++) {
space.event(
i === 20 ? user.alice : i % 2 === 0 ? user.carol : user.bob,
() =>
space
.kind(Comment)
.writer()
.setRootFromEvent(topic.event)
.setParentFromEvent(topic.event)
.setContent(`Reply ${String(i).padStart(2, "0")}`)
.renderTemplate(),
at(3, HOUR) + i * 60,
)
}
thread = topic
})
const {url} = scenario.space("space")
const threadPath = `${spacePath(url)}/threads/${thread.id}`
const bob = await as(users.bob, threadPath)
const openingPost = bob.locator(`[data-event="${thread.id}"]`)
await expect(openingPost).toBeVisible()
await expect(openingPost.getByText("OP", {exact: true})).toBeVisible()
await expect(bob.getByText("20 replies")).toBeVisible()
await bob.getByRole("button", {name: "Reply to thread"}).click()
const threadReply = composerForm(bob)
await expect(threadReply).toBeVisible()
await expect(threadReply.getByText(/^Replying to/)).toHaveCount(0)
await editorOf(threadReply).pressSequentially("Twice a year here.")
await threadReply.getByRole("button", {name: "Post Reply"}).click()
await expect(bob.getByText("21 replies")).toBeVisible()
// The thread's author is marked OP wherever their posts turn up, second page included.
await bob.getByRole("button", {name: "2", exact: true}).click()
await expect(bob.getByText("Page 2 of 2")).toBeVisible()
const alicesLastPost = bob.locator("article").filter({hasText: "Reply 20"})
await expect(alicesLastPost.getByText("OP", {exact: true})).toBeVisible()
await expect(bob.getByText("Twice a year here.")).toBeVisible()
const carol = await as(users.carol, threadPath)
const bobsFirstPost = carol.locator("article").filter({hasText: "Reply 01"})
await expect(bobsFirstPost).toBeVisible()
await bobsFirstPost.getByRole("button", {name: "Reply", exact: true}).click()
const postReply = composerForm(carol)
const replyingTo = postReply.locator(".border-l-2")
await expect(replyingTo).toContainText("Replying to @Bob Barker")
await expect(replyingTo).toContainText("Reply 01")
await replyingTo.getByRole("button").last().click()
await expect(postReply.getByText(/^Replying to/)).toHaveCount(0)
await editorOf(postReply).pressSequentially("Answering the thread instead.")
await postReply.getByRole("button", {name: "Post Reply"}).click()
// Twenty one replies before hers is already a page and a bit, so her post is appended to the
// last one rather than to the page she wrote it from. The last control in the join is "go last",
// which is the same button whatever the page count turns out to be.
await carol
.getByText(/^Page \d+ of \d+$/)
.locator("xpath=..")
.locator(".join")
.getByRole("button")
.last()
.click()
await expect(carol.getByText("Answering the thread instead.")).toBeVisible()
})
test("US-044 navigate a long thread", async ({seed, as}) => {
let thread!: Seeded
let lastPost!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("lounge", {name: "Lounge"})
space.join(user.alice, "lounge")
space.join(user.bob, "lounge")
space.join(user.carol, "lounge")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
space.profile(user.carol, {name: "Carol Chen"})
const topic = space.event(
user.alice,
() =>
space
.kind(Thread)
.writer()
.setRoom(space.url, "lounge")
.setTitle("The long one")
.setContent("Settle in.")
.renderTemplate(),
at(4, HOUR),
)
// Forty one replies plus the opening post is three pages of twenty.
for (let i = 1; i <= 41; i++) {
const reply = space.event(
i % 2 === 0 ? user.alice : user.bob,
() =>
space
.kind(Comment)
.writer()
.setRootFromEvent(topic.event)
.setParentFromEvent(topic.event)
.setContent(`Reply ${String(i).padStart(2, "0")}`)
.renderTemplate(),
at(4, HOUR) + i * 60,
)
if (i === 41) {
lastPost = reply
}
}
thread = topic
})
const {url} = scenario.space("space")
const threadPath = `${spacePath(url)}/threads/${thread.id}`
const bob = await as(users.bob, threadPath, {
context: {permissions: ["clipboard-read", "clipboard-write"]},
})
const indicator = bob.getByText(/^Page \d+ of \d+$/)
await expect(indicator).toHaveText("Page 1 of 3")
await expect(bob.getByText("Reply 19", {exact: true})).toBeVisible()
// ThreadPagination is first, prev, a button per page, next, last, and only the page numbers
// carry an accessible name — the rest are icons — so with three pages the ends are 0, 1, 5, 6.
const controls = indicator.locator("xpath=..").locator(".join").getByRole("button")
const goFirst = controls.nth(0)
const goPrev = controls.nth(1)
const goNext = controls.nth(5)
const goLast = controls.nth(6)
await goNext.click()
await expect(indicator).toHaveText("Page 2 of 3")
await expect(bob.getByText("Reply 20", {exact: true})).toBeVisible()
await goLast.click()
await expect(indicator).toHaveText("Page 3 of 3")
await expect(bob.getByText("Reply 41", {exact: true})).toBeVisible()
await goPrev.click()
await expect(indicator).toHaveText("Page 2 of 3")
await expect(bob.getByText("Reply 20", {exact: true})).toBeVisible()
await goFirst.click()
await expect(indicator).toHaveText("Page 1 of 3")
await expect(bob.getByText("Reply 19", {exact: true})).toBeVisible()
await bob.getByRole("button", {name: "3", exact: true}).click()
await expect(indicator).toHaveText("Page 3 of 3")
const finalPost = bob.locator(`[data-event="${lastPost.id}"]`)
await finalPost.getByRole("button", {name: "Permalink"}).click()
await expect(bob.getByRole("alert")).toContainText("Copied to clipboard!")
const permalink = await bob.evaluate(() => navigator.clipboard.readText())
const {pathname, hash} = new URL(permalink)
expect(pathname).toBe(threadPath)
expect(hash).toBe(`#${nip19.neventEncode({id: lastPost.id, relays: [url]})}`)
const carol = await as(users.carol, pathname + hash)
const target = carol.locator(`[data-event="${lastPost.id}"]`)
await expect(carol.getByText(/^Page \d+ of \d+$/)).toHaveText("Page 3 of 3")
await expect(target).toBeVisible()
await expect(target).toBeInViewport()
})
test("US-045 turn a chat message into a thread", async ({seed, as}) => {
let promoted!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("lounge", {name: "Lounge"})
space.join(user.alice, "lounge")
space.join(user.bob, "lounge")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
promoted = space.message(user.alice, "lounge", "the deploy broke again", at(20, MINUTE))
})
const {url} = scenario.space("space")
const page = await as(users.bob, roomPath(url, "lounge"))
const message = page.locator(`[data-event="${promoted.id}"]`)
await expect(message).toBeVisible()
await message.hover()
// RoomItem's hover actions are zap, emoji, reply and menu, all of them icons.
await message.locator(".room__item-actions").getByRole("button").last().click()
await page.getByRole("button", {name: "Create a Thread"}).click()
const composer = modal(page, "Create a Thread")
const nevent = nip19.neventEncode({id: promoted.id, kind: MESSAGE, relays: [url]})
await expect(editorOf(composer)).toContainText(`nostr:${nevent}`)
await composer.getByPlaceholder("What is this thread about?").fill("Deploy failures")
await composer.getByRole("button", {name: "Create Thread"}).click()
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
await page.goto(`${spacePath(url)}/threads`)
const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})})
const row = lounge.getByRole("row").filter({hasText: "Deploy failures"})
await expect(row).toBeVisible()
await row.click()
await expect(page.getByRole("heading", {name: "Deploy failures"})).toBeVisible()
await expect(page.getByText("the deploy broke again")).toBeVisible()
})

1142
e2e/specs/community.spec.ts Normal file

File diff suppressed because it is too large Load diff

496
e2e/specs/composer.spec.ts Normal file
View file

@ -0,0 +1,496 @@
import {createHash} from "node:crypto"
import type {BrowserContext, Locator, Page} from "@playwright/test"
import {HOUR, now} from "@welshman/lib"
import {MessagingRelayList, RelayList} from "@welshman/domain"
import {expect, makeTestUser, roomPath, test, users} from "../harness"
import type {SeededSpace, TestUser} from "../harness"
// The path the app builds for a conversation — see makeChatId in src/app/chats.ts.
const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
// Where an upload lands: the space's own origin is probed first and blossom is off in every
// tenant's toml, so what is left is VITE_DEFAULT_BLOSSOM_SERVERS.
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
// A one pixel gif. Gif rather than png because compressFileForUpload passes it through untouched
// rather than re-encoding it through a canvas, so the mock stores the bytes the browser was handed.
const GIF = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
const gifFile = (name: string) => ({
name,
mimeType: "image/gif",
buffer: Buffer.from(GIF, "base64"),
})
// A blossom server whose blobs are shared by every context in a test, and whose next upload can be
// held open. The harness's own `mockBlossom` keeps blobs per browser context, which a conversation's
// image cannot use: it is uploaded encrypted and decrypted by the recipient, so the bytes alice puts
// in have to be the bytes bob reads back. Holding the upload makes the in-flight state a fact rather
// than a race against a mock that answers in a microtask.
const serveBlossom = () => {
const blobs = new Map<string, {body: Buffer; type: string}>()
let held = Promise.resolve()
let open = () => {}
return {
hold: () => {
held = new Promise<void>(resolve => {
open = resolve
})
},
release: () => open(),
install: (context: BrowserContext) =>
context.route(`${BLOSSOM_ORIGIN}/**`, async route => {
const request = route.request()
const method = request.method()
const {pathname} = new URL(request.url())
if (pathname === "/upload" && method === "PUT") {
await held
const body = request.postDataBuffer() ?? Buffer.alloc(0)
const type = request.headers()["content-type"] ?? "application/octet-stream"
const sha256 = createHash("sha256").update(body).digest("hex")
blobs.set(sha256, {body, type})
const url = `${BLOSSOM_ORIGIN}/${sha256}`
return route.fulfill({json: {sha256, type, url, size: body.length, uploaded: now()}})
}
const blob = blobs.get(pathname.slice(1).split(".")[0])
if (blob) {
return route.fulfill({contentType: blob.type, body: method === "HEAD" ? "" : blob.body})
}
return route.fallback()
}),
}
}
// Outbox routing resolves everything about a person through their relay list, so a seeded profile
// is only loadable by somebody else once its author has one.
const seedRelayList = (space: SeededSpace, user: TestUser) =>
space.event(user, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url])
.renderTemplate(),
)
// The kind-10050 a conversation needs at both ends: the recipient's is what enables the composer,
// the sender's is what routes their own copy of the wrap back to them.
const seedMessagingRelays = (space: SeededSpace, user: TestUser) =>
space.event(user, () =>
space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(),
)
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
// The send button carries the shortcut it advertises, which differs by platform.
const sendButton = (page: Page) => page.locator("button[data-tip$='enter to send']")
const suggestions = (page: Page) => page.locator(".tiptap-suggestions__item")
const timeline = (page: Page) => page.locator(".room__content")
// RoomCompose leads with a two button group: upload, then the create menu.
const roomUploadButton = (page: Page) => page.locator(".room__compose-inner .join-item").first()
const chatUploadButton = (page: Page) => page.locator("button[data-tip='Add an image']")
// Both things the composer puts above itself — the message being replied to and the editing
// indicator — are the same bordered strip, each with a single button in it: its X.
const banner = (page: Page, text: string) =>
page.locator(".room__compose .border-l-2").filter({hasText: text})
// RoomItem gives its hover actions no accessible names — every one is an icon. Their order is
// fixed by the component: zap, emoji, reply, edit (only on your own recent message), menu.
const messageActions = (page: Page, text: string) =>
page.locator(".room__item").filter({hasText: text}).locator(".room__item-actions button")
const chooseFile = async (
page: Page,
button: Locator,
file: {name: string; mimeType: string; buffer: Buffer},
) => {
const chooser = page.waitForEvent("filechooser")
await button.click()
await (await chooser).setFiles(file)
}
// Both of these are DataTransfer dispatches rather than anything native: prosemirror reads the file
// straight off the event, and playwright's own dispatchEvent builds a plain Event, which would drop
// the dataTransfer the handler needs.
const dropImage = (editor: Locator, name: string) =>
editor.evaluate(
(node, {name, data}) => {
const transfer = new DataTransfer()
const {left, top, width, height} = node.getBoundingClientRect()
transfer.items.add(
new File([Uint8Array.from(atob(data), c => c.charCodeAt(0))], name, {type: "image/gif"}),
)
node.dispatchEvent(
new DragEvent("drop", {
bubbles: true,
cancelable: true,
dataTransfer: transfer,
clientX: left + width / 2,
clientY: top + height / 2,
}),
)
},
{name, data: GIF},
)
const pasteImage = (editor: Locator, name: string) =>
editor.evaluate(
(node, {name, data}) => {
const transfer = new DataTransfer()
transfer.items.add(
new File([Uint8Array.from(atob(data), c => c.charCodeAt(0))], name, {type: "image/gif"}),
)
node.dispatchEvent(
new ClipboardEvent("paste", {bubbles: true, cancelable: true, clipboardData: transfer}),
)
},
{name, data: GIF},
)
test("US-056 autocomplete a mention or a room reference", async ({seed, as}) => {
const outsider = makeTestUser("bobbin")
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.room("random", {name: "Random"})
space.join(user.alice, "general", "random")
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Roberts", picture: "https://images.test/bob.png"})
seedRelayList(space, user.bob)
space.message(user.bob, "general", "morning all", at(2, HOUR))
// Someone whose name matches the same term but who belongs to a different space, so the
// dropdown has a non-member to rank below this space's own members.
other.room("lounge", {name: "Lounge"})
other.join(user.alice, "lounge")
other.join(outsider, "lounge")
other.profile(outsider, {name: "Bobbin Amaranth"})
seedRelayList(other, outsider)
other.message(outsider, "lounge", "hello from the lounge", at(2, HOUR))
})
const space = scenario.space("space")
const other = scenario.space("other")
// Alice arrives through the other space so that the outsider's profile is in her client before
// she composes: a profile reaches her by being rendered, and his never renders in the space she
// is about to type in.
const page = await as(users.alice, roomPath(other.url, "lounge"))
await expect(timeline(page).getByText("Bobbin Amaranth")).toBeVisible()
// A space's nav item is labeled with the name its nip-11 document reports, and the two tenants
// report "space" and "other".
await page.locator('.primary-nav [data-tip="space"]').click()
await page.locator(".secondary-nav").getByRole("link", {name: "General"}).click()
await expect(timeline(page).getByText("morning all")).toBeVisible()
const editor = composer(page)
await editor.click()
await editor.pressSequentially("@bob")
// This space's own member first, the outsider from the other space under him.
await expect(suggestions(page)).toContainText(["Bob Roberts", "Bobbin Amaranth"])
await editor.pressSequentially("bin")
await expect(suggestions(page)).toContainText(["Bobbin Amaranth"])
// Back to "@bob", where both of them are on offer again.
await editor.press("Backspace")
await editor.press("Backspace")
await editor.press("Backspace")
await expect(suggestions(page)).toContainText(["Bob Roberts", "Bobbin Amaranth"])
await suggestions(page).filter({hasText: "Bob Roberts"}).click()
const mention = editor.locator(".tiptap-object")
await expect(mention).toHaveText("@Bob Roberts")
await expect(mention.locator("img")).toBeVisible()
await editor.pressSequentially("are you around?")
await editor.press("Enter")
const sent = page.locator(".room__item").filter({hasText: "are you around?"})
const sentMention = sent.getByRole("button", {name: "@Bob Roberts"})
await expect(sentMention).toBeVisible()
await expect(sentMention.locator("img")).toBeVisible()
await editor.pressSequentially("~gene")
await expect(suggestions(page)).toContainText(["General"])
await suggestions(page).filter({hasText: "General"}).click()
await expect(editor.locator(".tiptap-object")).toContainText("General")
await editor.press("Enter")
await expect(timeline(page).getByRole("link", {name: "#General"})).toHaveAttribute(
"href",
roomPath(space.url, "general"),
)
})
test("US-057 attach and send an image", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.message(user.bob, "general", "morning all", at(2, HOUR))
for (const person of [user.alice, user.bob]) {
space.profile(person, {name: person.name})
seedRelayList(space, person)
seedMessagingRelays(space, person)
}
})
const path = roomPath(scenario.space("space").url, "general")
const blossom = serveBlossom()
const alice = await as(users.alice, path)
const bob = await as(users.bob, path)
await blossom.install(alice.context())
await blossom.install(bob.context())
await expect(timeline(alice).getByText("morning all")).toBeVisible()
blossom.hold()
await chooseFile(alice, roomUploadButton(alice), gifFile("photo.gif"))
await expect(alice.locator(".room__compose-inner .spinner")).toBeVisible()
await expect(sendButton(alice)).toBeDisabled()
blossom.release()
await expect(sendButton(alice)).toBeEnabled()
await expect(composer(alice)).toContainText("photo.gif")
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
await composer(alice).press("Enter")
await expect(timeline(alice).locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
await expect(timeline(bob).locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
await dropImage(composer(alice), "dropped.gif")
await expect(composer(alice)).toContainText("dropped.gif")
await pasteImage(composer(alice), "pasted.gif")
await expect(composer(alice)).toContainText("pasted.gif")
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
// The same thing in a conversation. Its composer stays disabled until the recipient's messaging
// relays have been read, which is what waiting on the send button waits out.
await alice.goto(chatPath(users.bob.pubkey))
await bob.goto(chatPath(users.alice.pubkey))
await expect(sendButton(alice)).toBeEnabled()
await chooseFile(alice, chatUploadButton(alice), gifFile("selfie.gif"))
await expect(composer(alice)).toContainText("selfie.gif")
// The file node appears the moment it is attached, which is also the moment `uploading` goes
// true — and submit returns without a word while it is. Encryption makes that window wide enough
// to press enter into, so wait the upload out rather than losing the message to it.
await expect(sendButton(alice)).toBeEnabled()
await composer(alice).press("Enter")
// A conversation's image is uploaded encrypted, so the recipient fetches the ciphertext and
// decrypts it into a blob url rather than pointing an <img> at the server.
await expect(bob.locator('.chat-bubble img[src^="blob:"]')).toBeVisible()
// Back to the room on a fresh page, so no toast the conversation raised is still standing and the
// one asserted below can only be the rejected upload's own.
await alice.goto(path)
await expect(timeline(alice).getByText("morning all")).toBeVisible()
await expect(alice.getByRole("alert")).toHaveCount(0)
await chooseFile(alice, roomUploadButton(alice), {
name: "notes.txt",
mimeType: "text/plain",
buffer: Buffer.from("not an image"),
})
await expect(alice.getByRole("alert")).toBeVisible()
await expect(composer(alice)).not.toContainText("notes.txt")
})
test("US-058 drafts survive navigating away", async ({seed, as}) => {
await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.room("random", {name: "Random"})
space.join(user.alice, "general", "random")
space.join(user.bob, "general")
space.message(user.bob, "general", "morning all", at(2, HOUR))
// Alice's own kind-10050 is what lets the Messages nav item navigate rather than stop to ask
// her to enable chat; bob's is what enables the conversation's composer.
for (const person of [user.alice, user.bob]) {
space.profile(person, {name: person.name})
seedRelayList(space, person)
seedMessagingRelays(space, person)
}
})
// Drafts live in memory, so every move here is an in-app navigation — a reload would clear them
// whether or not they were kept.
const page = await as(users.alice, chatPath(users.bob.pubkey))
const editor = composer(page)
const rooms = page.locator(".secondary-nav")
await expect(sendButton(page)).toBeEnabled()
await editor.click()
await editor.pressSequentially("still thinking about this")
await page.locator('.primary-nav [data-tip="space"]').click()
await rooms.getByRole("link", {name: "General"}).click()
await expect(timeline(page).getByText("morning all")).toBeVisible()
await expect(editor).toHaveText("")
await editor.click()
await editor.pressSequentially("half a thought")
await rooms.getByRole("link", {name: "Random"}).click()
await expect(editor).toHaveText("")
await rooms.getByRole("link", {name: "General"}).click()
await expect(editor).toHaveText("half a thought")
await page.locator('.primary-nav [data-tip="Messages"]').click()
await expect(editor).toHaveText("still thinking about this")
await page.locator('.primary-nav [data-tip="space"]').click()
await rooms.getByRole("link", {name: "General"}).click()
await expect(editor).toHaveText("half a thought")
await editor.press("Enter")
await expect(timeline(page).getByText("half a thought")).toBeVisible()
await expect(editor).toHaveText("")
await rooms.getByRole("link", {name: "Random"}).click()
await rooms.getByRole("link", {name: "General"}).click()
await expect(editor).toHaveText("")
})
test("US-059 cancel a reply or an edit in progress", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Roberts"})
seedRelayList(space, user.bob)
space.message(user.bob, "general", "morning all", at(2, HOUR))
})
const page = await as(users.alice, roomPath(scenario.space("space").url, "general"))
const editor = composer(page)
const replyBanner = banner(page, "Replying to @Bob Roberts")
const editBanner = banner(page, "Editing message")
await expect(timeline(page).getByText("morning all")).toBeVisible()
// Only a message alice sent herself in the last five minutes offers an edit, so she sends one.
await editor.click()
await editor.pressSequentially("my first message")
await editor.press("Enter")
await expect(timeline(page).getByText("my first message")).toBeVisible()
await editor.pressSequentially("half-written thought")
// Bob's message offers zap, emoji, reply and the menu; alice's own also offers the edit.
await expect(messageActions(page, "morning all")).toHaveCount(4)
await expect(messageActions(page, "my first message")).toHaveCount(5)
await messageActions(page, "morning all").nth(2).click()
await expect(replyBanner).toContainText("morning all")
await editor.press("Escape")
await expect(replyBanner).toHaveCount(0)
await expect(editor).toHaveText("half-written thought")
await messageActions(page, "morning all").nth(2).click()
await expect(replyBanner).toBeVisible()
await replyBanner.getByRole("button").click()
await expect(replyBanner).toHaveCount(0)
await expect(editor).toHaveText("half-written thought")
await messageActions(page, "my first message").nth(3).click()
await expect(editBanner).toBeVisible()
await expect(editor).toHaveText("my first message")
await editor.press("Escape")
await expect(editBanner).toHaveCount(0)
await expect(editor).toHaveText("half-written thought")
await messageActions(page, "my first message").nth(3).click()
await expect(editBanner).toBeVisible()
await editBanner.getByRole("button").click()
await expect(editBanner).toHaveCount(0)
await expect(editor).toHaveText("half-written thought")
// Neither message was touched, and the draft never left the composer.
await expect(timeline(page).getByText("morning all")).toHaveCount(1)
await expect(timeline(page).getByText("my first message")).toHaveCount(1)
await expect(timeline(page).getByText("half-written thought")).toHaveCount(0)
})

View file

@ -0,0 +1,496 @@
import {npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE} from "@welshman/lib"
import {MESSAGE, makeEvent} from "@welshman/util"
import {Article, Thread} from "@welshman/domain"
import {expect, roomPath, spacePath, test, users} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string}
// A bolt11 invoice as @welshman/content recognizes one: only behind a `lightning:` scheme, and
// what the chip renders and copies is what follows the scheme.
const INVOICE =
"lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq" +
"ypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3" +
"agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9"
// A cashu token keeps its own scheme in the value, and needs fifty-odd payload characters after
// it before the parser will take it.
const CASHU =
"cashu:cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJpZCI6IjAwOWExZjI5MzI1M2U0MWUiLCJhbW91bnQiOjIs" +
"InNlY3JldCI6ImFhYSIsIkMiOiJiYmIifV0sIm1pbnQiOiJodHRwczovL21pbnQudGVzdCJ9XX0="
test("US-060 reveal a flagged sensitive message", async ({seed, as}) => {
let flagged!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.message(user.alice, "general", "no spoilers please", at(45, MINUTE))
flagged = space.event(
user.bob,
makeEvent(MESSAGE, {
content: "the butler did it",
tags: [
["h", "general"],
["content-warning", "spoilers"],
],
created_at: at(30, MINUTE),
}),
)
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general"))
// Scoped to bob's own message, so "in place of his text" is a claim about this message rather
// than about the room as a whole.
const message = page.locator(`[data-event="${flagged.id}"]`)
const warning = message.getByText('flagged by the author as "spoilers"')
await expect(warning).toBeVisible()
await expect(message.getByText("the butler did it")).toHaveCount(0)
await message.getByRole("button", {name: "Show anyway"}).click()
await expect(message.getByText("the butler did it")).toBeVisible()
await expect(warning).toHaveCount(0)
})
test("US-061 expand a long post", async ({seed, as}) => {
// Twelve of these is several times the three hundred characters an article card shows.
const paragraphs = Array.from(
{length: 12},
(_, i) => `Paragraph ${i} works through another part of the plan in some detail`,
)
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.carol, "general")
space.profile(user.carol, {name: "Carol Chen"})
// No summary, so the card shows the body itself and truncating it is what the story is about.
space.event(
user.carol,
() =>
space
.kind(Article)
.writer()
.setIdentifier("the-long-road")
.setTitle("The Long Road")
.setPublishedAt(at(3, HOUR))
.setContent(paragraphs.join("\n\n"))
.renderTemplate(),
at(3, HOUR),
)
})
const {url} = scenario.space("space")
const page = await as(users.alice, `${spacePath(url)}/articles`)
await expect(page.getByText("The Long Road")).toBeVisible()
await expect(page.getByText(paragraphs[0])).toBeVisible()
await expect(page.getByText(paragraphs[11])).toHaveCount(0)
const before = page.url()
await page.getByRole("button", {name: "Read more"}).click()
await expect(page.getByText(paragraphs[11])).toBeVisible()
// The whole card is a link into the article, so expanding in place means the click never
// reached it.
expect(page.url()).toBe(before)
})
test("US-062 see images and video inline", async ({seed, as}) => {
let picture!: Seeded
let clip!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
picture = space.message(user.bob, "general", "https://images.test/sunset.png", at(40, MINUTE))
clip = space.message(user.bob, "general", "https://media.test/clip.mp4", at(35, MINUTE))
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general"))
const pictureMessage = page.locator(`[data-event="${picture.id}"]`)
const clipMessage = page.locator(`[data-event="${clip.id}"]`)
const inlineImage = pictureMessage.locator('img[src="https://images.test/sunset.png"]')
await expect(inlineImage).toBeVisible()
await expect(pictureMessage.getByText("images.test/sunset.png")).toHaveCount(0)
await inlineImage.click()
// The lightbox is a fullscreen modal holding nothing but the image.
const lightbox = page.locator(".dialog img")
await expect(lightbox).toBeVisible()
await lightbox.click()
await expect(page.locator(".dialog")).toHaveCount(0)
await expect(inlineImage).toBeVisible()
const video = clipMessage.locator("video")
await expect(video).toBeVisible()
await expect(video).toHaveJSProperty("controls", true)
})
test("US-063 preview a shared link", async ({seed, as}) => {
const announcement = "https://example.test/announcement"
const unreadable = "https://example.test/unreadable"
let standalone!: Seeded
let broken!: Seeded
let sentence!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
standalone = space.message(user.bob, "general", announcement, at(40, MINUTE))
broken = space.message(user.bob, "general", unreadable, at(35, MINUTE))
sentence = space.message(
user.bob,
"general",
`Have a look at ${announcement} before the call.`,
at(30, MINUTE),
)
})
const {url} = scenario.space("space")
// The room is opened by hand below rather than by as(): a preview is fetched once per url and
// memoized for the life of the page, so the backend has to be standing before the first render.
const page = await as(users.alice, "/")
let servePreview = () => {}
const previewReleased = new Promise<void>(resolve => {
servePreview = resolve
})
// Registered after as(), so it answers ahead of the harness's own dufflepud. Holding the one
// preview open makes the loading state a fact rather than a race.
await page.context().route(
requestUrl => requestUrl.pathname === "/link/preview",
async route => {
const {url: target}: {url: string} = route.request().postDataJSON()
if (target === announcement) {
await previewReleased
return route.fulfill({
json: {
title: "Flotilla ships v1",
description: "Everything new in this release.",
image: "https://images.test/preview.png",
},
})
}
// What dufflepud answers with for a url it could make nothing of.
return route.fulfill({json: {}})
},
)
await page.goto(roomPath(url, "general"))
const card = page.locator(`[data-event="${standalone.id}"]`)
const failed = page.locator(`[data-event="${broken.id}"]`)
const inline = page.locator(`[data-event="${sentence.id}"]`)
const previewImage = 'img[src="https://images.test/preview.png"]'
await expect(card.locator(".spinner")).toBeVisible()
await expect(failed.getByText(`Unable to load a preview for ${unreadable}`)).toBeVisible()
servePreview()
await expect(card.getByText("Flotilla ships v1")).toBeVisible()
await expect(card.getByText("Everything new in this release.")).toBeVisible()
await expect(card.locator(previewImage)).toBeVisible()
// The same url mid-sentence, now that the preview it would have shown is known to resolve: a
// compact link, and none of the card.
await expect(inline.getByRole("link", {name: "example.test/announcement"})).toBeVisible()
await expect(inline.getByText("Flotilla ships v1")).toHaveCount(0)
await expect(inline.locator(previewImage)).toHaveCount(0)
})
test("US-064 follow a link to another space", async ({seed, as}) => {
let reference!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
other.room("lounge", {name: "Other Lounge"})
other.join(user.alice, "lounge")
// The second space's url only exists once seeding has run, so the message is rendered late.
reference = space.event(
user.bob,
() => makeEvent(MESSAGE, {content: other.url, tags: [["h", "general"]]}),
at(30, MINUTE),
)
})
const {url} = scenario.space("space")
const other = scenario.space("other")
const page = await as(users.alice, roomPath(url, "general"))
const message = page.locator(`[data-event="${reference.id}"]`)
const spaceLink = message.getByRole("link", {name: "other.test"})
await expect(spaceLink).toBeVisible()
await expect(message.getByText(other.url)).toHaveCount(0)
await expect(spaceLink).toHaveAttribute("href", spacePath(other.url))
await expect(spaceLink).not.toHaveAttribute("target", "_blank")
await spaceLink.click()
await page.waitForURL(next => next.pathname.startsWith(spacePath(other.url)))
await expect(
page.locator(".secondary-nav").getByRole("link", {name: "Other Lounge"}),
).toBeVisible()
expect(page.context().pages()).toHaveLength(1)
})
test("US-065 see quoted and embedded content", async ({seed, as}) => {
let original!: Seeded
let reply!: Seeded
let thread!: Seeded
let threadQuote!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.join(user.carol, "general")
space.profile(user.alice, {name: "Alice Anders"})
space.profile(user.bob, {name: "Bob Boberton"})
space.profile(user.carol, {name: "Carol Chen"})
const point = space.message(user.alice, "general", "the original point", at(6, HOUR))
// Enough between the two that the original is well off screen when the room opens.
for (let i = 0; i < 30; i++) {
space.message(user.bob, "general", `filler message number ${i}`, at(300 - i * 5, MINUTE))
}
// Carol says something in the room too, so her profile is loaded by the time her name has to
// render on a card quoting her thread.
space.message(user.carol, "general", "posted a roadmap", at(140, MINUTE))
const topic = space.event(
user.carol,
() =>
space
.kind(Thread)
.writer()
.setRoom(space.url, "general")
.setTitle("Roadmap for Q3")
.setContent("Here is what we are planning for the next quarter.")
.renderTemplate(),
at(3, HOUR),
)
original = point
thread = topic
reply = space.reply(user.bob, point, "totally agree with this", at(20, MINUTE))
threadQuote = space.reply(user.bob, topic, "worth a read", at(15, MINUTE))
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general"))
const threadMessage = page.locator(`[data-event="${threadQuote.id}"]`)
// First, because it is the state a quote card is in until the quoted event arrives, and a
// thread is not part of the room's own feed — the card has to go and fetch it.
await expect(threadMessage.getByText("Loading event...")).toBeVisible()
const replyMessage = page.locator(`[data-event="${reply.id}"]`)
const quoteStrip = replyMessage.locator(".border-l-2")
await expect(quoteStrip).toContainText("the original point")
await expect(replyMessage).toContainText("totally agree with this")
const originalMessage = page.locator(`[data-event="${original.id}"]`)
await expect(originalMessage).toBeAttached()
await expect(originalMessage).not.toBeInViewport()
await quoteStrip.click()
await expect(originalMessage).toBeInViewport()
// The quoted thread's card, once it has loaded: the profile circle beside the message carries
// the same border classes, so the title is what says which of them is meant.
const quotedThread = threadMessage.locator(".border.border-solid").filter({
hasText: "Roadmap for Q3",
})
await expect(quotedThread).toBeVisible()
await expect(quotedThread).toContainText("Carol Chen")
await expect(quotedThread).toContainText("Here is what we are planning for the next quarter.")
await quotedThread.getByText("Roadmap for Q3").click()
await page.waitForURL(next => next.pathname === `${spacePath(url)}/threads/${thread.id}`)
await expect(page.getByRole("heading", {name: "Roadmap for Q3"})).toBeVisible()
})
test("US-066 see distinctive inline tokens", async ({seed, as}) => {
const mention = `nostr:${npubEncode(users.bob.pubkey)}`
const content = [
"Shipping #nostr today :partyparrot:",
`Run \`npm install\` first, then ping ${mention} or email team@example.test`,
"```",
"const answer = 42",
"```",
].join("\n")
let tokens!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.join(user.carol, "general")
space.profile(user.bob, {name: "Bob Boberton"})
space.profile(user.carol, {name: "Carol Chen"})
// Bob posts too, so his profile is loaded against this space before he is mentioned.
space.message(user.bob, "general", "morning all", at(50, MINUTE))
// The shortcode's image comes off the message's own emoji tag, which is what a client writes
// when someone picks a custom emoji.
tokens = space.event(
user.carol,
makeEvent(MESSAGE, {
content,
tags: [
["h", "general"],
["emoji", "partyparrot", "https://images.test/partyparrot.png"],
],
created_at: at(40, MINUTE),
}),
)
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general"))
const message = page.locator(`[data-event="${tokens.id}"]`)
await expect(message.getByText("#nostr", {exact: true})).toHaveClass(/link-content/)
await expect(message.getByAltText(":partyparrot:")).toBeVisible()
// An img contributes no text, so this is the "instead of the raw text" half of the story: with
// no emoji tag to resolve, the shortcode is rendered verbatim.
await expect(message.getByText(":partyparrot:")).toHaveCount(0)
const inlineCode = message.locator("code").filter({hasText: "npm install"})
const blockCode = message.locator("code").filter({hasText: "const answer = 42"})
await expect(inlineCode).toBeVisible()
await expect(inlineCode).not.toHaveClass(/(^|\s)block(\s|$)/)
await expect(blockCode).toBeVisible()
await expect(blockCode).toHaveClass(/(^|\s)block(\s|$)/)
await expect(message.getByRole("link", {name: "team@example.test"})).toHaveAttribute(
"href",
"mailto:team@example.test",
)
await message.getByRole("button", {name: "@Bob Boberton"}).click()
// Dialog wraps its content in a second element of its own, so the inner one is the modal.
const profile = page.locator(".dialog").last()
await expect(profile.getByText("Bob Boberton", {exact: true})).toBeVisible()
await expect(profile.getByRole("button", {name: "View Full Profile"})).toBeVisible()
})
test("US-067 copy a shared invoice or token", async ({seed, as}) => {
let invoice!: Seeded
let token!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
invoice = space.message(user.bob, "general", `lightning:${INVOICE}`, at(40, MINUTE))
token = space.message(user.bob, "general", CASHU, at(35, MINUTE))
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general"), {
context: {permissions: ["clipboard-read", "clipboard-write"]},
})
const toast = page.getByRole("alert")
const invoiceChip = page
.locator(`[data-event="${invoice.id}"]`)
.getByRole("button", {name: INVOICE.slice(0, 16)})
await expect(invoiceChip).toBeVisible()
await expect(page.getByText(INVOICE)).toHaveCount(0)
await invoiceChip.click()
await expect(toast).toContainText("Copied to clipboard!")
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(INVOICE)
// Both copies raise the same toast, so this one is dismissed rather than waited out — otherwise
// the second assertion would pass on the first toast.
await toast.getByRole("button").click()
await expect(toast).toHaveCount(0)
const tokenChip = page
.locator(`[data-event="${token.id}"]`)
.getByRole("button", {name: CASHU.slice(0, 16)})
await expect(tokenChip).toBeVisible()
await expect(page.getByText(CASHU)).toHaveCount(0)
await tokenChip.click()
await expect(toast).toContainText("Copied to clipboard!")
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(CASHU)
})

608
e2e/specs/delivery.spec.ts Normal file
View file

@ -0,0 +1,608 @@
import {HOUR} from "@welshman/lib"
import {LONG_FORM, MESSAGE} from "@welshman/util"
import type {TrustedEvent} from "@welshman/util"
import {ClientMessageType} from "@welshman/net"
import {Article, Comment, MessagingRelayList, Reaction, RelayList} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {expect, getTranscript, mockBlossom, roomPath, spacePath, test, users} from "../harness"
import type {SeededSpace, TestUser} from "../harness"
// The path the app builds for a conversation — see makeChatId in src/app/chats.ts.
const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
// makeSpacePath percent-encodes each segment it is given, and an address is full of colons.
const articlePath = (url: string, address: string) =>
`${spacePath(url)}/articles/${encodeURIComponent(address)}`
// Where an upload lands: the space's own origin is probed first and blossom is off in every
// tenant's toml, so what is left is VITE_DEFAULT_BLOSSOM_SERVERS.
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
// A one pixel gif. Gif rather than png because compressFileForUpload passes it through untouched
// rather than re-encoding it through a canvas.
const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64")
// Outbox routing resolves everything about a person through their relay list, so a person here is
// a membership, a profile and a kind-10002.
const seedPerson = (space: SeededSpace, user: TestUser, name: string) => {
space.profile(user, {name})
space.event(user, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url])
.renderTemplate(),
)
}
// The kind-10050 that says where someone's direct messages go. It is read lazily so that a list
// can name a relay whose url only exists once seeding has drained.
const enableDms = (space: SeededSpace, user: TestUser, getUrls: () => string[]) =>
space.event(user, () =>
space.kind(MessagingRelayList).writer().setUrls(getUrls()).renderTemplate(),
)
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
// The send button carries the shortcut it advertises, which differs by platform.
const sendButton = (page: Page) => page.locator("button[data-tip$='enter to send']")
const timeline = (page: Page) => page.locator(".room__content")
const message = (page: Page, text: string) => page.locator(".room__item").filter({hasText: text})
const bubble = (page: Page, text: string) => page.locator(".chat-bubble").filter({hasText: text})
// One toast at a time — src/app/toast.ts holds a single writable — so this is the toast.
const toast = (page: Page) => page.getByRole("alert")
// A tippy is appended to the layout's own target rather than beside its trigger, and it keeps its
// content mounted after it hides, so the visible card there is the popover that was just opened.
const detail = (page: Page) => page.locator(".tippy-target .card").filter({visible: true})
// EventActions renders zap, emoji and menu into one join, in that order, and every one of them is
// an icon with no accessible name.
const menuOf = (scope: Locator) => scope.locator(".join").getByRole("button").last()
// A comment, an article and a thread post are all the same feature card, named by their text.
const noteCard = (page: Page, text: string) =>
page.locator(".card.z-feature").filter({hasText: text})
// A modal is mounted alongside the page it covers, so a page's own "Write" and the composer's
// submit are both in the dom at once.
const modal = (page: Page, title: string) =>
page.locator("form").filter({has: page.getByRole("heading", {name: title})})
const editorOf = (scope: Locator) => scope.locator(".note-editor [contenteditable=true]")
// The composer stays disabled until every recipient's messaging relays have been read, so waiting
// on the send button is part of sending rather than a wait for a wait's sake.
const send = async (page: Page, content: string) => {
await expect(sendButton(page)).toBeEnabled()
await composer(page).click()
await composer(page).pressSequentially(content)
await composer(page).press("Enter")
}
// Every frame this page put on the wire carrying an event, with the relay it went to.
const published = (page: Page): {url: string; event: TrustedEvent}[] =>
getTranscript(page.context())
.filter(
({direction, message}) => direction === "toRelay" && message[0] === ClientMessageType.Event,
)
.map(({url, message}) => ({url, event: message[1]}))
const publishedEvents = (page: Page, kind: number) =>
published(page)
.filter(({event}) => event.kind === kind)
.map(({event}) => event)
// Which relays a given event was sent to, oldest first — one entry per attempt.
const publishedTo = (page: Page, id: string) =>
published(page)
.filter(({event}) => event.id === id)
.map(({url}) => url)
const writeArticle = async (page: Page, title: string, body: string) => {
await page.getByRole("button", {name: "Write"}).click()
const composer = modal(page, "Write an Article")
await composer.getByPlaceholder("What is this article about?").fill(title)
await editorOf(composer).pressSequentially(body)
await composer.getByRole("button", {name: "Publish Article"}).click()
}
const writeComment = async (page: Page, body: string) => {
await page.getByRole("button", {name: "Add a comment"}).click()
const composer = page.locator("form").filter({has: page.locator(".note-editor")})
await editorOf(composer).pressSequentially(body)
await composer.getByRole("button", {name: "Comment"}).click()
}
// The send delay is a user setting rather than a page's own state, so it is set the way a person
// sets it. Reading it back off a freshly loaded page is what says the client has taken it up: the
// slider is initialised from the settings store on mount.
const setSendDelay = async (page: Page, seconds: number) => {
const slider = page.locator("input[type=range]")
await page.goto("/settings/content")
await slider.fill(String(seconds * 1000))
await expect(page.getByText(`Delay sending chat messages for ${seconds} seconds.`)).toBeVisible()
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(toast(page)).toContainText("Your settings have been saved!")
await page.goto("/settings/content")
await expect(slider).toHaveValue(String(seconds * 1000))
}
const chooseFile = async (page: Page, button: Locator, name: string) => {
const chooser = page.waitForEvent("filechooser")
await button.click()
await (await chooser).setFiles({name, mimeType: "image/gif", buffer: GIF})
}
test("US-068 watch a delayed send, and cancel it", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
for (const person of [user.alice, user.bob]) {
seedPerson(space, person, person.name)
enableDms(space, person, () => [space.url])
}
space.message(user.bob, "general", "morning all", at(2, HOUR))
})
const {url} = scenario.space("space")
const path = roomPath(url, "general")
const alice = await as(users.alice, path)
const bob = await as(users.bob, path)
await expect(timeline(alice).getByText("morning all")).toBeVisible()
await expect(timeline(bob).getByText("morning all")).toBeVisible()
// The delay defaults to zero, so this one leaves as soon as it is written and says nothing.
await send(alice, "no delay here")
await expect(message(alice, "no delay here")).toBeVisible()
await expect(message(bob, "no delay here")).toBeVisible()
await expect(toast(alice)).toHaveCount(0)
await setSendDelay(alice, 5)
await alice.goto(path)
await expect(timeline(alice).getByText("morning all")).toBeVisible()
await send(alice, "second thoughts")
// The bubble is hers straight away; the delay is only about when it leaves.
await expect(message(alice, "second thoughts")).toBeVisible()
await expect(toast(alice)).toContainText("Sending...")
await expect(toast(alice).getByRole("button", {name: "Cancel"})).toBeVisible()
await expect(toast(alice)).toContainText("Message sent!")
await expect(message(bob, "second thoughts")).toBeVisible()
await expect(toast(alice)).toHaveCount(0)
await send(alice, "wrong room, sorry")
await expect(message(alice, "wrong room, sorry")).toBeVisible()
await toast(alice).getByRole("button", {name: "Cancel"}).click()
await expect(message(alice, "wrong room, sorry")).toHaveCount(0)
await expect(toast(alice)).toHaveCount(0)
// Sent after the cancelled one and delayed by as long, so bob having this means the window the
// cancelled one would have left in has been and gone.
await send(alice, "still here")
await expect(message(bob, "still here")).toBeVisible()
await expect(message(bob, "wrong room, sorry")).toHaveCount(0)
// The same cancel in a conversation.
await alice.goto(chatPath(users.bob.pubkey))
await bob.goto(chatPath(users.alice.pubkey))
await send(alice, "ignore this one")
await expect(bubble(alice, "ignore this one")).toBeVisible()
await toast(alice).getByRole("button", {name: "Cancel"}).click()
await send(alice, "actually, hi")
await expect(bubble(bob, "actually, hi")).toBeVisible()
await expect(bubble(bob, "ignore this one")).toHaveCount(0)
await expect(bubble(alice, "ignore this one")).toHaveCount(0)
})
test("US-069 see why a message failed to deliver", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
// Alice belongs to both relays; bob deliberately belongs only to the first.
other.join(user.alice)
for (const person of [user.alice, user.bob]) {
seedPerson(space, person, person.name)
}
enableDms(space, user.alice, () => [space.url])
// Bob's client says his messages go to both relays, but he is not a member of the second, so a
// wrap addressed to him is stored by one and refused by the other.
enableDms(space, user.bob, () => [space.url, other.url])
space.message(user.bob, "general", "morning all", at(2, HOUR))
})
const {url} = scenario.space("space")
// A room the relay never created, which is where a link to a room that has since been deleted
// lands. Everything else about the space still works, so only this one publish is refused.
const ghost = roomPath(url, "archive")
const alice = await as(users.alice, ghost)
const bob = await as(users.bob, ghost)
await send(alice, "anyone here?")
const failure = message(alice, "anyone here?").getByText("Failed to send!")
await expect(failure).toBeVisible()
// The text is still hers to see, and the publish is finished and was refused, so there is
// nothing left for bob to receive.
await expect(message(alice, "anyone here?")).toContainText("anyone here?")
await expect(message(bob, "anyone here?")).toHaveCount(0)
await failure.click()
await expect(detail(alice)).toContainText("Failed to send!")
await expect(detail(alice)).toContainText("space.test")
await expect(detail(alice)).toContainText("invalid: group not found.")
await expect(detail(alice).getByRole("button", {name: "Retry"})).toBeVisible()
// A conversation with someone whose two messaging relays do not both take his mail.
await alice.goto(chatPath(users.bob.pubkey))
await send(alice, "half a message")
const partial = alice.getByText("Failed to send!")
await expect(partial).toBeVisible()
await partial.click()
await expect(detail(alice)).toContainText("Partial delivery 1/2 relays")
await expect(detail(alice)).toContainText("space.test")
await expect(detail(alice)).toContainText("other.test")
await expect(detail(alice)).toContainText("restricted:")
await expect(detail(alice).locator(".text-success")).toHaveCount(1)
})
test("US-070 retry a failed relay", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
seedPerson(space, user.alice, "Alice Anderson")
seedPerson(space, user.bob, "Bob Barker")
})
const {url} = scenario.space("space")
const ghost = roomPath(url, "archive")
const alice = await as(users.alice, ghost)
// Opened now rather than once the room exists: seeding again below replaces the scenario, and a
// page opened from that one carries no room list — which is the only thing telling authPolicy it
// may answer a members-only relay's challenge, so bob would never get to read anything.
const bob = await as(users.bob, roomPath(url, "general"))
// A delay is what makes the retry's own "Sending..." a state rather than an instant, so this
// watches a retry the way the person who asked for it does.
await setSendDelay(alice, 5)
await alice.goto(ghost)
await send(alice, "is anyone here?")
const failure = message(alice, "is anyone here?").getByText("Failed to send!")
await expect(failure).toBeVisible()
await failure.click()
await detail(alice).getByRole("button", {name: "Retry"}).click()
await expect(toast(alice)).toContainText("Sending...")
// The room still does not exist, so this attempt is refused too: the toast goes without ever
// having said the message was sent, and the message is still marked failed.
await expect(toast(alice)).toHaveCount(0)
await expect(failure).toBeVisible()
// The admin creates the room the message was addressed to. Seeding again is the only way the
// relay changes its mind about something it has already refused.
await seed(({relay}) => {
relay("space").room("archive", {name: "Archive"})
})
await failure.click()
await detail(alice).getByRole("button", {name: "Retry"}).click()
await expect(toast(alice)).toContainText("Sending...")
await expect(toast(alice)).toContainText("Message sent!")
const [sent] = publishedEvents(alice, MESSAGE)
// One attempt and two retries, every one of them to the relay that failed and to nothing else.
expect(publishedTo(alice, sent.id)).toEqual([url, url, url])
await bob.goto(roomPath(url, "archive"))
await expect(message(bob, "is anyone here?")).toBeVisible()
})
test("US-071 content posts show delivery status in place", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
seedPerson(space, user.alice, "Alice Anderson")
seedPerson(space, user.bob, "Bob Barker")
// A second space in her list, so that opening it prompts her for nothing.
other.join(user.alice)
})
const {url} = scenario.space("space")
const quiet = scenario.space("other").url
// A space whose relay this browser will not open a socket to, which is what a post that sits
// unconfirmed looks like from the inside: it is sent, and nothing comes back.
const alice = await as(users.alice, `${spacePath(url)}/articles`, {
env: {VITE_BLOCKED_RELAYS: quiet},
})
await writeArticle(alice, "Signals in the Noise", "Everything worth hearing is quiet.")
const article = alice.getByRole("link", {name: /Written by/}).filter({hasText: "Signals"})
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
// The row's own actions first: a status this row does not have says nothing until the row it
// would sit in is on screen.
await expect(menuOf(article)).toBeVisible()
await expect(article.getByText("Sending...")).toHaveCount(0)
await expect(article.getByText("Failed to send!")).toHaveCount(0)
await expect(toast(alice)).toHaveCount(0)
await article.getByText("Signals in the Noise").click()
await writeComment(alice, "Worth saying twice.")
const comment = noteCard(alice, "Worth saying twice.")
await expect(menuOf(comment)).toBeVisible()
await expect(comment.getByText("Sending...")).toHaveCount(0)
await expect(comment.getByText("Failed to send!")).toHaveCount(0)
await expect(toast(alice)).toHaveCount(0)
// The same post into the space whose relay says nothing back.
await alice.goto(`${spacePath(quiet)}/articles`)
await writeArticle(alice, "Into the Void", "Nobody is listening.")
const stuck = alice.getByRole("link", {name: /Written by/}).filter({hasText: "Into the Void"})
await expect(stuck.getByText("Sending...")).toBeVisible()
await expect(stuck.getByRole("button", {name: "Cancel"})).toBeVisible()
// The composer closes itself once publishing gives up, and the row it left behind says why.
await expect(stuck.getByText("Failed to send!")).toBeVisible()
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
await stuck.getByText("Failed to send!").click()
await expect(detail(alice)).toContainText("Failed to send!")
await expect(detail(alice)).toContainText("other.test")
await expect(detail(alice)).toContainText("request timed out.")
await stuck.getByText("Into the Void").click()
await writeComment(alice, "A footnote nobody asked for.")
const footnote = noteCard(alice, "A footnote nobody asked for.")
await expect(footnote.getByText("Sending...")).toBeVisible()
await expect(footnote.getByRole("button", {name: "Cancel"})).toBeVisible()
await footnote.getByRole("button", {name: "Cancel"}).click()
await expect(noteCard(alice, "A footnote nobody asked for.")).toHaveCount(0)
})
test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
seedPerson(space, user.alice, "Alice Anderson")
seedPerson(space, user.bob, "Bob Barker")
other.join(user.alice)
const article = space.event(
user.alice,
() =>
space
.kind(Article)
.writer()
.setIdentifier("tending-the-garden")
.setTitle("Tending the Garden")
.setPublishedAt(at(4, HOUR))
.setContent("Gardens are worth the trouble.")
.renderTemplate(),
at(4, HOUR),
)
const comment = space.event(
user.alice,
() =>
space
.kind(Comment)
.writer()
.setRootFromEvent(article.event)
.setParentFromEvent(article.event)
.setContent("A note about the soil chapter.")
.renderTemplate(),
at(3, HOUR),
)
// Somebody else's reaction, so the summary the pill is meant to replace has something in it.
space.event(
user.bob,
() => space.kind(Reaction).writer().setEvent(comment.event).setContent("🎉").renderTemplate(),
at(2, HOUR),
)
})
const {url} = scenario.space("space")
const quiet = scenario.space("other").url
const alice = await as(users.alice, `${spacePath(quiet)}/articles`, {
env: {VITE_BLOCKED_RELAYS: quiet},
})
// A post whose relay never answered, deleted while it is still marked failed.
await writeArticle(alice, "Into the Void", "Nobody is listening.")
const stuck = alice.getByRole("link", {name: /Written by/}).filter({hasText: "Into the Void"})
await expect(stuck.getByText("Failed to send!")).toBeVisible()
// The composer closes itself once publishing gives up, and the row's menu is underneath it.
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
await menuOf(stuck).click()
await alice.getByRole("button", {name: "Delete Article"}).click()
await alice.getByRole("button", {name: "Confirm"}).click()
await expect(stuck.getByText("Deleted", {exact: true})).toBeVisible()
await expect(stuck.getByText("Failed to send!")).toHaveCount(0)
await alice.goto(articlePath(url, `${LONG_FORM}:${users.alice.pubkey}:tending-the-garden`))
const comment = noteCard(alice, "A note about the soil chapter.")
const article = noteCard(alice, "Gardens are worth the trouble.")
await expect(comment).toBeVisible()
await expect(comment.getByRole("button", {name: /🎉/})).toBeVisible()
await menuOf(comment).click()
await alice.getByRole("button", {name: "Delete Comment"}).click()
await alice.getByRole("button", {name: "Confirm"}).click()
await expect(comment.getByText("Deleted", {exact: true})).toBeVisible()
await menuOf(article).click()
await alice.getByRole("button", {name: "Delete Article"}).click()
await alice.getByRole("button", {name: "Confirm"}).click()
await expect(article.getByText("Deleted", {exact: true})).toBeVisible()
// The pill stands in place of what the row offered before it, rather than beside it.
await expect(comment.getByRole("button", {name: /🎉/})).toHaveCount(0)
await expect(comment.locator(".join")).toHaveCount(0)
await expect(article.locator(".join")).toHaveCount(0)
})
test("US-073 a multi-part message reports one status", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
for (const person of [user.alice, user.bob, user.carol]) {
space.join(person, "general")
seedPerson(space, person, person.name)
}
enableDms(space, user.alice, () => [space.url])
enableDms(space, user.bob, () => [space.url])
// Carol's client names a relay she does not belong to, so every part of a message to her is
// stored by one of her two relays and refused by the other.
enableDms(space, user.carol, () => [space.url, other.url])
})
const alice = await as(users.alice, chatPath(users.bob.pubkey))
const bob = await as(users.bob, chatPath(users.alice.pubkey))
await mockBlossom(alice.context(), {server: BLOSSOM_ORIGIN})
await mockBlossom(bob.context(), {server: BLOSSOM_ORIGIN})
await expect(sendButton(alice)).toBeEnabled()
await composer(alice).click()
await composer(alice).pressSequentially("here is the harbour")
await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), "harbour.gif")
// The editor names the file the moment it is attached, so the name alone does not mean the
// upload is done — and a submit while it is still running is dropped on the floor.
await expect(composer(alice)).toContainText("harbour.gif")
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
await expect(sendButton(alice)).toBeEnabled()
await composer(alice).press("Enter")
// Two bubbles, one status for the pair of them.
await expect(toast(alice)).toHaveCount(1)
await expect(toast(alice)).toContainText("Sending...")
await expect(alice.locator(".chat-bubble")).toHaveCount(2)
await expect(bubble(alice, "here is the harbour")).toBeVisible()
// Which only becomes a success once both parts have landed everywhere they were sent.
await expect(toast(alice)).toContainText("Message sent!")
await expect(toast(alice)).toHaveCount(1)
await expect(bob.locator(".chat-bubble")).toHaveCount(2)
await expect(toast(alice)).toHaveCount(0)
// The same message to someone one of whose relays refuses it: each part says so for itself.
await alice.goto(chatPath(users.carol.pubkey))
await expect(sendButton(alice)).toBeEnabled()
await composer(alice).click()
await composer(alice).pressSequentially("and one for you")
await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), "harbour.gif")
await expect(composer(alice)).toContainText("harbour.gif")
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
await expect(sendButton(alice)).toBeEnabled()
await composer(alice).press("Enter")
await expect(alice.locator(".chat-bubble")).toHaveCount(2)
await expect(alice.getByText("Failed to send!")).toHaveCount(2)
})

613
e2e/specs/dms.spec.ts Normal file
View file

@ -0,0 +1,613 @@
import {npubEncode} from "nostr-tools/nip19"
import type {Locator, Page} from "@playwright/test"
import {DAY, HOUR, MINUTE} from "@welshman/lib"
import {MessagingRelayList, RelayList} from "@welshman/domain"
import {expect, makeTestUser, test, users} from "../harness"
import type {SeededRumor, SeededSpace, TestUser} from "../harness"
// The path the app builds for a conversation: the other participants' pubkeys, sorted and joined
// with commas — see makeChatId in src/app/chats.ts.
const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
const profilePath = (pubkey: string) => `/people/${npubEncode(pubkey)}`
// A path as a pattern, for a url that carries a modal's hash alongside it.
const pathPattern = (path: string) => new RegExp(path.replace(/[.?+*()[\]]/g, "\\$&"))
// Everything about a person is resolved through their relay list — their profile, their messaging
// relays, the wraps addressed to them — and a scenario with no fallbacks resolves an author with
// no kind-10002 to no relays at all. So a person here is a membership, a profile and a relay list.
// Membership is also what lets a gift wrap addressed to them be stored: zooid authorizes a
// kind-1059 by the member named in its p tag.
const seedPerson = (space: SeededSpace, user: TestUser, name: string) => {
space.join(user)
space.profile(user, {name})
space.event(user, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url])
.renderTemplate(),
)
}
// The kind-10050 ChatEnable publishes when someone accepts "Enable direct messaging?". Having one
// is what makes a person reachable, so a story about messaging being off is a person without one.
const enableDms = (space: SeededSpace, user: TestUser) =>
space.event(user, () =>
space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(),
)
const chatList = (page: Page) => page.locator(".secondary-nav .overflow-auto")
// One ChatItem is one button; nothing inside it is one.
const chatItems = (page: Page) => chatList(page).locator("button")
const chatFilter = (page: Page) => page.locator(".secondary-nav input[type='text']")
// ChatItem's unread mark is a bare dot with no text of its own.
const unreadDots = (scope: Locator) => scope.locator(".rounded-full.bg-primary")
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
// A modal's body is the only scroll container carrying its title.
const modalBody = (page: Page, title: string) =>
page
.locator(".scroll-container")
.filter({has: page.getByRole("heading", {name: title, exact: true})})
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
// The send button is named by the shortcut it advertises, which differs by platform.
const sendButton = (page: Page) => page.locator('.room__compose [data-tip$="enter to send"]')
// Both things the composer puts above itself — the message being replied to and the editing
// indicator — are the same bordered strip.
const composePreview = (page: Page) => page.locator(".room__compose .border-l-2")
const bubble = (page: Page, text: string) => page.locator(".chat-bubble").filter({hasText: text})
const message = (page: Page, id: string) => page.locator(`[data-event="${id}"]`)
const enablePrompt = (page: Page) => page.getByRole("heading", {name: "Enable direct messaging?"})
// The composer stays disabled until every recipient's messaging relays have been read, so waiting
// on it is part of sending rather than a wait for a wait's sake.
const send = async (page: Page, content: string) => {
await expect(sendButton(page)).toBeEnabled()
await composer(page).pressSequentially(content)
await composer(page).press("Enter")
}
// A suggestion carries the pubkey it selects as its label, so the name is what gets typed and the
// pubkey is what identifies the row that comes back.
const startChat = async (page: Page, people: {term: string; user: TestUser}[]) => {
await page.getByRole("button", {name: "Start New Chat"}).click()
await expect(page.getByRole("heading", {name: "Start a Chat"})).toBeVisible()
for (const {term, user} of people) {
const suggestion = page.locator(`.tiptap-suggestions__item[aria-label="${user.pubkey}"]`)
// Typed a keystroke at a time rather than filled: the suggestion list is rebuilt off the term
// changing, so a term that arrives all at once is matched against whatever the profile search
// held at that instant and never again.
await page.getByPlaceholder("Search for profiles...").pressSequentially(term, {delay: 150})
await expect(suggestion).toContainText(term)
await suggestion.click()
}
await page.getByRole("button", {name: "Create Chat"}).click()
}
// A message's actions live behind a hover popover on a pointer device and behind a modal of named
// buttons on a touch one, so a spec that wants to name them opens a touch context.
const openMessageMenu = (page: Page, id: string) =>
message(page, id).locator(".chat-bubble").click()
// A day divider and a chat item's stamp, formatted by the browser rather than by node, so the
// locale and the timezone are the ones the app rendered with. The options mirror dateFormatter and
// dateTimeFormatter in @welshman/lib.
const dayLabel = (page: Page, seconds: number) =>
page.evaluate(
ts =>
new Intl.DateTimeFormat(undefined, {year: "numeric", month: "long", day: "numeric"}).format(
new Date(ts * 1000),
),
seconds,
)
const stampLabel = (page: Page, seconds: number) =>
page.evaluate(
ts =>
new Intl.DateTimeFormat(undefined, {dateStyle: "short", timeStyle: "short"}).format(
new Date(ts * 1000),
),
seconds,
)
const topOf = async (locator: Locator) => {
const box = await locator.boundingBox()
expect(box).toBeTruthy()
return box!.y
}
test("US-029 start a one-on-one chat", async ({seed, as}) => {
await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
enableDms(space, user.alice)
enableDms(space, user.bob)
// A conversation the three of them are already in. It puts bob's profile in her client before
// she goes looking for him, without being the one-on-one she is about to start.
space.dm(user.carol, [user.alice, user.bob], "welcome aboard, both of you", at(3, HOUR))
})
const page = await as(users.alice, "/chat")
// A group conversation's item in the list names only the first of its participants, so his name
// rather than his npub in its header is the client saying it has his profile, which is what the
// search she is about to type into is built from.
await chatItems(page).filter({hasText: "welcome aboard"}).click()
await expect(pageBar(page)).toContainText("Bob Barnacle")
await startChat(page, [{term: "Barnacle", user: users.bob}])
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
await expect(composer(page)).toBeVisible()
await expect(sendButton(page)).toBeEnabled()
await send(page, "hi bob")
await expect(bubble(page, "hi bob")).toBeVisible()
// The conversation she just started is the one at the top of her list, under his name
await expect(chatItems(page).first()).toContainText("Bob Barnacle")
await expect(chatItems(page).first()).toContainText("hi bob")
// His profile leads back into the same conversation
await pageBar(page).getByRole("button", {name: "Bob Barnacle"}).click()
await page.getByRole("button", {name: "View Full Profile"}).click()
await expect(page).toHaveURL(pathPattern(profilePath(users.bob.pubkey)))
await page.getByRole("button", {name: "Message", exact: true}).click()
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
await expect(bubble(page, "hi bob")).toBeVisible()
// Her own profile offers no way to message herself
await page.goto(profilePath(users.alice.pubkey))
await expect(page.getByRole("button", {name: "Edit profile"})).toBeVisible()
await expect(page.getByRole("button", {name: "Message", exact: true})).toHaveCount(0)
})
test("US-030 start a group chat", async ({seed, as}) => {
await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
enableDms(space, user.alice)
enableDms(space, user.bob)
enableDms(space, user.carol)
// One conversation with each of them, so both profiles are in her client before she searches.
space.dm(user.bob, [user.alice], "just us two", at(4, HOUR))
space.dm(user.carol, [user.alice], "hello from carol", at(3, HOUR))
})
const page = await as(users.alice, "/chat")
// Their names rather than their npubs is the client saying it has both profiles, which is what
// the search she is about to type into is built from.
await expect(chatItems(page).filter({hasText: "just us two"})).toContainText("Bob Barnacle")
await expect(chatItems(page).filter({hasText: "hello from carol"})).toContainText("Carol Cutter")
await startChat(page, [
{term: "Barnacle", user: users.bob},
{term: "Cutter", user: users.carol},
])
// One conversation holding both of them, rather than one apiece
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey, users.carol.pubkey)))
await expect(sendButton(page)).toBeEnabled()
// Labeled with both of them rather than with one
await expect(pageBar(page)).toContainText("Bob Barnacle")
await expect(pageBar(page)).toContainText("Carol Cutter")
await pageBar(page)
.getByRole("button", {name: /Bob Barnacle/})
.click()
const members = modalBody(page, "People in this conversation")
await expect(members.getByText("Bob Barnacle")).toBeVisible()
await expect(members.getByText("Carol Cutter")).toBeVisible()
await members.getByRole("button", {name: "Carol Cutter"}).click()
await expect(members).toHaveCount(0)
await expect(page.getByRole("button", {name: "View Full Profile"})).toBeVisible()
await page.getByRole("button", {name: "View Full Profile"}).click()
await expect(page).toHaveURL(pathPattern(profilePath(users.carol.pubkey)))
})
test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
// Neither of them has ever published a kind-10050, which is what messaging being off is.
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
})
const alice = await as(users.alice, profilePath(users.bob.pubkey))
await alice.getByRole("button", {name: "Message", exact: true}).click()
// The prompt stands where the conversation she asked for would have been
await expect(enablePrompt(alice)).toBeVisible()
await expect(alice).not.toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
await expect(composer(alice)).toHaveCount(0)
await alice.getByRole("button", {name: "Enable direct messaging"}).click()
// Turning it on carries her on into the conversation she started
await expect(enablePrompt(alice)).toHaveCount(0)
await expect(alice).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
// Bob has never enabled it, so his end of the conversation is a dead end
const banner = alice.locator(".card").filter({hasText: "Direct messages are not enabled"})
await expect(banner).toContainText("Bob Barnacle")
await expect(sendButton(alice)).toBeDisabled()
const bob = await as(users.bob, "/")
await bob.locator('.primary-nav [data-tip="Messages"]').click()
await expect(enablePrompt(bob)).toBeVisible()
await bob.getByRole("button", {name: "Enable direct messaging"}).click()
// His list is the app's evidence that his kind-10050 has been published
await expect(bob).toHaveURL(/\/chat$/)
await expect(bob.getByRole("button", {name: "Start New Chat"})).toBeVisible()
// She reopens the conversation the way she opened it the first time
await pageBar(alice).getByRole("button", {name: "Bob Barnacle"}).click()
await alice.getByRole("button", {name: "View Full Profile"}).click()
await expect(alice).toHaveURL(pathPattern(profilePath(users.bob.pubkey)))
await alice.getByRole("button", {name: "Message", exact: true}).click()
// The prompt is behind her for good, and his end of it works now
await expect(alice).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
await expect(enablePrompt(alice)).toHaveCount(0)
await expect(sendButton(alice)).toBeEnabled()
await expect(banner).toHaveCount(0)
await send(alice, "finally")
await expect(bubble(alice, "finally")).toBeVisible()
await expect(chatItems(bob).filter({hasText: "finally"})).toBeVisible()
})
test("US-032 exchange messages in a conversation", async ({seed, as}) => {
let yesterday!: SeededRumor
let firstToday!: SeededRumor
let secondToday!: SeededRumor
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
enableDms(space, user.alice)
enableDms(space, user.bob)
yesterday = space.dm(user.bob, [user.alice], "sent this yesterday", at(30, HOUR))
// A minute apart, which is what puts these two in the same run of messages.
firstToday = space.dm(user.bob, [user.alice], "morning alice", at(50, MINUTE))
secondToday = space.dm(user.bob, [user.alice], "still around?", at(49, MINUTE))
})
const alice = await as(users.alice, chatPath(users.bob.pubkey))
const bob = await as(users.bob, chatPath(users.alice.pubkey))
await expect(message(alice, yesterday.id)).toBeVisible()
await expect(message(alice, firstToday.id)).toBeVisible()
await expect(message(alice, secondToday.id)).toBeVisible()
// Chronological: each message is rendered below the one before it
expect(await topOf(message(alice, yesterday.id))).toBeLessThan(
await topOf(message(alice, firstToday.id)),
)
expect(await topOf(message(alice, firstToday.id))).toBeLessThan(
await topOf(message(alice, secondToday.id)),
)
// A divider per day
await expect(alice.getByText(await dayLabel(alice, scenario.at(30, HOUR)))).toBeVisible()
await expect(alice.getByText(await dayLabel(alice, scenario.at(50, MINUTE)))).toBeVisible()
// His second message in a row carries neither his name nor his avatar
await expect(bubble(alice, "morning alice")).toContainText("Bob Barnacle")
await expect(bubble(alice, "morning alice").locator(".rounded-full")).toHaveCount(1)
await expect(bubble(alice, "still around?")).not.toContainText("Bob Barnacle")
await expect(bubble(alice, "still around?").locator(".rounded-full")).toHaveCount(0)
await send(alice, "just got here")
await expect(bubble(alice, "just got here")).toBeVisible()
await expect(bubble(alice, "just got here")).toHaveClass(/chat-bubble--user/)
await expect(composer(alice)).toHaveText("")
// ...and it reaches the conversation he already has open, attributed to her
await expect(bubble(bob, "just got here")).toBeVisible()
await expect(bubble(bob, "just got here")).toContainText("Alice Anchor")
})
test("US-033 browse and search your conversations", async ({seed, as}) => {
const dave = makeTestUser("dave")
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
seedPerson(space, dave, "Dave Davits")
enableDms(space, user.alice)
space.dm(dave, [user.alice], "see you at the meetup", at(2, DAY))
space.dm(user.carol, [user.alice], "thanks for the link", at(3, HOUR))
space.dm(user.bob, [user.alice], "lunch tomorrow?", at(30, MINUTE))
})
const page = await as(users.alice, "/chat")
await expect(chatItems(page)).toHaveCount(3)
const bobStamp = await stampLabel(page, scenario.at(30, MINUTE))
const carolStamp = await stampLabel(page, scenario.at(3, HOUR))
const daveStamp = await stampLabel(page, scenario.at(2, DAY))
// Most recently active first, each with a name, a preview and a timestamp
await expect(chatItems(page).nth(0)).toContainText("Bob Barnacle")
await expect(chatItems(page).nth(0)).toContainText("lunch tomorrow?")
await expect(chatItems(page).nth(0)).toContainText(bobStamp)
await expect(chatItems(page).nth(1)).toContainText("Carol Cutter")
await expect(chatItems(page).nth(1)).toContainText("thanks for the link")
await expect(chatItems(page).nth(1)).toContainText(carolStamp)
await expect(chatItems(page).nth(2)).toContainText("Dave Davits")
await expect(chatItems(page).nth(2)).toContainText("see you at the meetup")
await expect(chatItems(page).nth(2)).toContainText(daveStamp)
await chatFilter(page).fill("Bob Barnacle")
await expect(chatItems(page)).toHaveCount(1)
await expect(chatItems(page).nth(0)).toContainText("Bob Barnacle")
await chatFilter(page).fill("")
await expect(chatItems(page)).toHaveCount(3)
})
test("US-034 track and clear unread conversations", async ({seed, as}) => {
const dave = makeTestUser("dave")
await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
seedPerson(space, dave, "Dave Davits")
enableDms(space, user.alice)
enableDms(space, user.bob)
space.dm(user.carol, [user.alice], "did you see this?", at(3, HOUR))
space.dm(dave, [user.alice], "ping from dave", at(2, HOUR))
})
const alice = await as(users.alice, "/chat")
const bobChat = chatItems(alice).filter({hasText: "Bob Barnacle"})
const carolChat = chatItems(alice).filter({hasText: "Carol Cutter"})
const daveChat = chatItems(alice).filter({hasText: "Dave Davits"})
await expect(chatItems(alice)).toHaveCount(2)
await expect(unreadDots(carolChat)).toBeVisible()
await expect(unreadDots(daveChat)).toBeVisible()
// Bob writes while she is looking at the list rather than at his conversation
const bob = await as(users.bob, chatPath(users.alice.pubkey))
await send(bob, "are you free later?")
await expect(bobChat).toBeVisible()
await expect(unreadDots(bobChat)).toBeVisible()
// Reading it and moving on clears it
await bobChat.click()
await expect(bubble(alice, "are you free later?")).toBeVisible()
await carolChat.click()
await expect(bubble(alice, "did you see this?")).toBeVisible()
await expect(unreadDots(bobChat)).toHaveCount(0)
// Mark all read clears what is left of them in one go
await alice.locator(".secondary-nav__header").getByRole("button").click()
await alice.getByRole("button", {name: "Mark all read"}).click()
await expect(unreadDots(chatList(alice))).toHaveCount(0)
// ...and they are still clear when she comes back
await alice.locator('.primary-nav [data-tip="Settings"]').click()
await expect(alice).toHaveURL(pathPattern("/settings/profile"))
await alice.locator('.primary-nav [data-tip="Messages"]').click()
await expect(chatItems(alice)).toHaveCount(3)
await expect(unreadDots(chatList(alice))).toHaveCount(0)
})
test("US-035 reply to, edit, and react to a direct message", async ({seed, as}) => {
let his!: SeededRumor
let hers!: SeededRumor
await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
enableDms(space, user.alice)
enableDms(space, user.bob)
his = space.dm(user.bob, [user.alice], "did you see the thing?", at(20, MINUTE))
hers = space.dm(user.alice, [user.bob], "first attempt", at(10, MINUTE))
})
// A touch context, which is what puts a message's actions behind named buttons rather than
// behind a row of icons in a hover popover.
const alice = await as(users.alice, chatPath(users.bob.pubkey), {context: {hasTouch: true}})
const bob = await as(users.bob, chatPath(users.alice.pubkey))
await expect(message(alice, his.id)).toBeVisible()
await expect(message(alice, hers.id)).toBeVisible()
// Reply: the preview appears above the composer, and closing it sends nothing
await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "Send Reply"}).click()
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
await expect(composePreview(alice)).toContainText("did you see the thing?")
await composePreview(alice).getByRole("button").last().click()
await expect(composePreview(alice)).toHaveCount(0)
await expect(alice.locator(".chat-bubble")).toHaveCount(2)
await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "Send Reply"}).click()
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
await send(alice, "yes I did")
// The reply that gets sent quotes what it answers
await expect(bubble(alice, "yes I did")).toContainText("did you see the thing?")
await expect(bubble(bob, "yes I did")).toContainText("did you see the thing?")
// Edit: her own message is replaced in place for both of them rather than duplicated
await openMessageMenu(alice, hers.id)
await expect(alice.getByRole("button", {name: "Edit Message"})).toBeVisible()
await alice.getByRole("button", {name: "Edit Message"}).click()
await expect(composePreview(alice)).toContainText("Editing message")
await expect(composer(alice)).toContainText("first attempt")
await composer(alice).press("ControlOrMeta+a")
await send(alice, "second attempt")
await expect(bubble(alice, "second attempt")).toBeVisible()
await expect(alice.locator(".chat-bubble").filter({hasText: "first attempt"})).toHaveCount(0)
await expect(bubble(bob, "second attempt")).toBeVisible()
await expect(bob.locator(".chat-bubble").filter({hasText: "first attempt"})).toHaveCount(0)
// His, hers and her reply — the edit replaced one rather than adding a fourth
await expect(alice.locator(".chat-bubble")).toHaveCount(3)
await expect(bob.locator(".chat-bubble")).toHaveCount(3)
// React, then take it back, on both sides of the conversation
await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "Send Reaction"}).click()
const picker = alice.locator("emoji-picker").filter({visible: true})
// A result's label is the emoji, its annotation and every shortcode joined together, so the
// annotation is what gets matched rather than the whole of it.
await picker.locator("input.search").fill("party popper")
await picker.getByRole("option", {name: /party popper/}).click()
await expect(message(alice, his.id)).toContainText("🎉")
await expect(message(bob, his.id)).toContainText("🎉")
await message(alice, his.id).getByRole("button", {name: "🎉"}).click()
await expect(message(alice, his.id)).not.toContainText("🎉")
await expect(message(bob, his.id)).not.toContainText("🎉")
// Up in an empty composer picks up the last thing she said, which is the reply
await expect(composer(alice)).toHaveText("")
await composer(alice).press("ArrowUp")
await expect(composePreview(alice)).toContainText("Editing message")
await expect(composer(alice)).toContainText("yes I did")
})
test("US-036 receive a new conversation live", async ({seed, as}) => {
await seed(({relay, user, at}) => {
const space = relay("space")
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
enableDms(space, user.alice)
enableDms(space, user.bob)
// A conversation each of them already has. A list with something in it is how each page says
// its own end of the sync is up, before the one that has to arrive live is sent.
space.dm(user.carol, [user.alice], "see you monday", at(4, HOUR))
space.dm(user.carol, [user.bob], "you too", at(4, HOUR))
})
const alice = await as(users.alice, "/chat")
await expect(chatItems(alice).filter({hasText: "see you monday"})).toBeVisible()
const bob = await as(users.bob, "/chat")
await expect(chatItems(bob).filter({hasText: "you too"})).toBeVisible()
// He has never talked to her, so he identifies her by the code she gave him
await bob.getByRole("button", {name: "Start New Chat"}).click()
await bob.getByPlaceholder("Search for profiles...").fill(npubEncode(users.alice.pubkey))
await bob.getByRole("button", {name: "Create Chat"}).click()
await expect(bob).toHaveURL(pathPattern(chatPath(users.alice.pubkey)))
await send(bob, "starting a chat with you")
// Her list picks the conversation up on its own
const fromBob = chatItems(alice).filter({hasText: "Bob Barnacle"})
await expect(fromBob).toBeVisible()
await fromBob.click()
await expect(bubble(alice, "starting a chat with you")).toBeVisible()
})

View file

@ -0,0 +1,420 @@
import {neventEncode, npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {MessagingRelayList, RelayList} 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}
// 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.
const pattern = (literal: string) => new RegExp(literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
// The unread indicator, which every surface renders the same way: a small primary-colored dot in
// the corner of the thing it belongs to. RelaySummary's "you're a member" check is the same shape
// at h-5 w-5, so the size is part of what says which one this is.
const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary")
// The bell SpaceMenuRoomItem hangs off a room whose notification setting differs from its space's.
// An icon is a css mask built from a data url, so which bell it is can't be read out of the class
// list — but it only renders on a difference, and while the space itself is still notifying the
// only difference a room can have is a mute.
const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50")
// The bell SpaceMenuHeader puts beside the space's name once the space itself is muted. The only
// other absolutely-positioned dot in that button is the admin action-items one, which is opacity-0.
const mutedSpaceBell = (header: Locator) => header.locator("div.opacity-50")
// FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row, with the
// label at one end and the control at the other.
const settingRow = (page: Page, label: string) =>
page.locator("div.items-center.justify-between").filter({hasText: label})
// PrimaryNavItemSpace carries the relay's name as a tooltip rather than as an accessible name — its
// icon is a masked svg with no alt text — and it has an onclick, so PrimaryNavItem renders it as a
// button rather than a link. The name comes from nip-11; until that document lands the tooltip is
// the relay's host instead, and each tenant's toml names it after itself, so both start the same.
const spaceNavItem = (page: Page, name: string) =>
page.locator(`.primary-nav [data-tip^="${name}"]`)
// The space menu's header, the one button in the secondary nav carrying the relay's address.
const spaceMenu = (page: Page, url: string) =>
page.locator(".secondary-nav").getByRole("button", {name: pattern(displayRelayUrl(url))})
const roomLink = (page: Page, name: string) =>
page.locator(".space-menu__scroll").getByRole("link", {name})
// The room's page bar carries a back arrow (display:none at this viewport), a search button and the
// detail button, in that order.
const openRoomDetail = (page: Page) =>
page.locator('[data-component="PageBar"]').getByRole("button").last().click()
// `.dialog` is on both the backdrop wrapper and the panel inside it, so the last match is the panel.
const dialog = (page: Page, title: string) =>
page
.locator(".dialog")
.filter({has: page.getByRole("heading", {name: title, exact: true})})
.last()
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
const message = (page: Page, text: string) => page.locator(".room__item").filter({hasText: text})
const send = async (page: Page, content: string) => {
await composer(page).pressSequentially(content)
await composer(page).press("Enter")
await expect(message(page, content)).toBeVisible()
}
// The page bar names the room, so waiting for it is what says the composer below now belongs to
// the room that was just opened rather than to the one being torn down.
const postTo = async (page: Page, room: string, content: string) => {
await roomLink(page, room).click()
await expect(page.locator('[data-component="PageBar"]')).toContainText(room)
await send(page, content)
}
// Outbox routing resolves everything about a person through their relay list: settings are
// published to the relays it names, and a gift wrap only reaches somebody who has said where their
// messages go.
const seedRelays = (space: SeededSpace, user: TestUser) => {
space.event(user, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url])
.renderTemplate(),
)
space.event(user, () =>
space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(),
)
}
test("US-103 see and clear unread indicators", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.room("random", {name: "Random"})
space.join(user.alice, "general", "random")
space.join(user.bob, "general", "random")
// History bob wrote himself, so the space has something in it while nothing in it is unread
// for him — his own messages never raise an indicator.
space.message(user.bob, "general", "anyone around?", at(2, HOUR))
})
const space = scenario.space("space")
const bob = await as(users.bob, "/spaces")
const alice = await as(users.alice, roomPath(space.url, "general"))
const spaceRow = bob.getByRole("listitem").filter({hasText: space.url})
const navItem = spaceNavItem(bob, space.name)
await expect(spaceRow).toBeVisible()
await expect(navItem).toBeVisible()
await expect(unreadDot(spaceRow)).toHaveCount(0)
await expect(unreadDot(navItem)).toHaveCount(0)
await send(alice, "the server is on fire")
// Bob is sitting on the space list the whole time, so both dots arrive without a navigation
await expect(unreadDot(spaceRow)).toBeVisible()
await expect(unreadDot(navItem)).toBeVisible()
// Inside the space, the dot points at the room the message landed in. A space's room list only
// exists in its own menu, so this is the one indicator the list above can't show.
await spaceRow.click()
const general = roomLink(bob, "General")
const random = roomLink(bob, "Random")
// Both rooms are on screen first, so a room with no dot is a room raising none rather than a
// row that never rendered
await expect(random).toBeVisible()
await expect(unreadDot(general)).toBeVisible()
await expect(unreadDot(random)).toHaveCount(0)
await general.click()
await expect(message(bob, "the server is on fire")).toBeVisible()
// Reading it clears the room's dot...
await bob.getByRole("link", {name: "Space Details"}).click()
await expect(general).toBeVisible()
await expect(unreadDot(general)).toHaveCount(0)
// ...and it stays cleared back on the space list he started from
await bob.locator('.primary-nav a[href="/spaces"]').click()
await expect(spaceRow).toBeVisible()
await expect(unreadDot(spaceRow)).toHaveCount(0)
await expect(unreadDot(navItem)).toHaveCount(0)
})
test("US-104 mute a room or a whole space", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.room("random", {name: "Random"})
space.join(user.alice, "general", "random")
space.join(user.bob, "general", "random")
seedRelays(space, user.alice)
})
const space = scenario.space("space")
// Silencing a whole space is only offered once push notifications are on, so that is where alice
// has to start.
const alice = await as(users.alice, "/settings/alerts", {
context: {permissions: ["notifications"]},
})
await settingRow(alice, "Enable push notifications").getByRole("checkbox").check()
await alice.getByRole("button", {name: "Save Changes"}).click()
await expect(alice.getByRole("alert")).toContainText("Your settings have been saved!")
await spaceNavItem(alice, space.name).click()
const general = roomLink(alice, "General")
const random = roomLink(alice, "Random")
await general.click()
await expect(alice).toHaveURL(pattern(roomPath(space.url, "general")))
// Silence this one room from its detail panel
await openRoomDetail(alice)
const roomNotifications = settingRow(alice, "Notifications").getByRole("checkbox")
await expect(roomNotifications).toBeChecked()
await roomNotifications.uncheck()
await alice.getByRole("button", {name: "Go back"}).click()
await expect(random).toBeVisible()
await expect(mutedRoomBell(general)).toBeVisible()
await expect(mutedRoomBell(random)).toHaveCount(0)
// Step out of both rooms, so either one is free to raise a dot
await alice.getByRole("link", {name: "Space Details"}).click()
const bob = await as(users.bob, roomPath(space.url, "general"))
await send(bob, "deploy is broken")
await postTo(bob, "Random", "lunch?")
// The sibling room's message raises a dot, the muted room's does not
await expect(unreadDot(random)).toBeVisible()
await expect(unreadDot(general)).toHaveCount(0)
// The muted room's message did arrive, it just raised nothing
await general.click()
await expect(message(alice, "deploy is broken")).toBeVisible()
// Turning the room back on restores its indicator for what comes next
await openRoomDetail(alice)
await roomNotifications.check()
await alice.getByRole("button", {name: "Go back"}).click()
await alice.getByRole("link", {name: "Space Details"}).click()
await expect(general).toBeVisible()
await expect(mutedRoomBell(general)).toHaveCount(0)
await postTo(bob, "General", "and now the build too")
// Both rooms are now showing a dot, so silencing the space is what takes them down
await expect(unreadDot(general)).toBeVisible()
await expect(unreadDot(random)).toBeVisible()
const menu = spaceMenu(alice, space.url)
await menu.click()
await alice.getByRole("button", {name: "Turn off notifications"}).click()
await expect(mutedSpaceBell(menu)).toBeVisible()
await expect(unreadDot(general)).toHaveCount(0)
await expect(unreadDot(random)).toHaveCount(0)
await postTo(bob, "General", "still broken")
await postTo(bob, "Random", "still hungry")
await expect(unreadDot(general)).toHaveCount(0)
await expect(unreadDot(random)).toHaveCount(0)
// Reopening the menu shows the label the mute flipped, and turning it back on brings the
// indicators back — which is also what proves those messages reached alice at all
await menu.click()
const turnOn = alice.getByRole("button", {name: "Turn on notifications"})
await expect(turnOn).toBeVisible()
await turnOn.click()
await expect(mutedSpaceBell(menu)).toHaveCount(0)
await expect(unreadDot(general)).toBeVisible()
await expect(unreadDot(random)).toBeVisible()
})
test("US-105 land on the home page", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
})
const space = scenario.space("space")
// A build that names a platform space sends /home straight into it
const platform = await as(users.alice, "/home", {env: {VITE_PLATFORM_RELAYS: space.url}})
await expect(platform).toHaveURL(pattern(spacePath(space.url)))
// With none configured, /home is a welcome screen with two ways out
const page = await as(users.alice, "/home")
const addSpace = page.getByRole("link", {name: "Add a space"})
const startConversation = page.getByRole("button", {name: "Start a conversation"})
await expect(addSpace).toBeVisible()
await expect(startConversation).toBeVisible()
await addSpace.click()
await expect(page).toHaveURL(/\/spaces$/)
await page.goBack()
await expect(page).toHaveURL(/\/home$/)
await startConversation.click()
await expect(page).toHaveURL(/\/chat$/)
})
test("US-106 share text into the app", async ({seed, as}) => {
const shared = "the offsite is moving to the 14th"
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.room("watercooler", {name: "Watercooler"})
space.join(user.alice, "general", "watercooler")
space.join(user.bob, "general", "watercooler")
space.profile(user.bob, {name: "Bob Barker"})
seedRelays(space, user.alice)
seedRelays(space, user.bob)
space.dm(user.bob, [user.alice], "are you around?", at(20, MINUTE))
})
const space = scenario.space("space")
// A client only unwraps direct messages once its owner has opened chat, so a conversation the
// share dialog can offer is one alice has already seen.
const page = await as(users.alice, "/chat")
await expect(page.getByText("are you around?").first()).toBeVisible()
// The text arrives in the query string rather than through a native share intent, which is the
// same thing src/routes/share reads either way.
await page.goto(`/share?text=${encodeURIComponent(shared)}`)
const share = dialog(page, "Share")
await expect(page.getByText("Where would you like to share this?")).toBeVisible()
await expect(share.getByRole("button", {name: "General"})).toBeVisible()
await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible()
await expect(share.getByRole("button", {name: "Bob Barker"})).toBeVisible()
const search = page.getByPlaceholder("Search rooms and conversations...")
await search.fill("Watercooler")
await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible()
await expect(share.getByRole("button", {name: "General"})).toHaveCount(0)
await expect(share.getByRole("button", {name: "Bob Barker"})).toHaveCount(0)
await search.fill("")
await share.getByRole("button", {name: "General"}).click()
await share.getByRole("button", {name: "Share", exact: true}).click()
await expect(page).toHaveURL(pattern(roomPath(space.url, "general")))
await expect(composer(page)).toHaveText(shared)
})
test("US-107 open a nostr link", async ({seed, as}) => {
let posted!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barker"})
seedRelays(space, user.bob)
posted = space.message(user.bob, "general", "the meeting moved to friday", at(20, MINUTE))
})
const space = scenario.space("space")
const page = await as(users.alice, roomPath(space.url, "general"), {
context: {permissions: ["clipboard-read", "clipboard-write"]},
})
const item = page.locator(`[data-event="${posted.id}"]`)
await expect(item).toBeVisible()
await item.hover()
await item.locator(".room__item-actions button").last().click()
await page.getByRole("button", {name: "Message Details"}).click()
await expect(page.getByRole("heading", {name: "Event Details"})).toBeVisible()
// EventInfo renders the link on mount rather than during setup, so wait for it rather than
// copying an empty field
const eventLink = settingRow(page, "Event Link")
await expect(eventLink.getByRole("textbox")).toHaveValue(/^nostr:nevent1/)
await eventLink.getByRole("button").click()
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
const link = await page.evaluate(() => navigator.clipboard.readText())
expect(link).toMatch(/^nostr:nevent1/)
await page.goto("/" + link.replace(/^nostr:/, ""))
await expect(page).toHaveURL(pattern(roomPath(space.url, "general") + "?at="))
await expect(message(page, "the meeting moved to friday")).toBeVisible()
// A person's npub resolves the same way
const npub = npubEncode(users.bob.pubkey)
await page.goto(`/${npub}`)
await expect(page).toHaveURL(pattern(`/people/${npub}`))
await expect(page.getByRole("heading", {name: "Bob Barker"})).toBeVisible()
// Nothing holds this event, so the link falls back to the app's home rather than a dead page
await page.goto(`/${neventEncode({id: "f".repeat(64), relays: [space.url]})}`)
await expect(page).toHaveURL(/\/home$/)
})

View file

@ -0,0 +1,413 @@
import {readFile} from "node:fs/promises"
import type {Page} from "@playwright/test"
import {npubEncode, nsecEncode} from "nostr-tools/nip19"
import {encrypt} from "nostr-tools/nip49"
import {hexToBytes} from "@welshman/lib"
import {expect, roomPath, spacePath, test, users} from "../harness"
import type {TestUser} from "../harness"
// The sign-in gate AppContainer renders in place of the app for anyone without a session.
const gate = (page: Page) => page.getByRole("heading", {name: "Welcome to Flotilla!"})
const nsecFor = (user: TestUser) => nsecEncode(hexToBytes(user.secret))
// Landing → "Log in" → "Log in with Key" → paste → submit, which is the only way to watch a
// session come into existence: a session injected by `as()` is re-applied on every navigation.
const logInWithKey = async (page: Page, key: string) => {
await page.getByRole("button", {name: "Log in"}).click()
await page.getByRole("button", {name: "Log in with Key"}).click()
await page.getByPlaceholder("nsec1...").fill(key)
await page.getByRole("button", {name: "Log in", exact: true}).click()
}
// The nav's settings link carries its label as a tooltip rather than as an accessible name — its
// icon is a masked svg with no alt text — so it is addressed by where it goes.
const openSettings = (page: Page) =>
page.locator('.primary-nav a[href="/settings/profile"]').click()
test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => {
const scenario = await seed(({relay}) => {
relay("space").room("general", {name: "General"})
})
const {url} = scenario.space("space")
// A room url rather than the root: the gate stands in front of the whole app, so where the
// visitor landed makes no difference.
const page = await visit(roomPath(url, "general"))
await expect(gate(page)).toBeVisible()
await expect(page.getByRole("button", {name: "Log in"})).toBeVisible()
await expect(page.getByRole("button", {name: "Create an account"})).toBeVisible()
await expect(page.getByRole("link", {name: "Terms of Service"})).toHaveAttribute(
"href",
"https://flotilla.social/terms",
)
await expect(page.getByRole("link", {name: "Privacy Policy"})).toHaveAttribute(
"href",
"https://flotilla.social/privacy",
)
await page.keyboard.press("Escape")
await expect(gate(page)).toBeVisible()
// The backdrop, clicked in its top left corner so the dialog itself isn't what gets hit.
await page.getByRole("button", {name: "Close dialog"}).click({position: {x: 4, y: 4}})
await expect(gate(page)).toBeVisible()
await page.getByRole("button", {name: "Log in"}).click()
await expect(page.getByRole("heading", {name: "Log in with Nostr"})).toBeVisible()
await page.goBack()
await expect(gate(page)).toBeVisible()
await page.getByRole("button", {name: "Create an account"}).click()
await expect(page.getByRole("heading", {name: "Join Flotilla"})).toBeVisible()
})
test("US-002 sign up by generating a new key", async ({seed, visit}) => {
const scenario = await seed(({relay}) => {
relay("space").room("general", {name: "General"})
})
const {url} = scenario.space("space")
const page = await visit()
await page.getByRole("button", {name: "Create an account"}).click()
await page.getByRole("button", {name: "Generate a key"}).click()
await page.locator('input[type="text"]').fill("Nova Tester")
await page.getByRole("button", {name: "Create Account"}).click()
await expect(page.getByRole("heading", {name: "Your Keys are Ready!"})).toBeVisible()
const continueButton = page.getByRole("button", {name: "Continue"})
const password = page.locator('input[type="password"]')
await expect(continueButton).toBeDisabled()
await page.getByRole("button", {name: "I want to download an encrypted version"}).click()
await password.fill("hunter2")
await page.getByRole("button", {name: "Download my key"}).click()
await expect(page.getByRole("alert")).toContainText(
"Your password must be at least 12 characters long.",
)
await expect(continueButton).toBeDisabled()
await password.fill("correct horse battery staple")
const download = page.waitForEvent("download")
await page.getByRole("button", {name: "Download my key"}).click()
const contents = await readFile(await (await download).path(), "utf8")
expect(contents).toContain("ncryptsec1")
expect(contents).not.toContain("nsec1")
await expect(continueButton).toBeEnabled()
await continueButton.click()
await expect(page.getByRole("heading", {name: "You're all set!"})).toBeVisible()
await page.getByRole("button", {name: "Go to Dashboard"}).click()
await expect(page).toHaveURL(/\/home$/)
await expect(page.locator(".primary-nav")).toBeVisible()
await expect(page.getByRole("heading", {name: "You're all set!"})).toHaveCount(0)
await expect(gate(page)).toHaveCount(0)
// A space's nav item is a button rather than a link — PrimaryNavItemSpace passes an onclick, and
// PrimaryNavItem renders a Button whenever it has one — so it is addressed by the tooltip it
// carries, which is the relay's nip-11 name, and clicking it is what says which space it is.
const spaceItem = page.locator('.primary-nav [data-tip="space"]')
await expect(spaceItem).toBeVisible()
await spaceItem.click()
await expect(page).toHaveURL(new RegExp(spacePath(url)))
await openSettings(page)
await expect(page.getByText("Nova Tester")).toBeVisible()
})
test("US-003 log in with an existing private key", async ({seed, visit}) => {
await seed(({relay}) => {
relay("space").room("general", {name: "General"})
})
const aliceNpub = npubEncode(users.alice.pubkey)
const password = "a very good password"
const ncryptsec = encrypt(hexToBytes(users.alice.secret), password)
const withNsec = await visit()
await logInWithKey(withNsec, nsecFor(users.alice))
await expect(withNsec.locator(".primary-nav")).toBeVisible()
await openSettings(withNsec)
await expect(withNsec.getByRole("textbox")).toHaveValue(aliceNpub)
const withHex = await visit()
await logInWithKey(withHex, users.alice.secret)
await expect(withHex.locator(".primary-nav")).toBeVisible()
await openSettings(withHex)
await expect(withHex.getByRole("textbox")).toHaveValue(aliceNpub)
const withNcryptsec = await visit()
await withNcryptsec.getByRole("button", {name: "Log in"}).click()
await withNcryptsec.getByRole("button", {name: "Log in with Key"}).click()
const key = withNcryptsec.getByPlaceholder("nsec1...")
const submit = withNcryptsec.getByRole("button", {name: "Log in", exact: true})
await key.fill("this is not a key")
await expect(submit).toBeDisabled()
await key.fill(ncryptsec)
await withNcryptsec.getByPlaceholder("Your password").fill("not the password")
await submit.click()
await expect(withNcryptsec.getByRole("alert")).toContainText(
"Failed to decrypt key. Please check your password.",
)
await expect(withNcryptsec.getByRole("heading", {name: "Log In with Key"})).toBeVisible()
await expect(withNcryptsec.locator(".primary-nav")).toHaveCount(0)
await withNcryptsec.getByPlaceholder("Your password").fill(password)
await submit.click()
await expect(withNcryptsec.locator(".primary-nav")).toBeVisible()
await openSettings(withNcryptsec)
await expect(withNcryptsec.getByRole("textbox")).toHaveValue(aliceNpub)
// A second browser context with its own storage: bob's session is his own, not a second view
// of alice's.
const asBob = await visit()
await logInWithKey(asBob, nsecFor(users.bob))
await openSettings(asBob)
await expect(asBob.getByRole("textbox")).toHaveValue(npubEncode(users.bob.pubkey))
})
test("US-004 log in with a browser extension", async ({seed, visit}) => {
await seed(({relay}) => {
relay("space").room("general", {name: "General"})
})
const page = await visit("/", {nip07: users.alice})
await page.getByRole("button", {name: "Log in"}).click()
await page.getByRole("button", {name: "Log in with Extension"}).click()
await expect(page.locator(".primary-nav")).toBeVisible()
await openSettings(page)
await expect(page.getByRole("textbox")).toHaveValue(npubEncode(users.alice.pubkey))
await expect(page.locator('input[type="password"]')).toHaveCount(0)
const withoutExtension = await visit()
await withoutExtension.getByRole("button", {name: "Log in"}).click()
await expect(
withoutExtension.getByRole("button", {name: "Log in with Remote Signer"}),
).toBeVisible()
await expect(withoutExtension.getByRole("button", {name: "Log in with Extension"})).toHaveCount(0)
})
test("US-005 log in with a remote signer", async ({seed, visit}) => {
await seed(({relay}) => {
relay("space").room("general", {name: "General"})
})
const page = await visit()
await page.getByRole("button", {name: "Log in"}).click()
await page.getByRole("button", {name: "Log in with Remote Signer"}).click()
const bunker = page.getByPlaceholder("bunker://")
const next = page.getByRole("button", {name: "Next"})
// The relay named here belongs to no scenario, so a connection attempt would be recorded as a
// leak and fail this test rather than passing unnoticed.
await bunker.fill("bunker://not-a-signer-pubkey?relay=wss://nowhere.test/")
await next.click()
await expect(page.getByRole("alert")).toContainText(
"Sorry, it looks like that's an invalid bunker link.",
)
await bunker.fill(`bunker://${users.carol.pubkey}`)
await next.click()
await expect(page.getByRole("alert")).toContainText(
"That bunker link does not include any relays.",
)
await page.getByRole("button", {name: "Log in with a QR code instead"}).click()
await expect(page.getByText("Scan with your signer to log in, or click to copy.")).toBeVisible()
await page.getByRole("button", {name: "Go back"}).click()
await expect(bunker).toBeVisible()
})
test("US-006 stay logged in, and log out deliberately", async ({seed, visit}) => {
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
})
const npub = npubEncode(users.alice.pubkey)
const page = await visit()
await logInWithKey(page, nsecFor(users.alice))
await expect(page.locator(".primary-nav")).toBeVisible()
await openSettings(page)
await expect(page.getByRole("textbox")).toHaveValue(npub)
await page.reload()
await expect(page.locator(".primary-nav")).toBeVisible()
await expect(gate(page)).toHaveCount(0)
await expect(page.getByRole("textbox")).toHaveValue(npub)
await page.locator(".secondary-nav").getByRole("button", {name: "Log Out"}).click()
await expect(page.getByRole("heading", {name: /Are you sure you want/})).toBeVisible()
await expect(page.getByText("Your local database will be cleared.")).toBeVisible()
await page.getByRole("button", {name: "Go back"}).click()
await expect(page.locator(".primary-nav")).toBeVisible()
await expect(page.getByRole("textbox")).toHaveValue(npub)
await page.locator(".secondary-nav").getByRole("button", {name: "Log Out"}).click()
await page.locator("form").getByRole("button", {name: "Log Out"}).click()
await expect(gate(page)).toBeVisible()
})
test("US-007 inspect your keys and signer status", async ({seed, as, visit}) => {
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
})
const npub = npubEncode(users.alice.pubkey)
const page = await as(users.alice, "/settings/profile", {
context: {permissions: ["clipboard-read", "clipboard-write"]},
})
const readClipboard = () => page.evaluate(() => navigator.clipboard.readText())
const npubInput = page.getByRole("textbox")
await expect(npubInput).toHaveValue(npub)
await expect(npubInput).toHaveJSProperty("readOnly", true)
await page.locator("label.input").filter({has: npubInput}).getByRole("button").click()
await expect.poll(readClipboard).toBe(npub)
const secretInput = page.locator('input[type="password"]')
await expect(secretInput).toHaveValue(users.alice.secret)
await page.locator("label.input").filter({has: secretInput}).getByRole("button").click()
await expect.poll(readClipboard).toBe(nsecFor(users.alice))
const signerStatus = page.locator(".card").filter({hasText: "Signer Status"}).last()
await expect(signerStatus).toContainText("Logged in with private key")
await expect(signerStatus).toContainText("Ok")
const succeeded = async () =>
Number(/(\d+) requests succeeded/.exec(await signerStatus.innerText())?.[1])
const before = await succeeded()
await page.locator(".card").first().getByRole("button").click()
await page.locator('input[type="text"]').first().fill("Alice Anderson")
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
await expect.poll(succeeded).toBeGreaterThan(before)
// An extension session holds no key for the app to show.
const extension = await visit("/", {nip07: users.bob})
await extension.getByRole("button", {name: "Log in"}).click()
await extension.getByRole("button", {name: "Log in with Extension"}).click()
await expect(extension.locator(".primary-nav")).toBeVisible()
await openSettings(extension)
await expect(extension.getByRole("textbox")).toHaveValue(npubEncode(users.bob.pubkey))
await expect(extension.locator('input[type="password"]')).toHaveCount(0)
await expect(extension.locator(".card").filter({hasText: "Signer Status"}).last()).toContainText(
"Logged in with browser extension",
)
})
test("US-008 delete your nostr account", async ({seed, as, visit}) => {
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anderson"})
})
const alice = await visit()
await logInWithKey(alice, nsecFor(users.alice))
await openSettings(alice)
await alice.locator(".card").filter({hasText: "Advanced"}).getByRole("button").click()
await alice.getByRole("button", {name: "Delete your profile"}).click()
await expect(alice.getByRole("heading", {name: "Delete your account"})).toBeVisible()
const confirm = alice.getByRole("button", {name: "Confirm"})
const phrase = alice.locator('input[type="text"]')
await expect(confirm).toBeDisabled()
await phrase.fill("permanently delete my")
await expect(confirm).toBeDisabled()
await phrase.fill("permanently delete my nostr account")
await expect(confirm).toBeEnabled()
await confirm.click()
await expect(alice.getByRole("progressbar")).toBeVisible()
await expect(gate(alice)).toBeVisible()
const bob = await as(users.bob, `/people/${npubEncode(users.alice.pubkey)}`)
await expect(bob.getByRole("heading", {name: "[deleted]"})).toBeVisible()
})

771
e2e/specs/people.spec.ts Normal file
View file

@ -0,0 +1,771 @@
import {createHash} from "node:crypto"
import type {Locator, Page} from "@playwright/test"
import {nprofileEncode, npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE, MONTH} from "@welshman/lib"
import {NOTE, makeEvent} from "@welshman/util"
import type {SignedEvent} from "@welshman/util"
import {FollowList, Note, PinList, Profile, RelayList} from "@welshman/domain"
import {expect, makeTestUser, mockBlossom, spacePath, test, users} from "../harness"
import type {SeededSpace, TestUser} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string; readonly event: SignedEvent}
// The profile page keeps its Reputation and Spaces panels in a sidebar that only exists above
// tailwind's xl breakpoint, and the default 1280 viewport sits exactly on it.
const DESKTOP = {viewport: {width: 1440, height: 900}}
const CLIPBOARD = {...DESKTOP, permissions: ["clipboard-read", "clipboard-write"]}
// Where an upload lands. Both the avatar picker and the banner picker call into uploadFile with no
// relay of their own, so the blossom probe is skipped and VITE_DEFAULT_BLOSSOM_SERVERS is used.
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
// A 1x1 gif and a 1x1 webp. compressFileForUpload passes both formats through untouched rather
// than re-encoding them through a canvas, so the bytes the server hashes are the bytes chosen
// here and the url an upload resolves to is predictable from node.
const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64")
const WEBP = Buffer.from("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", "base64")
// uploadFile appends the extension when the descriptor's url carries none, which the mock's never
// does.
const uploadedUrl = (body: Buffer, extension: string) =>
`${BLOSSOM_ORIGIN}/${createHash("sha256").update(body).digest("hex")}.${extension}`
const profilePath = (user: TestUser) => `/people/${npubEncode(user.pubkey)}`
// displayPubkey in @welshman/domain: the npub with its middle taken out.
const shortNpub = (user: TestUser) => {
const npub = npubEncode(user.pubkey)
return npub.slice(0, 8) + "…" + npub.slice(-5)
}
// The page's own region, so an assertion about an avatar isn't satisfied by the copy of it the
// nav renders.
const pageContent = (page: Page) => page.locator(".page__content")
// ProfileTrust and ProfileSharedSpaces are rendered twice — stacked for narrow viewports, and in
// the sidebar — so anything said about them is scoped to the copy that is actually on screen.
const sidebar = (page: Page) => page.locator("aside")
// A modal is a `.dialog` overlay wrapping a `.dialog` card, and the card is the one with the
// content in it.
const dialog = (page: Page) => page.locator(".dialog").last()
const notes = (page: Page) => pageContent(page).locator(".cv.card")
const readClipboard = (page: Page) => page.evaluate(() => navigator.clipboard.readText())
// The "..." menu on a profile header, which is the only ghost circle button the page renders.
const profileMenu = (page: Page) => page.locator("button.button-circle.button-ghost")
// PeopleItem renders its link twice, once for each breakpoint; the wide one comes first.
const viewProfile = (card: Locator) => card.getByRole("link", {name: "View Profile"}).first()
// Outbox routing resolves everything about a person through their relay list, so a seeded fixture
// is only loadable by somebody else once its author has one.
const seedRelayList = (space: SeededSpace, user: TestUser) =>
space.event(user, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url])
.renderTemplate(),
)
test("US-074 find a person", async ({seed, as}) => {
// Sixty of them: the people list starts at ten and adds another ten every second until the page
// is taller than the scroller's threshold — and it keeps counting while the profiles are still
// on their way, so by the time the first ones render the limit is already well past ten.
// "Scrolling loads more" is only a fact when there are far more matches than that first fill
// can reach.
const searchers = Array.from({length: 60}, (_, i) => makeTestUser(`searcher-${i}`))
const searcherName = (i: number) => `Searcher ${String(i).padStart(2, "0")}`
const searcherAvatar = (i: number) => `https://images.test/searcher-${i}.png`
const about = [
"Deck crew on the northern run.",
"Splices rope, keeps the log, argues about knots.",
"Ask about the time the compass froze solid.",
"Answers to a whistle and to nothing else.",
].join("\n")
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
// Bob is the one person whose name shares no token with the others, so narrowing the term is
// visibly a filter rather than a reordering.
space.profile(user.bob, {
name: "Bob Barnacle",
about: "Dockside cook and keeper of the ship's cat.",
})
seedRelayList(space, user.bob)
for (const [i, searcher] of searchers.entries()) {
space.member(searcher)
space.profile(searcher, {name: searcherName(i), about, picture: searcherAvatar(i)})
}
})
const page = await as(users.alice, "/people", {context: DESKTOP})
const term = page.getByPlaceholder("Search for people...")
const cards = page.locator(".card.card-interactive")
await term.fill("Searcher")
// Which of them ranks first is fuse's business, so a result is described by what every
// result carries rather than by which one it turned out to be.
const first = cards.first()
await expect(first).toBeVisible()
await expect(first.locator('img[src^="https://images.test/searcher-"]')).toBeVisible()
await expect(first).toContainText(/Searcher \d\d/)
await expect(first).toContainText("Deck crew on the northern run.")
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(10)
const shown = await cards.count()
expect(shown).toBeLessThan(searchers.length)
await cards.last().scrollIntoViewIfNeeded()
await expect.poll(() => cards.count()).toBeGreaterThan(shown)
// Narrowing the term filters the list rather than reordering it.
await term.fill("Barnacle")
const bobCard = cards.filter({hasText: "Bob Barnacle"})
await expect(bobCard).toHaveCount(1)
await expect(cards.filter({hasText: /Searcher \d\d/})).toHaveCount(0)
await viewProfile(bobCard).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
})
test("US-075 view someone's profile", async ({seed, as}) => {
const avatar = "https://images.test/bob-avatar.png"
const banner = "https://images.test/bob-banner.png"
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
// A second space alice does not belong to, so "Member" is a claim about the overlap rather
// than about every space bob is in.
other.room("lounge", {name: "Lounge"})
other.join(user.bob, "lounge")
space.event(user.bob, () =>
space
.kind(Profile)
.writer()
.setName("Bob Barnacle")
.setAbout("Deckhand, dockside cook, and keeper of the ship's cat.")
.setPicture(avatar)
.setBanner(banner)
.setWebsite("bobbarnacle.example")
.renderTemplate(),
)
seedRelayList(space, user.bob)
// Carol belongs to no space at all, which is what the panel's empty state is about.
space.member(user.carol)
space.profile(user.carol, {name: "Carol Cutter"})
seedRelayList(space, user.carol)
})
const space = scenario.space("space")
const other = scenario.space("other")
const page = await as(users.alice, profilePath(users.bob), {context: CLIPBOARD})
const region = pageContent(page)
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
// The header renders the avatar twice, one size per breakpoint; the wide one is second.
await expect(region.locator(`img[src="${avatar}"]`).last()).toBeVisible()
await expect(region.locator(`img[src="${banner}"]`)).toBeVisible()
await expect(region.getByText("Deckhand, dockside cook")).toBeVisible()
const npub = region.getByText(shortNpub(users.bob))
await expect(npub).toBeVisible()
await npub.locator("xpath=following-sibling::button").click()
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey))
await expect(region.getByRole("link", {name: "bobbarnacle.example"})).toHaveAttribute(
"href",
"https://bobbarnacle.example",
)
const spaces = sidebar(page).locator(".card.card-sm").filter({hasText: "Spaces"})
const spaceLink = spaces.locator(`a[href="${spacePath(space.url)}"]`)
const otherLink = spaces.locator(`a[href="${spacePath(other.url)}"]`)
await expect(spaces.locator(".badge").first()).toHaveText("2")
await expect(spaceLink).toContainText("Member")
await expect(otherLink).toBeVisible()
await expect(otherLink.locator(".badge").filter({hasText: "Member"})).toHaveCount(0)
await spaceLink.click()
await expect(page).toHaveURL(new RegExp(spacePath(space.url)))
await page.goto(profilePath(users.carol))
await expect(page.getByRole("heading", {name: "Carol Cutter"})).toBeVisible()
await expect(sidebar(page).getByText("No spaces found.")).toBeVisible()
})
test("US-076 follow and unfollow", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle"})
seedRelayList(space, user.alice)
seedRelayList(space, user.bob)
})
const page = await as(users.alice, profilePath(users.bob), {context: DESKTOP})
const follow = page.getByRole("button", {name: "Follow", exact: true})
const unfollow = page.getByRole("button", {name: "Unfollow", exact: true})
await expect(follow).toBeVisible()
await follow.click()
// The same page, still on the same url: the label flips where it stands.
await expect(unfollow).toBeVisible()
await expect(follow).toHaveCount(0)
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
await unfollow.click()
await expect(follow).toBeVisible()
await expect(unfollow).toHaveCount(0)
})
test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
const carolAvatar = "https://images.test/carol-avatar.png"
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.join(user.carol, "general")
space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."})
space.profile(user.carol, {name: "Carol Cutter", about: "Sailmaker.", picture: carolAvatar})
seedRelayList(space, user.alice)
seedRelayList(space, user.bob)
seedRelayList(space, user.carol)
// Carol already follows bob, so alice following carol is the one thing that has to happen
// through the ui for his standing to move.
space.event(user.carol, () =>
space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(),
)
})
// Every hop here is a link click rather than a navigation, so the follow alice publishes stays
// in the client that published it.
const page = await as(users.alice, "/people", {context: DESKTOP})
const term = page.getByPlaceholder("Search for people...")
const cards = page.locator(".card.card-interactive")
const bobCard = cards.filter({hasText: "Bob Barnacle"})
const carolCard = cards.filter({hasText: "Carol Cutter"})
const reputation = () => sidebar(page).locator(".card.card-sm").filter({hasText: "Reputation"})
// The trust ring beside a name: a circle whose dash offset shrinks as the ring fills in.
const ring = async () =>
Number(await bobCard.locator("circle.wot-highlight").getAttribute("stroke-dashoffset"))
await term.fill("Barnacle")
await expect(bobCard).toHaveCount(1)
await expect(bobCard.locator("circle.wot-highlight")).toBeAttached()
const emptyRing = await ring()
await viewProfile(bobCard).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
// Word-bounded: a plain "0 / 100" is also a substring of "10 / 100" and "20 / 100", which are
// exactly the readings this is supposed to rule out.
await expect(reputation()).toContainText(/\b0 \/ 100\b/)
await expect(reputation()).toContainText("This user is not well known in your network.")
await page.locator('.primary-nav a[href="/people"]').click()
await term.fill("Cutter")
await expect(carolCard).toHaveCount(1)
await viewProfile(carolCard).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.carol)}$`))
await page.getByRole("button", {name: "Follow", exact: true}).click()
await expect(page.getByRole("button", {name: "Unfollow", exact: true})).toBeVisible()
await page.locator('.primary-nav a[href="/people"]').click()
await term.fill("Barnacle")
await expect(bobCard).toHaveCount(1)
await expect.poll(ring).toBeLessThan(emptyRing)
await viewProfile(bobCard).click()
await expect(reputation()).toContainText("Followed by 1+ people in your network.")
await expect(reputation()).toContainText("1 person you follow also follow Bob Barnacle.")
await expect(reputation().locator(`img[src="${carolAvatar}"]`)).toBeVisible()
})
test("US-078 edit your own profile", async ({seed, as}) => {
const oldAvatar = "https://images.test/alice-avatar.png"
const newAvatar = uploadedUrl(WEBP, "webp")
const newBanner = uploadedUrl(GIF, "gif")
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.event(user.alice, () =>
space
.kind(Profile)
.writer()
.setName("Alice Anderson")
.setAbout("Ship's navigator.")
.setPicture(oldAvatar)
.setNip05("alice@flotilla.test")
.renderTemplate(),
)
seedRelayList(space, user.alice)
})
const page = await as(users.alice, profilePath(users.alice), {context: DESKTOP})
await mockBlossom(page.context(), {server: BLOSSOM_ORIGIN})
const region = pageContent(page)
const edit = page.getByRole("button", {name: "Edit profile"})
await expect(edit).toBeVisible()
await expect(page.getByRole("button", {name: "Follow", exact: true})).toHaveCount(0)
// Exact, or this would match the nav's "Messages".
await expect(page.getByRole("button", {name: "Message", exact: true})).toHaveCount(0)
await edit.click()
// ProfileEditForm's text inputs, in the order it renders them: nickname, website, nostr address.
const form = page.locator("form").first()
const nickname = form.locator('input[type="text"]').first()
const about = form.locator("textarea")
const nostrAddress = form.locator('input[type="text"]').nth(2)
const picker = page.locator('label[aria-label="Drag and drop files here."]')
await expect(nickname).toHaveValue("Alice Anderson")
await expect(about).toHaveValue("Ship's navigator.")
await expect(nostrAddress).toHaveValue("alice@flotilla.test")
await expect.poll(() => picker.getAttribute("style")).toContain(oldAvatar)
await nickname.fill("Alice Anchor")
await about.fill("Ship's navigator and part-time cartographer.")
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
await expect(page.getByRole("heading", {name: "Alice Anchor"})).toBeVisible()
await expect(region.getByText("part-time cartographer")).toBeVisible()
await edit.click()
const avatarChooser = page.waitForEvent("filechooser")
await picker.click()
await (
await avatarChooser
).setFiles({
name: "avatar.webp",
mimeType: "image/webp",
buffer: WEBP,
})
await expect.poll(() => picker.getAttribute("style")).toContain(newAvatar)
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(region.locator(`img[src="${newAvatar}"]`).last()).toBeVisible()
const bannerChooser = page.waitForEvent("filechooser")
await page.getByRole("button", {name: "Change banner"}).click()
await (await bannerChooser).setFiles({name: "banner.gif", mimeType: "image/gif", buffer: GIF})
await expect(page.getByRole("alert")).toContainText("Banner updated.")
await expect(region.locator(`img[src="${newBanner}"]`)).toBeVisible()
})
test("US-079 read a person's notes", async ({seed, as}) => {
const avatar = "https://images.test/alice-avatar.png"
let carolNote!: Seeded
let pinned!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.join(user.carol, "general")
space.profile(user.alice, {name: "Alice Anderson", picture: avatar})
space.profile(user.carol, {name: "Carol Cutter"})
seedRelayList(space, user.alice)
seedRelayList(space, user.carol)
carolNote = space.event(
user.carol,
makeEvent(NOTE, {content: "Anyone seen the tide charts?", created_at: at(4, HOUR)}),
)
pinned = space.event(
user.alice,
makeEvent(NOTE, {content: "PINNED how to read a tide chart", created_at: at(3, HOUR)}),
)
space.event(
user.alice,
makeEvent(NOTE, {content: "MIDDLE rigging notes from this morning", created_at: at(2, HOUR)}),
)
space.event(
user.alice,
makeEvent(NOTE, {content: "NEWEST the wind has finally turned", created_at: at(1, HOUR)}),
)
// Older than the window the feed opens with, so reaching it is the feed paging backwards.
space.event(
user.alice,
makeEvent(NOTE, {content: "OLDEST from the spring refit", created_at: at(8, MONTH)}),
)
// A reply to carol, which a profile's note list leaves out.
space.event(
user.alice,
() =>
space
.kind(Note)
.writer()
.setParent(carolNote.event)
.setContent("REPLY they are in the chart drawer")
.renderTemplate(),
at(90, MINUTE),
)
space.event(user.alice, () =>
space.kind(PinList).writer().pinPublicly(["e", pinned.id]).renderTemplate(),
)
})
const {url} = scenario.space("space")
const page = await as(users.bob, profilePath(users.alice), {context: DESKTOP})
const list = notes(page)
const newest = list.filter({hasText: "NEWEST"})
await expect(newest).toBeVisible()
await expect(newest.getByText("Alice Anderson")).toBeVisible()
await expect(newest.locator(`img[src="${avatar}"]`)).toBeVisible()
// The story asks for a relative timestamp. NoteCard renders formatTimestamp, which is a short
// date plus a clock time, so this is the story's reading rather than the app's.
await expect(newest).toContainText(/\d+ (second|minute|hour|day)s? ago/)
await expect(list.filter({hasText: "REPLY"})).toHaveCount(0)
// The pin outranks the newer notes; the rest are newest first.
await expect(list.first()).toContainText("PINNED")
await expect(list.nth(1)).toContainText("NEWEST")
await expect(list.nth(2)).toContainText("MIDDLE")
// Nothing is scrolled here: the feed keeps widening its window until the page is full, which is
// what "loads older notes automatically" means.
await expect(list.filter({hasText: "OLDEST"})).toBeVisible()
// A note alice publishes while bob is looking. Flotilla has no composer for a kind-1 note, so
// it goes out through the app's own primitives in her signed-in session, over her socket to the
// space — which is the half of this the story is about.
const alice = await as(users.alice, spacePath(url), {context: DESKTOP})
await alice.evaluate(
async ([relayUrl, content]) => {
const {
app,
network,
makeEvent: make,
} = window as unknown as {
app: {get(): {user: {sign(template: object): Promise<object>}}}
network: {get(): {publish(options: {event: object; relays: string[]}): Promise<unknown>}}
makeEvent(kind: number, values: {content: string}): object
}
await network.get().publish({
event: await app.get().user.sign(make(1, {content})),
relays: [relayUrl],
})
},
[url, "LIVE straight off the deck"] as const,
)
// Above every unpinned note, without bob having reloaded anything.
await expect(list.filter({hasText: "LIVE"})).toBeVisible()
await expect(list.nth(1)).toContainText("LIVE")
// Carol has posted nothing at all.
await page.goto(profilePath(users.carol))
await expect(page.getByText("Loading notes...")).toBeVisible()
await expect(page.getByText("No notes found for this profile.")).toBeVisible()
})
test("US-080 preview a profile from anywhere", async ({seed, as}) => {
const avatar = "https://images.test/bob-avatar.png"
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.bob, {
name: "Bob Barnacle",
about: "Deckhand, dockside cook, and keeper of the ship's cat.",
picture: avatar,
})
space.message(user.bob, "general", "the cat has the helm", at(1, HOUR))
seedRelayList(space, user.bob)
})
const {url} = scenario.space("space")
const page = await as(users.alice, `${spacePath(url)}/directory`, {context: DESKTOP})
// SpaceMember covers its whole card with one button, whose aria-label is interpolated from
// `$profiles.display(pubkey).get()` — a plain call, so the label keeps whatever the name was at
// first render, which is the npub the card falls back to before the profile has loaded. The
// name it *displays* comes from a store and does update, so the card is found by that.
const preview = page
.locator(".card.card-interactive")
.filter({hasText: "Bob Barnacle"})
.locator("button[aria-label]")
.first()
await expect(preview).toBeVisible()
await preview.click()
await expect(dialog(page).getByText("Bob Barnacle")).toBeVisible()
await expect(dialog(page).locator(`img[src="${avatar}"]`)).toBeVisible()
await expect(dialog(page).getByText("Deckhand, dockside cook")).toBeVisible()
await expect(dialog(page).getByText(/Last active/)).toBeVisible()
await dialog(page).getByRole("button", {name: "Go back"}).click()
// Closed, and alice is exactly where she opened it from.
await expect(page.locator(".dialog")).toHaveCount(0)
await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`))
await expect(preview).toBeVisible()
await preview.click()
await page.getByRole("button", {name: "View Full Profile"}).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
})
test("US-081 inspect and share a profile", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."})
seedRelayList(space, user.bob)
})
const page = await as(users.alice, profilePath(users.bob), {context: CLIPBOARD})
await profileMenu(page).click()
await page.getByRole("button", {name: "Profile Info"}).click()
await expect(page.getByRole("heading", {name: "Profile Details"})).toBeVisible()
const info = dialog(page)
const link = info.locator('input[type="text"]').first()
const pubkey = info.locator('input[type="text"]').nth(1)
// The profile was signed during this test, so the creation date is today's. A FieldInline puts
// its value in the div right after its label, which keeps this a claim about the date rather
// than about anything else in the dialog that happens to carry four digits.
const createdAt = info
.locator("label")
.filter({hasText: "Created At"})
.locator("xpath=following-sibling::div")
await expect(createdAt).toContainText(String(new Date().getFullYear()))
await expect(link).toHaveValue(/^nostr:nprofile1/)
await expect(pubkey).toHaveValue(npubEncode(users.bob.pubkey))
await expect(info.locator("pre code")).toContainText("Bob Barnacle")
await info.locator("label.input").filter({has: link}).getByRole("button").click()
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
await expect.poll(() => readClipboard(page)).toBe(await link.inputValue())
await info.locator("label.input").filter({has: pubkey}).getByRole("button").click()
await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey))
await info.getByRole("button", {name: "Copy"}).click()
await expect.poll(() => readClipboard(page)).toContain("Bob Barnacle")
await page.getByRole("button", {name: "Got it"}).click()
await profileMenu(page).click()
await page.getByRole("button", {name: "Share"}).click()
await expect(page.getByText("Share Profile")).toBeVisible()
const share = dialog(page)
const qr = share.locator("canvas")
const shareLink = share.locator("input")
await expect(qr).toBeVisible()
expect((await qr.boundingBox())?.width ?? 0).toBeGreaterThan(0)
// pubkeyLink in src/app/env.ts, which shares a profile without relay hints.
await expect(shareLink).toHaveValue(
`https://coracle.social/${nprofileEncode({pubkey: users.bob.pubkey, relays: []})}`,
)
await share.locator("label.input").getByRole("button").click()
await expect.poll(() => readClipboard(page)).toBe(await shareLink.inputValue())
})
test("US-082 mute an account", async ({seed, as}) => {
await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."})
seedRelayList(space, user.alice)
seedRelayList(space, user.bob)
space.event(
user.bob,
makeEvent(NOTE, {content: "Salt on the rigging this morning", created_at: at(2, HOUR)}),
)
space.event(
user.bob,
makeEvent(NOTE, {content: "The gulls have taken the mainsail", created_at: at(1, HOUR)}),
)
})
const page = await as(users.alice, "/settings/content", {context: DESKTOP})
const badge = page.locator(".badge").filter({hasText: "Bob Barnacle"})
const save = page.getByRole("button", {name: "Save Changes"})
// Typed rather than filled, and slower than the search's own debounce: the suggestion list is
// rebuilt per keystroke, and the profile it is searching for only arrives from the relay once a
// pause in the typing has let the query go out.
await page
.getByPlaceholder("Search for profiles...")
.pressSequentially("Bob Barnacle", {delay: 700})
const suggestion = page.locator(".tiptap-suggestions__item").filter({hasText: "Bob Barnacle"})
await expect(suggestion).toBeVisible()
await suggestion.click()
await expect(badge).toBeVisible()
await save.click()
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
// Through the nav rather than a fresh navigation, so what is on screen is what the client that
// just published the mute believes.
const openBobsProfile = async () => {
await page.locator('.primary-nav a[href="/people"]').click()
await page.getByPlaceholder("Search for people...").fill("Barnacle")
const card = page.locator(".card.card-interactive").filter({hasText: "Bob Barnacle"})
await expect(card).toHaveCount(1)
await viewProfile(card).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
}
await openBobsProfile()
const list = notes(page)
await expect(list).toHaveCount(2)
await expect(list.first()).toContainText("You have muted this person.")
await expect(list.last()).toContainText("You have muted this person.")
await expect(pageContent(page).getByText("Salt on the rigging")).toHaveCount(0)
await list.first().getByRole("button", {name: "Show anyway"}).click()
await expect(list.first()).toContainText("The gulls have taken the mainsail")
await expect(list.last()).toContainText("You have muted this person.")
await page.locator('.primary-nav a[href="/settings/profile"]').click()
await page.getByRole("link", {name: "Content"}).click()
await expect(badge).toBeVisible()
await page.reload()
await expect(badge).toBeVisible()
await badge.getByRole("button").first().click()
await expect(badge).toHaveCount(0)
await save.click()
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
await openBobsProfile()
await expect(list).toHaveCount(2)
await expect(pageContent(page).getByText("Salt on the rigging this morning")).toBeVisible()
await expect(pageContent(page).getByText("The gulls have taken the mainsail")).toBeVisible()
await expect(pageContent(page).getByText("You have muted this person.")).toHaveCount(0)
})

771
e2e/specs/rooms.spec.ts Normal file
View file

@ -0,0 +1,771 @@
import {DAY, HOUR, MINUTE, WEEK} from "@welshman/lib"
import {MessagingRelayList, RelayList, displayPubkey} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {expect, makeTestUser, roomPath, spacePath, test, users} from "../harness"
import type {SeededRumor, 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.
const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
// The panel of the modal carrying a given title. `.dialog` is on both the backdrop wrapper and the
// panel inside it, so the last match is the panel.
const dialog = (page: Page, title: string) =>
page
.locator(".dialog")
.filter({has: page.getByRole("heading", {name: title, exact: true})})
.last()
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
// .room__content is column-reverse, so the message at the bottom of the room is the first one in
// the dom.
const messages = (page: Page) => page.locator(".room__item")
const message = (page: Page, text: string) => messages(page).filter({hasText: text})
// RoomItem gives its hover actions no accessible names — every one is an icon. Their order is
// fixed by the component: zap, emoji, reply, edit (only on your own recent message), menu.
const messageActions = (page: Page, text: string) =>
message(page, text).locator(".room__item-actions button")
const openMessageMenu = (page: Page, text: string) => messageActions(page, text).last().click()
// Each of these menus hides itself once the pointer leaves it, which is how one is dismissed
// without clicking an item. The popper sits against the right hand end of the message, so the top
// left corner of the viewport is outside it.
const dismissMenu = (page: Page) => page.mouse.move(0, 0)
// The room's page bar carries a back arrow, a search button and the detail button, in that order.
// The back arrow is display:none at this viewport, so a role query sees the other two.
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
const openRoomDetail = (page: Page) => pageBar(page).getByRole("button").last().click()
// RoomDetail has no title of its own — the room's name stands in for one — so it is named by the
// permissions card only it renders. Scoped to the modal's body rather than the dialog, so that the
// dialog's own close button isn't the first button in it.
const roomDetail = (page: Page) =>
page.locator(".scroll-container").filter({hasText: "Room Permissions"})
const openRoomDetailMenu = (page: Page) => roomDetail(page).getByRole("button").first().click()
// The space menu's sections are flat siblings — a header, then the rooms under it — so which
// section a room is in is a question about document order rather than nesting.
const roomSection = (page: Page, name: string) =>
page.locator(".space-menu__scroll").evaluate((menu, roomName) => {
let section: string | undefined
for (const node of menu.querySelectorAll(".secondary-nav__header, .secondary-nav__nav-item")) {
if (node.classList.contains("secondary-nav__header")) {
section = node.textContent?.trim()
} else if (node.textContent?.trim() === roomName) {
return section
}
}
}, name)
const roomLink = (page: Page, name: string) =>
page.locator(".space-menu__scroll").getByRole("link", {name})
// A FieldInline puts its control in the div immediately after its label.
const field = (form: Locator, label: string) =>
form
.locator("label")
.filter({hasText: label})
.locator("xpath=following-sibling::div")
.locator("input[type=text]")
// RoomForm's permission toggles are a bare checkbox beside their own text rather than a labelled
// control.
const permission = (form: Locator, label: string) =>
form.getByText(label).locator("xpath=preceding-sibling::input")
const reactionPill = (page: Page, text: string) =>
message(page, text).getByRole("button", {name: /🎉/})
// The picker is a web component with an open shadow root, so its search field and its results are
// reachable through it. Searching rather than browsing avoids depending on which category tab an
// emoji happens to live under. A tippy keeps its content mounted after it hides, so the visible
// one is the picker that was just opened.
const react = async (page: Page, opener: Locator) => {
await opener.click()
const picker = page.locator("emoji-picker").filter({visible: true})
await picker.locator("input.search").fill("party popper")
await picker.locator('[role="option"][aria-label*="party popper"]').first().click()
}
const send = async (page: Page, content: string) => {
await composer(page).pressSequentially(content)
await composer(page).press("Enter")
}
// Outbox routing resolves everything about a person through their relay list, and a gift wrap only
// reaches somebody who has said where their messages go.
const seedChatter = (space: SeededSpace, user: TestUser) => {
space.event(user, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url])
.renderTemplate(),
)
space.event(user, () =>
space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(),
)
}
test("US-018 send and receive a room message in real time", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anchor", picture: "https://images.test/alice.png"})
space.message(user.bob, "general", "morning all", at(2, HOUR))
})
const {url} = scenario.space("space")
const path = roomPath(url, "general")
// Two browser contexts, two identities, one relay: bob's page is already listening when alice
// sends, so the message reaches him over the wire.
const alice = await as(users.alice, path)
const bob = await as(users.bob, path)
await expect(message(bob, "morning all")).toBeVisible()
await send(alice, "anyone there?")
await expect(composer(alice)).toHaveText("")
const sent = messages(alice).first()
await expect(sent).toContainText("anyone there?")
await expect(sent).toContainText("Alice Anchor")
await expect(sent).toContainText(/Today at \d/)
await expect(sent.locator('img[src="https://images.test/alice.png"]')).toBeVisible()
await expect(message(bob, "anyone there?")).toBeVisible()
// Shift+Enter breaks the line rather than sending it
await composer(alice).pressSequentially("first line")
await composer(alice).press("Shift+Enter")
await composer(alice).pressSequentially("second line")
await expect(composer(alice).locator("br")).toHaveCount(1)
await expect(messages(alice)).toHaveCount(2)
// ...and cmd/ctrl+enter sends from the middle of it
await composer(alice).press("ArrowUp")
await composer(alice).press("ControlOrMeta+Enter")
await expect(composer(alice)).toHaveText("")
await expect(message(alice, "second line")).toContainText("first line")
await expect(message(bob, "second line")).toContainText("first line")
})
test("US-019 join and leave a room", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
// Bob belongs to the space but to none of its rooms, so joining one is his to do.
space.join(user.bob)
space.profile(user.bob, {name: "Bob Barnacle"})
space.message(user.alice, "general", "morning all", at(2, HOUR))
})
const {url} = scenario.space("space")
const path = roomPath(url, "general")
const bob = await as(users.bob, path)
const alice = await as(users.alice, path)
await expect(message(bob, "morning all")).toBeVisible()
await expect.poll(() => roomSection(bob, "General")).toBe("Rooms")
await openRoomDetail(bob)
await openRoomDetailMenu(bob)
await bob.getByRole("button", {name: "Join member list"}).click()
await expect.poll(() => roomSection(bob, "General")).toBe("Your Rooms")
// Everyone else in the room watches him arrive
const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"})
await expect(joined).toBeVisible()
await joined.getByRole("button", {name: "@Bob Barnacle"}).click()
await expect(alice.getByRole("button", {name: "View Full Profile"})).toBeVisible()
await openRoomDetailMenu(bob)
await bob.getByRole("button", {name: "Leave member list"}).click()
await expect(bob.getByRole("alert")).toHaveClass(/text-content/)
await openRoomDetailMenu(bob)
await expect(bob.getByRole("button", {name: "Join member list"})).toBeVisible()
await expect.poll(() => roomSection(bob, "General")).toBe("Rooms")
})
test("US-020 create, edit, and delete a room", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.admin, "general")
space.join(user.bob, "general")
})
const {url} = scenario.space("space")
const admin = await as(users.admin, roomPath(url, "general"))
await admin.getByRole("button", {name: "Create room"}).click()
const createForm = dialog(admin, "Create a Room")
await field(createForm, "Name").fill("Ship Log")
await field(createForm, "Description").fill("Where the watch writes things down")
await createForm.getByRole("button", {name: "Create Room"}).click()
await expect(pageBar(admin)).toContainText("Ship Log")
await expect.poll(() => roomSection(admin, "Ship Log")).toBe("Your Rooms")
// The room id is generated, so it comes back off the url the form navigated to
const h = new URL(admin.url()).pathname.split("/").pop()!
await openRoomDetail(admin)
await openRoomDetailMenu(admin)
await admin.getByRole("button", {name: "Edit Room"}).click()
const editForm = dialog(admin, "Edit a Room")
await field(editForm, "Name").fill("Captain's Log")
await permission(editForm, "Only allow members to read messages").check()
await editForm.getByRole("button", {name: "Save Changes"}).click()
await expect(pageBar(admin)).toContainText("Captain's Log")
await expect(roomLink(admin, "Captain's Log")).toBeVisible()
await openRoomDetail(admin)
await expect(roomDetail(admin).getByRole("button", {name: "Private"})).toBeVisible()
await openRoomDetailMenu(admin)
await admin.getByRole("button", {name: "Edit Room"}).click()
await expect(field(editForm, "Name")).toHaveValue("Captain's Log")
await expect(permission(editForm, "Only allow members to read messages")).toBeChecked()
await editForm.getByRole("button", {name: "Go back"}).click()
await openRoomDetailMenu(admin)
await admin.getByRole("button", {name: "Delete Room"}).click()
await expect(
admin.getByRole("heading", {name: "Are you sure you want to delete this room?"}),
).toBeVisible()
await admin.getByRole("button", {name: "Confirm"}).click()
await expect(admin).not.toHaveURL(pathPattern(h))
await expect(admin).toHaveURL(pathPattern(spacePath(url)))
await expect(roomLink(admin, "General")).toBeVisible()
await expect(roomLink(admin, "Captain's Log")).toHaveCount(0)
// Room management is the relay's business as much as the ui's, and bob has neither
const bob = await as(users.bob, roomPath(url, "general"))
await openRoomDetail(bob)
await openRoomDetailMenu(bob)
await expect(bob.getByRole("button", {name: "Leave member list"})).toBeVisible()
await expect(bob.getByRole("button", {name: "Edit Room"})).toHaveCount(0)
await expect(bob.getByRole("button", {name: "Delete Room"})).toHaveCount(0)
})
test("US-021 request access to a private room and get approved", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
// `private` is what hides the room's history from non-members; `closed` is what keeps a join
// request from admitting her on its own, so an admin has to act on it.
space.room("wardroom", {name: "Wardroom", private: true, closed: true})
space.join(user.admin, "general", "wardroom")
space.join(user.alice, "general", "wardroom")
// Carol belongs to the space, which is what lets her see that the room exists at all.
space.join(user.carol)
space.profile(user.carol, {name: "Carol Cutter"})
space.message(user.alice, "wardroom", "the charts are in the locker", at(2, HOUR))
})
const {url} = scenario.space("space")
const path = roomPath(url, "wardroom")
const carol = await as(users.carol, path)
const admin = await as(users.admin, path)
await expect(message(admin, "the charts are in the locker")).toBeVisible()
await expect(carol.getByText("You aren't currently a member of this room.")).toBeVisible()
await expect(carol.getByText("the charts are in the locker")).toHaveCount(0)
await carol.getByRole("button", {name: "Ask to Join"}).click()
await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible()
await admin.getByRole("button", {name: /space\.test/}).click()
await admin.getByRole("button", {name: /Action Items/}).click()
const request = admin.locator(".card").filter({hasText: "requested membership in"})
await expect(request).toContainText("Carol Cutter")
await expect(request).toContainText("Wardroom")
await request.getByRole("button", {name: "Accept"}).click()
await expect(admin.getByText("Member has been added to the room!")).toBeVisible()
await expect(carol.getByText("the charts are in the locker")).toBeVisible()
await send(carol, "found them, thanks")
await expect(message(carol, "found them, thanks")).toBeVisible()
await expect(message(admin, "found them, thanks")).toBeVisible()
})
test("US-022 bring people into a room", async ({seed, as}) => {
const dora = makeTestUser("dora")
const erik = makeTestUser("erik")
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.admin, "general")
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle"})
// A space member who isn't in the room yet. Her message is what loads her profile into the
// client that goes looking for her.
space.join(dora)
space.profile(dora, {name: "Dora Deckhand"})
space.message(dora, "general", "passing through", at(3, HOUR))
})
const {url} = scenario.space("space")
const admin = await as(users.admin, roomPath(url, "general"))
await expect(message(admin, "passing through")).toContainText("Dora Deckhand")
await openRoomDetail(admin)
await admin.getByRole("button", {name: "Create invite"}).click()
const inviteModal = dialog(admin, "Create a Room Invite")
const invite = inviteModal.locator("input[readonly]")
await expect(inviteModal.locator("canvas")).toBeVisible()
await expect(invite).toHaveValue(/\/join\?r=space\.test&c=[^&]*&h=general&code=.+/)
await inviteModal.locator("label").filter({has: invite}).getByRole("button").click()
await expect(admin.getByText("Copied to clipboard!")).toBeVisible()
// The link is opened as a path rather than as an absolute url, which would leave the test's own
// dev server for the platform's.
const link = new URL(await invite.inputValue())
const carol = await as(users.carol, `/join${link.search}`)
await expect(carol.getByText("You're about to join:")).toBeVisible()
await carol.getByRole("button", {name: "Join Room"}).click()
await expect(carol.getByText("Welcome to the room!")).toBeVisible()
await expect(carol).toHaveURL(pathPattern(roomPath(url, "general")))
await expect(message(carol, "passing through")).toBeVisible()
await admin.getByRole("button", {name: "Done"}).click()
await admin.getByRole("button", {name: "View All"}).click()
const members = dialog(admin, "Members")
await expect(members.getByText("Bob Barnacle")).toBeVisible()
await expect(members.getByText("Dora Deckhand")).toHaveCount(0)
await admin.getByRole("button", {name: "Add members"}).click()
await admin.getByPlaceholder("Search for profiles...").fill("Dora")
await admin.locator(".tiptap-suggestions").getByRole("button", {name: dora.pubkey}).click()
await admin.getByRole("button", {name: "Save changes"}).click()
await expect(admin.getByText("Members have successfully been added!")).toBeVisible()
await expect(members.getByText("Dora Deckhand")).toBeVisible()
// Somebody who isn't in the space yet has to be let into it first. A pubkey pasted into the
// search field selects that person outright, which is how erik is reachable without a profile.
await admin.getByRole("button", {name: "Add members"}).click()
await admin.getByPlaceholder("Search for profiles...").fill(erik.pubkey)
await admin.getByRole("button", {name: "Save changes"}).click()
await expect(admin.getByText("is not a member of this space. Add them?")).toBeVisible()
await admin.getByRole("button", {name: "Confirm"}).click()
await expect(admin.getByText("Members have successfully been added!")).toBeVisible()
await expect(members.getByText(displayPubkey(erik.pubkey))).toBeVisible()
const doraCard = members.locator(".card").filter({hasText: "Dora Deckhand"})
await doraCard.getByRole("button").last().click()
await admin.getByRole("button", {name: "Remove Member"}).click()
await admin.getByRole("button", {name: "Confirm"}).click()
await expect(admin.getByText("Member has successfully been removed!")).toBeVisible()
await expect(members.getByText("Dora Deckhand")).toHaveCount(0)
const bob = await as(users.bob, roomPath(url, "general"))
await openRoomDetail(bob)
await bob.getByRole("button", {name: "View All"}).click()
await expect(dialog(bob, "Members").getByText("Bob Barnacle")).toBeVisible()
await expect(bob.getByRole("button", {name: "Add members"})).toHaveCount(0)
})
test("US-023 reply to a message", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anchor"})
space.message(user.alice, "general", "the tide turns at six", at(2, HOUR))
})
const {url} = scenario.space("space")
const path = roomPath(url, "general")
const bob = await as(users.bob, path)
const alice = await as(users.alice, path)
await expect(message(bob, "the tide turns at six")).toBeVisible()
await messageActions(bob, "the tide turns at six").nth(2).click()
const banner = bob.getByText("Replying to @Alice Anchor")
await expect(banner).toBeVisible()
await expect(bob.locator(".room__compose")).toContainText("the tide turns at six")
// Escape clears the reply without sending it
await composer(bob).press("Escape")
await expect(banner).toHaveCount(0)
// ...and so does the banner's own close button
await messageActions(bob, "the tide turns at six").nth(2).click()
await banner.locator("..").getByRole("button").last().click()
await expect(banner).toHaveCount(0)
await expect(messages(bob)).toHaveCount(1)
await messageActions(bob, "the tide turns at six").nth(2).click()
await send(bob, "aye, I'll be there")
await expect(message(bob, "aye, I'll be there")).toContainText("the tide turns at six")
await expect(message(alice, "aye, I'll be there")).toContainText("the tide turns at six")
})
test("US-024 edit or delete a message you sent", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anchor"})
space.profile(user.bob, {name: "Bob Barnacle"})
})
const {url} = scenario.space("space")
const path = roomPath(url, "general")
const alice = await as(users.alice, path)
const bob = await as(users.bob, path)
// Only a message she sent in the last five minutes is editable, so she sends one
await send(alice, "we sail at dwan")
await expect(message(bob, "we sail at dwan")).toBeVisible()
await send(bob, "spelling?")
await expect(message(alice, "spelling?")).toBeVisible()
// Her own message offers zap, emoji, reply, edit and a menu; his offers no edit...
await expect(messageActions(alice, "we sail at dwan")).toHaveCount(5)
await expect(messageActions(alice, "spelling?")).toHaveCount(4)
// ...and no delete either
await openMessageMenu(alice, "spelling?")
await expect(alice.getByRole("button", {name: "Report Content"})).toBeVisible()
await expect(alice.getByRole("button", {name: "Delete Message"})).toHaveCount(0)
await dismissMenu(alice)
await messageActions(alice, "we sail at dwan").nth(3).click()
await expect(alice.getByText("Editing message")).toBeVisible()
await expect(composer(alice)).toHaveText("we sail at dwan")
await composer(alice).press("ControlOrMeta+a")
await send(alice, "we sail at dawn")
await expect(message(alice, "we sail at dawn")).toBeVisible()
await expect(alice.getByText("we sail at dwan")).toHaveCount(0)
await expect(message(bob, "we sail at dawn")).toBeVisible()
await expect(bob.getByText("we sail at dwan")).toHaveCount(0)
// Republished with its original timestamp, so it stays above the message it drew
await expect(messages(alice).nth(1)).toContainText("we sail at dawn")
await expect(messages(bob).nth(1)).toContainText("we sail at dawn")
// Up in an empty composer picks up her most recent editable message
await composer(alice).press("ArrowUp")
await expect(alice.getByText("Editing message")).toBeVisible()
await expect(composer(alice)).toHaveText("we sail at dawn")
await alice.getByText("Editing message").locator("..").getByRole("button").click()
await expect(alice.getByText("Editing message")).toHaveCount(0)
await expect(message(alice, "we sail at dawn")).toBeVisible()
await openMessageMenu(alice, "we sail at dawn")
await alice.getByRole("button", {name: "Delete Message"}).click()
await alice.getByRole("button", {name: "Confirm"}).click()
await expect(alice.getByText("we sail at dawn")).toHaveCount(0)
await expect(bob.getByText("we sail at dawn")).toHaveCount(0)
})
test("US-025 react to a message", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.join(user.carol, "general")
space.profile(user.bob, {name: "Bob Barnacle"})
space.message(user.alice, "general", "we made port", at(2, HOUR))
})
const {url} = scenario.space("space")
const path = roomPath(url, "general")
const carol = await as(users.carol, path)
const bob = await as(users.bob, path)
await expect(message(carol, "we made port")).toBeVisible()
await expect(message(bob, "we made port")).toBeVisible()
// The quick reaction button is the second of RoomItem's hover actions
await react(carol, messageActions(carol, "we made port").nth(1))
await expect(reactionPill(carol, "we made port")).toBeVisible()
await expect(reactionPill(bob, "we made port")).toBeVisible()
await react(bob, messageActions(bob, "we made port").nth(1))
await expect(reactionPill(carol, "we made port")).toContainText("2")
await expect(reactionPill(bob, "we made port")).toContainText("2")
await reactionPill(carol, "we made port").click()
await expect(reactionPill(carol, "we made port")).not.toContainText("2")
await expect(reactionPill(carol, "we made port")).not.toHaveClass(/button-primary/)
await expect(reactionPill(bob, "we made port")).not.toContainText("2")
// On a phone, a pill she hasn't joined shows who reacted rather than adding her own
const phone = await as(users.carol, path, {
context: {viewport: {width: 390, height: 844}, hasTouch: true},
})
await expect(reactionPill(phone, "we made port")).toBeVisible()
await reactionPill(phone, "we made port").click()
await expect(phone.getByText("Reacted to this message")).toBeVisible()
await expect(phone.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
})
test("US-026 pin a message and browse pins", async ({seed, as}) => {
let pinned!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.admin, "general")
space.join(user.alice, "general")
pinned = space.message(user.alice, "general", "muster at eight bells", at(2, HOUR))
})
const {url} = scenario.space("space")
const admin = await as(users.admin, roomPath(url, "general"))
await expect(message(admin, "muster at eight bells")).toBeVisible()
await openMessageMenu(admin, "muster at eight bells")
await admin.getByRole("button", {name: "Pin Message"}).click()
await expect(admin.getByText("Message pinned")).toBeVisible()
const banner = admin.locator(".room-pins")
await expect(banner).toContainText("muster at eight bells")
await banner.getByRole("button", {name: "All pinned messages"}).click()
const pins = dialog(admin, "Pinned Messages")
await expect(pins).toContainText("muster at eight bells")
await pins.getByRole("button", {name: "Jump to message"}).click()
await expect(pins).toHaveCount(0)
await expect(admin.locator(`[data-event="${pinned.id}"]`)).toBeInViewport()
await banner.getByRole("button", {name: "All pinned messages"}).click()
await pins.getByRole("button", {name: "Unpin"}).click()
await expect(admin.getByText("Message unpinned")).toBeVisible()
await expect(pins).toContainText("No pinned messages.")
await expect(banner).toHaveCount(0)
})
test("US-027 find a past message and jump to it", async ({seed, as}) => {
let lastWeek!: Seeded
let older!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.message(user.bob, "general", "harbor lights are on tonight", at(2, HOUR))
lastWeek = space.message(user.bob, "general", "harbor pilot is booked", at(3, DAY))
older = space.message(user.bob, "general", "harbor dredging starts monday", at(3, WEEK))
})
const {url} = scenario.space("space")
const path = roomPath(url, "general")
const page = await as(users.alice, path)
await expect(message(page, "harbor lights are on tonight")).toBeVisible()
await pageBar(page).getByRole("button", {name: "Search"}).click()
const term = page.getByPlaceholder("Search this room...")
const search = dialog(page, "Search Content")
await term.fill("harbor")
await expect(search.getByText("Last 24 Hours")).toBeVisible()
await expect(search.getByText("Last 7 Days")).toBeVisible()
await expect(search.getByText("Older")).toBeVisible()
await expect(search).toContainText("harbor dredging starts monday")
await term.fill("mizzenmast")
await expect(page.getByText("No results found.")).toBeVisible()
await term.fill("harbor pilot")
await search.getByText("harbor pilot is booked").click()
await expect(search).toHaveCount(0)
await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport()
// A permalink to one message lands on it, with a way back down to the newest
await page.goto(`${path}?at=${older.event.created_at}`)
await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport()
await expect(page.locator(".chat__scroll-down")).toBeVisible()
})
test("US-028 share a message somewhere else", async ({seed, as}) => {
let dm!: SeededRumor
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.room("random", {name: "Random"})
space.join(user.alice, "general", "random")
space.join(user.bob, "general", "random")
space.profile(user.alice, {name: "Alice Anchor"})
space.profile(user.bob, {name: "Bob Barnacle"})
space.message(user.bob, "general", "the dock is closed on sunday", at(2, HOUR))
seedChatter(space, user.alice)
seedChatter(space, user.bob)
dm = space.dm(user.bob, [user.alice], "are you around?", at(20, MINUTE))
})
const {url} = scenario.space("space")
const alice = await as(users.alice, roomPath(url, "general"))
const bob = await as(users.bob, roomPath(url, "random"))
await expect(message(alice, "the dock is closed on sunday")).toBeVisible()
await openMessageMenu(alice, "the dock is closed on sunday")
await alice.getByRole("button", {name: "Share"}).click()
// ShareEvent is titled after the noun it was opened with, so its subtitle names it instead.
const picker = alice
.locator(".dialog")
.filter({hasText: "Which room would you like to share this event to?"})
.last()
await picker.getByRole("button", {name: "Random"}).click()
await picker.getByRole("button", {name: /^Share/}).click()
await expect(alice).toHaveURL(pathPattern(roomPath(url, "random")))
await expect(alice.locator(".room__compose")).toContainText("the dock is closed on sunday")
await send(alice, "heads up")
await expect(message(alice, "heads up")).toContainText("the dock is closed on sunday")
await expect(message(bob, "heads up")).toContainText("the dock is closed on sunday")
// A conversation has no space of its own, so sharing from one searches everywhere she can post
await alice.goto(`/chat/${users.bob.pubkey}`)
const received = alice.locator(`[data-event="${dm.id}"]`)
await expect(received).toContainText("are you around?")
await received.hover()
await received.getByRole("button").first().click()
await alice.getByRole("button", {name: "Share"}).click()
await expect(alice.getByText("Where would you like to share this?")).toBeVisible()
await expect(alice.getByPlaceholder("Search rooms and conversations...")).toBeVisible()
})

589
e2e/specs/settings.spec.ts Normal file
View file

@ -0,0 +1,589 @@
import {MINUTE} from "@welshman/lib"
import {RelayList} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {expect, roomPath, test, users} from "../harness"
import type {SeededSpace, TestUser} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string}
// The user's own relay list. Everything on these pages is published through their outbox, and the
// sync that loads their settings back after a reload hangs off this list, so a spec that saves a
// setting has to seed one.
const relayList = (space: SeededSpace, user: TestUser) =>
space.event(user, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url])
.renderTemplate(),
)
// FieldInline lays a labelled control out as a single row, with the label at one end and the
// control at the other.
const row = (page: Page, label: string) =>
page.locator("div.items-center.justify-between").filter({hasText: label})
const toggle = (page: Page, label: string) => row(page, label).getByRole("checkbox")
// RelaySettingsItem renders one relay list as a button: its name and description at one end, a
// check or a danger triangle and the list's size at the other.
const relayRow = (page: Page, title: string) => page.getByRole("button").filter({hasText: title})
const relayCount = (page: Page, title: string) => relayRow(page, title).locator("div.justify-end")
// The check/warning icon beside that count. Icons are mask-image data urls, so which one is showing
// can only be read by comparing it against a row whose state is already known.
const relayIcon = (page: Page, title: string) =>
relayRow(page, title).locator("div.inline-block").last().getAttribute("style")
// One relay list's modal, which carries the list's name as its heading. Dialog nests two elements
// with the class; the inner one holds the content.
const relayDialog = (page: Page, title: string) =>
page
.locator(".dialog")
.filter({has: page.getByRole("heading", {name: title, exact: true})})
.last()
// The relay picker, which has no heading of its own — and is pushed over the list modal rather
// than alongside it, so it is the only dialog in the dom while it is open.
const relayPicker = (page: Page) => page.locator(".dialog").last()
const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name})
// RelaySettingsHealthCheck renders one issue as a small card with its recommendation beside it.
const issue = (page: Page, title: string) => page.locator(".card-sm").filter({hasText: title})
// A saved setting reaches indexeddb in batches, and a settings page reads its values once when it
// mounts — so a reload only sees the new value after the batch has been flushed. A toast clears
// itself after five seconds, which is longer than the batch window, so waiting it out is what
// makes the assertion that follows about persistence rather than about timing.
const waitForToastToClear = (page: Page) => expect(page.getByRole("alert")).toHaveCount(0)
test("US-083 manage inbox and outbox relays", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.join(user.alice, "general")
// Kind 10002 publishes to every relay it names, and to the indexers, so alice has to be a
// member of both of them for her own edits to be accepted.
other.room("lounge", {name: "Lounge"})
other.join(user.alice, "lounge")
space.event(user.alice, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url, other.url])
.renderTemplate(),
)
})
const page = await as(users.alice, "/settings/relays")
await expect(relayRow(page, "Inbox Relays")).toBeVisible()
await expect(relayRow(page, "Outbox Relays")).toBeVisible()
await expect(relayCount(page, "Inbox Relays")).toHaveText("1")
await expect(relayCount(page, "Outbox Relays")).toHaveText("2")
// alice has no dm relays at all, so this row is the reference for what a warning looks like.
const warningIcon = await relayIcon(page, "DM Relays")
expect(await relayIcon(page, "Outbox Relays")).not.toBe(warningIcon)
await relayRow(page, "Inbox Relays").click()
const inbox = relayDialog(page, "Inbox Relays")
await expect(inbox.getByText("space.test")).toBeVisible()
await expect(inbox.getByText("other.test")).toHaveCount(0)
await inbox.getByRole("button", {name: "Add Relays"}).click()
await page.getByPlaceholder("Search for relays...").fill("other.test")
// The typed url is offered as a custom entry alongside whatever the picker already knows about,
// and both add the same relay.
await relayPicker(page).getByRole("button", {name: "Add Relay"}).first().click()
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(0)
await page.getByRole("button", {name: "Done"}).click()
await expect(relayDialog(page, "Inbox Relays").getByText("other.test")).toBeVisible()
await relayDialog(page, "Inbox Relays").getByRole("button", {name: "Go back"}).click()
await expect(relayCount(page, "Inbox Relays")).toHaveText("2")
await relayRow(page, "Outbox Relays").click()
const outbox = relayDialog(page, "Outbox Relays")
await relayCard(outbox, "other.test").getByRole("button", {name: "Remove"}).click()
await expect(outbox.getByText("space.test")).toBeVisible()
await expect(outbox.getByText("other.test")).toHaveCount(0)
await outbox.getByRole("button", {name: "Go back"}).click()
await expect(relayCount(page, "Outbox Relays")).toHaveText("1")
expect(await relayIcon(page, "Outbox Relays")).toBe(warningIcon)
})
test("US-084 manage DM, search, and blocked relays", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
// A second relay for the pickers to offer. Nobody is a member of it — what puts a relay in
// the suggestion pool is its nip-11 document having been fetched, and every scenario relay is
// an indexer, so bob's client reads this one at startup.
relay("other")
space.room("general", {name: "General"})
space.join(user.bob, "general")
relayList(space, user.bob)
})
const other = scenario.space("other")
const page = await as(users.bob, "/settings/relays", {
// zooid advertises no nip-50, and the search-relay picker offers nothing that doesn't.
relayInfo: {[other.url]: {supported_nips: ["1", "29", "43", "50"]}},
})
await expect(relayCount(page, "DM Relays")).toHaveText("0")
await expect(relayCount(page, "Search Relays")).toHaveText("0")
await relayRow(page, "DM Relays").click()
await relayDialog(page, "DM Relays").getByRole("button", {name: "Add Relays"}).click()
await relayCard(relayPicker(page), "space.test").getByRole("button", {name: "Add Relay"}).click()
await page.getByRole("button", {name: "Done"}).click()
const dms = relayDialog(page, "DM Relays")
await expect(dms.getByText("space.test")).toBeVisible()
await dms.getByRole("button", {name: "Go back"}).click()
await expect(relayCount(page, "DM Relays")).toHaveText("1")
// The same relay is not now a search relay: the two lists are separate.
await relayRow(page, "Search Relays").click()
const search = relayDialog(page, "Search Relays")
await expect(search.getByText("No relay selections found.")).toBeVisible()
await expect(search.getByText("space.test")).toHaveCount(0)
await search.getByRole("button", {name: "Add Relays"}).click()
// Only the relay advertising nip-50 is on offer.
await expect(relayPicker(page).getByText("other.test")).toBeVisible()
await expect(relayPicker(page).getByText("space.test")).toHaveCount(0)
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(1)
await relayPicker(page).getByRole("button", {name: "Add Relay"}).click()
await page.getByRole("button", {name: "Done"}).click()
await relayDialog(page, "Search Relays").getByRole("button", {name: "Go back"}).click()
await expect(relayCount(page, "Search Relays")).toHaveText("1")
await relayRow(page, "Search Relays").click()
await relayCard(relayDialog(page, "Search Relays"), "other.test")
.getByRole("button", {name: "Remove"})
.click()
await relayDialog(page, "Search Relays").getByRole("button", {name: "Go back"}).click()
await expect(relayCount(page, "Search Relays")).toHaveText("0")
await relayRow(page, "Blocked Relays").click()
await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
await relayCard(relayPicker(page), "other.test").getByRole("button", {name: "Add Relay"}).click()
await page.getByRole("button", {name: "Done"}).click()
const blocked = relayDialog(page, "Blocked Relays")
await expect(blocked.getByText("other.test")).toBeVisible()
await blocked.getByRole("button", {name: "Go back"}).click()
await expect(relayCount(page, "Blocked Relays")).toHaveText("1")
// A blocked relay is one bob never wants used, so the other lists stop suggesting it. The search
// picker offered other.test and nothing else a moment ago, which is what makes its absence here
// a statement about blocking rather than about an empty picker.
await relayRow(page, "Search Relays").click()
await relayDialog(page, "Search Relays").getByRole("button", {name: "Add Relays"}).click()
await expect(relayPicker(page).getByPlaceholder("Search for relays...")).toBeVisible()
await expect(relayPicker(page).getByText("other.test")).toHaveCount(0)
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(0)
})
test("US-085 fix relay misconfiguration from the health check", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.join(user.alice, "general")
// A recommendation writes the platform's default relays into the list, which is both of the
// scenario's, so alice has to be a member of both for the new list to be accepted.
other.room("lounge", {name: "Lounge"})
other.join(user.alice, "lounge")
relayList(space, user.alice)
})
const page = await as(users.alice, "/settings/relays")
// Exact, because "3 Issues Detected" is also a substring of "13 Issues Detected".
await expect(page.getByText("3 Issues Detected", {exact: true})).toBeVisible()
await expect(issue(page, "Missing Outbox Relays")).toBeVisible()
await expect(relayCount(page, "Outbox Relays")).toHaveText("1")
await issue(page, "Missing Outbox Relays").getByRole("button", {name: "Update"}).click()
await expect(relayCount(page, "Outbox Relays")).toHaveText("2")
await expect(issue(page, "Missing Outbox Relays")).toHaveCount(0)
await expect(page.getByText("2 Issues Detected", {exact: true})).toBeVisible()
await page.getByRole("button", {name: "Apply All Recommendations"}).click()
await expect(page.getByText("0 Issues Detected", {exact: true})).toBeVisible()
await expect(page.getByRole("button", {name: "Apply All Recommendations"})).toHaveCount(0)
})
test("US-086 configure alerts", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
relayList(space, user.alice)
})
const page = await as(users.alice, "/settings/alerts")
const sound = toggle(page, "Play sound for new activity")
const push = toggle(page, "Enable push notifications")
const alertTypes = page.locator("div.card").filter({has: page.getByText("Alert Types")})
await expect(sound).toBeChecked()
await expect(push).not.toBeChecked()
await expect(alertTypes).not.toHaveClass(/opacity-50/)
// With nothing left to be alerted through, there is nothing to be alerted about.
await sound.uncheck()
await expect(alertTypes).toHaveClass(/opacity-50/)
await page.getByRole("button", {name: "Discard Changes"}).click()
await expect(sound).toBeChecked()
await expect(alertTypes).not.toHaveClass(/opacity-50/)
// Push asks the browser for permission, and this context was never granted it.
await push.check()
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Failed to request notification permissions")
await expect(push).not.toBeChecked()
await expect(sound).toBeChecked()
await waitForToastToClear(page)
await sound.uncheck()
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
await waitForToastToClear(page)
await page.reload()
await expect(toggle(page, "Play sound for new activity")).not.toBeChecked()
})
test("US-087 configure content display", async ({seed, as}) => {
let picture!: Seeded
let link!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
picture = space.message(user.bob, "general", "https://images.test/sunset.png", at(40, MINUTE))
link = space.message(user.bob, "general", "https://example.test/announcement", at(35, MINUTE))
relayList(space, user.alice)
})
const {url} = scenario.space("space")
const page = await as(users.alice, "/settings/content")
const hideSensitive = toggle(page, "Hide sensitive content?")
const showMedia = toggle(page, "Show media?")
await expect(hideSensitive).toBeChecked()
await expect(showMedia).toBeChecked()
await hideSensitive.uncheck()
await showMedia.uncheck()
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
await waitForToastToClear(page)
await page.goto(roomPath(url, "general"))
const pictureMessage = page.locator(`[data-event="${picture.id}"]`)
const linkMessage = page.locator(`[data-event="${link.id}"]`)
await expect(pictureMessage.getByRole("link", {name: "images.test/sunset.png"})).toBeVisible()
await expect(pictureMessage.locator('img[src="https://images.test/sunset.png"]')).toHaveCount(0)
await expect(linkMessage.getByRole("link", {name: "example.test/announcement"})).toBeVisible()
await expect(linkMessage.locator(".spinner")).toHaveCount(0)
await expect(linkMessage.getByText("Unable to load a preview")).toHaveCount(0)
await page.goto("/settings/content")
await expect(toggle(page, "Hide sensitive content?")).not.toBeChecked()
})
test("US-088 adjust send delay and media servers", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
relayList(space, user.alice)
})
const page = await as(users.alice, "/settings/content")
// The send delay is the only slider on this page.
const sendDelay = page.locator('input[type="range"]')
const servers = page.getByRole("listitem")
await expect(page.getByText("Delay sending chat messages for 0 seconds.")).toBeVisible()
await sendDelay.fill("3000")
await expect(page.getByText("Delay sending chat messages for 3 seconds.")).toBeVisible()
await expect(servers).toHaveCount(0)
await page.getByRole("button", {name: "Add Server"}).click()
await expect(servers).toHaveCount(1)
await servers.first().locator("input").fill("https://media.test/")
await page.getByRole("button", {name: "Add Server"}).click()
await expect(servers).toHaveCount(2)
// Each row leads with its own remove button; the drag handle is the one with a label.
await servers.nth(1).getByRole("button").first().click()
await expect(servers).toHaveCount(1)
await expect(servers.first().locator("input")).toHaveValue("https://media.test/")
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
await waitForToastToClear(page)
await page.reload()
await expect(page.getByText("Delay sending chat messages for 3 seconds.")).toBeVisible()
await expect(page.locator('input[type="range"]')).toHaveValue("3000")
})
test("US-089 configure privacy preferences", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
relayList(space, user.alice)
})
const page = await as(users.alice, "/settings/privacy")
const auth = toggle(page, "Authenticate with unknown relays?")
const usage = toggle(page, "Report usage?")
await expect(auth).not.toBeChecked()
await expect(usage).toBeChecked()
await auth.check()
await usage.uncheck()
await page.getByRole("button", {name: "Discard Changes"}).click()
await expect(auth).not.toBeChecked()
await expect(usage).toBeChecked()
await auth.check()
await usage.uncheck()
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
await waitForToastToClear(page)
await page.reload()
await expect(toggle(page, "Authenticate with unknown relays?")).toBeChecked()
await expect(toggle(page, "Report usage?")).not.toBeChecked()
})
test("US-090 change the app's appearance", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
relayList(space, user.alice)
})
const page = await as(users.alice, "/settings/theme", {context: {colorScheme: "light"}})
const body = page.locator("body")
await expect(body).toHaveAttribute("data-theme", "light")
await page.getByRole("button", {name: "Dark", exact: true}).click()
await expect(body).toHaveAttribute("data-theme", "dark")
await page.getByRole("button", {name: "System", exact: true}).click()
await expect(body).toHaveAttribute("data-theme", "light")
// System means the device's, so changing the device's changes the app's.
await page.emulateMedia({colorScheme: "dark"})
await expect(body).toHaveAttribute("data-theme", "dark")
await page.getByLabel("Style").selectOption("navy")
await expect(body).toHaveAttribute("data-fl-theme", "navy")
await page.reload()
await expect(body).toHaveAttribute("data-fl-theme", "navy")
await expect(page.getByLabel("Style")).toHaveValue("navy")
// Font size is the only slider on this page, and the only setting here that is published.
const fontSize = page.locator('input[type="range"]')
await expect(page.getByText("110%")).toBeVisible()
await expect(page.getByRole("button", {name: "Save Changes"})).toHaveCount(0)
await fontSize.fill("1.25")
await expect(page.getByText("125%")).toBeVisible()
await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/)
// Moving the slider is the save, so the size the document is rendered at survives a reload
// without anything else having been pressed.
await page.reload()
await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/)
})
test("US-091 set up how people zap you", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.profile(user.alice, {name: "Alice Anders"})
relayList(space, user.alice)
})
const page = await as(users.alice, "/settings/wallet")
await expect(page.getByText("Not set")).toBeVisible()
await page.getByRole("button", {name: "Update"}).click()
const address = page
.locator(".dialog")
.filter({has: page.getByRole("heading", {name: "Update Lightning Address"})})
.last()
await address.getByPlaceholder("user@domain.com").fill("alice@example.test")
await address.getByRole("button", {name: "Save Changes"}).click()
// The dialog closes itself once the profile has gone out, and the page behind it carries its own
// "Save Changes" — so wait it out rather than leaving that name ambiguous.
await expect(address).toHaveCount(0)
await expect(page.getByText("alice@example.test")).toBeVisible()
await expect(page.getByText("Not set")).toHaveCount(0)
await page.getByRole("button", {name: "Update"}).click()
await address.getByPlaceholder("user@domain.com").fill("")
await address.getByRole("button", {name: "Save Changes"}).click()
await expect(address).toHaveCount(0)
await expect(page.getByText("Not set")).toBeVisible()
// Each preset is a row: its own remove button and the amount side by side.
const presets = page.locator("form div.items-center.gap-2")
await expect(presets).toHaveCount(4)
await page.getByRole("button", {name: "Add amount"}).click()
await expect(presets).toHaveCount(5)
// A preset has to be worth something, so saving a zero is refused with an error.
await presets.nth(4).locator("input").fill("0")
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Zap amounts must be greater than zero.")
await waitForToastToClear(page)
// Discarding puts back the last saved values, which are still alice's original four — the zero
// never reached them.
await page.getByRole("button", {name: "Discard Changes"}).click()
await expect(presets).toHaveCount(4)
await presets.nth(3).getByRole("button").click()
await presets.nth(2).getByRole("button").click()
await presets.nth(1).getByRole("button").click()
await expect(presets).toHaveCount(1)
await expect(presets.first().getByRole("button")).toBeDisabled()
await presets.first().locator("input").fill("500")
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(page.getByRole("alert")).toContainText("Your zap amounts have been saved!")
await waitForToastToClear(page)
await page.reload()
const saved = page.locator("form div.items-center.gap-2")
await expect(saved).toHaveCount(1)
await expect(saved.first().locator("input")).toHaveValue("500")
})

545
e2e/specs/spaces.spec.ts Normal file
View file

@ -0,0 +1,545 @@
import {DAY, HOUR, MINUTE, WEEK} from "@welshman/lib"
import {THREAD, makeEvent} from "@welshman/util"
import {expect, roomPath, spacePath, test, users} from "../harness"
test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
const unsigned = relay("unsigned")
space.room("general", {name: "General"})
other.room("lounge", {name: "Lounge"})
unsigned.room("hall", {name: "Hall"})
// Alice's own two spaces, declared in the order her room list should start out in
space.join(user.alice, "general")
other.join(user.alice, "lounge")
// A space she hasn't joined only becomes browsable through somebody else's room list
space.join(user.bob, "general")
unsigned.join(user.bob, "hall")
})
const space = scenario.space("space")
const other = scenario.space("other")
const unsigned = scenario.space("unsigned")
// The spaces page discovers unjoined spaces by pulling the room lists of the pubkeys it
// bootstraps from, so pointing that at bob is what puts his other space in front of alice.
const page = await as(users.alice, "/spaces", {env: {VITE_DEFAULT_PUBKEYS: users.bob.pubkey}})
await expect(page.getByText("Your spaces")).toBeVisible()
await expect(page.getByText(space.url)).toBeVisible()
await expect(page.getByText(other.url)).toBeVisible()
// ...and the rest of them in a section of their own
await expect(page.getByText("Browse Spaces")).toBeVisible()
await expect(page.getByText(unsigned.url)).toBeVisible()
const term = page.getByPlaceholder("Search for spaces...")
await term.fill("unsigned")
await expect(page.getByText(unsigned.url)).toBeVisible()
await expect(page.getByText(space.url)).toHaveCount(0)
await expect(page.getByText(other.url)).toHaveCount(0)
await term.fill("")
await expect(page.getByText(space.url)).toBeVisible()
// A space she's joined opens
await page.getByRole("listitem").filter({hasText: space.url}).click()
await expect(page).toHaveURL(/\/spaces\/space\.test\//)
// A space she hasn't asks her to join first
await page.goto("/spaces")
await page.getByRole("button").filter({hasText: unsigned.url}).click()
await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible()
await expect(page.getByRole("button", {name: "Go back"})).toBeEnabled()
// Reordering by dragging, which lives in her room list and so outlives the page
await page.goto("/spaces")
const joined = page.getByRole("listitem")
await expect(joined.first()).toContainText(space.url)
await joined.filter({hasText: other.url}).dragTo(joined.filter({hasText: space.url}))
await expect(joined.first()).toContainText(other.url)
await page.reload()
await expect(page.getByRole("listitem").first()).toContainText(other.url)
})
test("US-010 join a space from an invite link", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
other.room("lounge", {name: "Lounge"})
space.join(user.alice, "general")
})
const other = scenario.space("other")
const page = await as(users.alice, spacePath(other.url))
// Arriving at a space she hasn't joined raises the join prompt on its own
await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled()
await expect(page.getByText(other.url)).toBeVisible()
await page.getByRole("button", {name: "Go back"}).click()
// Going back left her un-joined, so arriving again asks again
await page.goto(spacePath(other.url))
await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled()
await page.goto("/spaces")
await page.getByRole("button", {name: "Add Space"}).click()
await page.getByRole("button", {name: /Join a space/}).click()
const invite = page.locator("form").filter({hasText: "Invite Link*"}).getByRole("textbox")
await invite.fill("not an invite")
await expect(page.getByRole("button", {name: "Join Space"})).toBeDisabled()
await expect(page.getByText("You're about to join:")).toHaveCount(0)
// The link makeInviteLink builds, typed rather than followed — an absolute platform url is an
// off-origin navigation, and parseInviteLink only ever reads its query params.
await invite.fill("https://app.flotilla.social/join?r=other.test&c=")
await expect(page.getByText("You're about to join:")).toBeVisible()
await expect(page.getByText(other.url)).toBeVisible()
await page.getByRole("button", {name: "Join Space"}).click()
await expect(page.getByText("Welcome to the space!")).toBeVisible()
await expect(page).toHaveURL(/\/spaces\/other\.test/)
})
test("US-011 request access when a space turns you away", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const closed = relay("closed")
const space = relay("space")
// This relay refuses a join that carries no claim. A claim is not an event a scenario can
// publish — the relay only registers one through the nip-86 method the invite dialog calls —
// so the code alice needs comes out of that dialog below.
closed.room("lobby", {name: "Lobby"})
space.room("general", {name: "General"})
space.join(user.admin, "general")
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle"})
})
const closed = scenario.space("closed")
const space = scenario.space("space")
// Administering a relay is not the same as belonging to it, so the space asks admin to join
// like anyone else — and issues him an invite code all the same.
const admin = await as(users.admin, spacePath(closed.url) + "/about")
await admin.locator("form").getByRole("button", {name: "Go back"}).click()
await admin.getByRole("button", {name: /closed\.test/}).click()
await admin.getByRole("button", {name: "Create Invite"}).click()
const inviteLink = admin.locator("input[readonly]")
await expect(inviteLink).toHaveValue(/[?&]c=\w/)
const [, claim = ""] = (await inviteLink.inputValue()).match(/[?&]c=([^&]+)/) ?? []
const alice = await as(users.alice, spacePath(closed.url))
// Turned away rather than joined, so the prompt becomes a request
await expect(alice.getByRole("button", {name: "Request Access"})).toBeEnabled()
await alice.getByRole("button", {name: "Request Access"}).click()
await expect(alice.getByRole("heading", {name: "Request Access"})).toBeVisible()
const code = alice.locator("form").filter({hasText: "Invite code*"}).getByRole("textbox")
await code.fill("NOTTHECODE")
await alice.getByRole("button", {name: "Join Space"}).click()
// The relay refused the code, so she is told and left asking
await expect(alice.getByRole("alert")).toContainText("join request rejected")
await expect(alice.getByRole("heading", {name: "Request Access"})).toBeVisible()
await code.fill(claim)
await alice.getByRole("button", {name: "Join Space"}).click()
// The correct code granted her access, so the space stops turning her away
await expect(alice.getByRole("heading", {name: "Request Access"})).toHaveCount(0)
await expect(alice.getByRole("button", {name: "Join Space"})).toBeEnabled()
await alice.getByRole("button", {name: "Join Space"}).click()
await expect(alice.getByText("Welcome to the space!")).toBeVisible()
await expect(alice).toHaveURL(/\/spaces\/closed\.test/)
// A member the admin bans is told why by the relay itself
await admin.goto(spacePath(space.url) + "/directory")
const bobCard = admin
.getByRole("button", {name: "View Bob Barnacle's profile"})
.locator("xpath=..")
await bobCard.getByRole("button").last().click()
await admin.getByRole("button", {name: "Ban member"}).click()
await expect(admin.getByText("Ban @Bob Barnacle from the space?")).toBeVisible()
await admin.getByRole("button", {name: "Confirm"}).click()
await expect(admin.getByText("Member has successfully been banned!")).toBeVisible()
const bob = await as(users.bob, spacePath(space.url))
await expect(bob.getByRole("heading", {name: "Access Error"})).toBeVisible()
await expect(bob.getByText(/not a member of this relay/i)).toBeVisible()
// From here he can ask for a code back in
await bob.getByRole("button", {name: "Request Access"}).click()
await expect(bob.getByRole("heading", {name: "Request Access"})).toBeVisible()
await bob.getByRole("button", {name: "Go back"}).click()
// ...or give up on the space, which takes it off his list
await bob.getByRole("button", {name: "Leave Space"}).click()
await expect(bob).toHaveURL(/\/home/)
await bob.goto("/spaces")
await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible()
})
test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const unsigned = relay("unsigned")
unsigned.room("general", {name: "General"})
unsigned.join(user.alice, "general")
unsigned.join(user.bob, "general")
unsigned.message(user.alice, "general", "the hull is patched")
})
const unsigned = scenario.space("unsigned")
// This relay serves events with their signatures stripped, so it can forge messages
const bob = await as(users.bob, roomPath(unsigned.url, "general"))
await expect(bob.getByRole("heading", {name: "Do you trust this space?"})).toBeVisible()
await bob.getByRole("button", {name: "I don't trust this space"}).click()
await expect(bob).toHaveURL(/\/home/)
await bob.goto("/spaces")
await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible()
const alice = await as(users.alice, roomPath(unsigned.url, "general"))
await expect(alice.getByRole("heading", {name: "Do you trust this space?"})).toBeVisible()
await alice.getByRole("button", {name: "I trust this space, continue"}).click()
await expect(alice.getByRole("heading", {name: "Do you trust this space?"})).toHaveCount(0)
await expect(alice.getByText("the hull is patched")).toBeVisible()
})
test("US-013 follow a space that has moved", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
other.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
// The new address has to let her in once her list points at it
other.member(user.alice)
})
const space = scenario.space("space")
const other = scenario.space("other")
// The relay's own nip-11 document is what reports the move
const relayInfo = {[space.url]: {redirect_to: "https://other.test/"}}
const alice = await as(users.alice, spacePath(space.url) + "/about", {relayInfo})
const movedDialog = alice.locator("form")
await expect(alice.getByRole("heading", {name: "This space has moved"})).toBeVisible()
await expect(movedDialog.getByText("space.test", {exact: true})).toBeVisible()
await expect(movedDialog.getByText("other.test", {exact: true})).toBeVisible()
await alice.getByRole("button", {name: "Update and go"}).click()
await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/)
await alice.goto("/spaces")
const aliceSpaces = alice.getByRole("listitem")
await expect(aliceSpaces).toHaveCount(1)
await expect(aliceSpaces.first()).toContainText(other.url)
const bob = await as(users.bob, spacePath(space.url) + "/about", {relayInfo})
await expect(bob.getByRole("heading", {name: "This space has moved"})).toBeVisible()
await bob.getByRole("button", {name: "Not now"}).click()
await expect(bob).toHaveURL(/\/spaces\/space\.test\/about/)
await bob.goto("/spaces")
const bobSpaces = bob.getByRole("listitem")
await expect(bobSpaces).toHaveCount(1)
await expect(bobSpaces.first()).toContainText(space.url)
})
test("US-014 leave a space", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.bob, "general")
space.message(user.bob, "general", "signing off for a while")
})
const space = scenario.space("space")
const page = await as(users.bob, spacePath(space.url) + "/about")
await page.getByRole("button", {name: /space\.test/}).click()
await page.getByRole("button", {name: "Leave Space"}).click()
await expect(page.getByText("Are you sure you want to leave?")).toBeVisible()
await page.getByRole("button", {name: "Confirm"}).click()
await expect(page).toHaveURL(/\/home/)
await page.goto("/spaces")
await expect(page.getByText("You haven't joined any spaces yet.")).toBeVisible()
// Nothing stops him coming back
await page.getByRole("button", {name: "Add Space"}).click()
await page.getByRole("button", {name: /Join a space/}).click()
await page
.locator("form")
.filter({hasText: "Invite Link*"})
.getByRole("textbox")
.fill("https://app.flotilla.social/join?r=space.test&c=")
await expect(page.getByText("You're about to join:")).toBeVisible()
await page.getByRole("button", {name: "Join Space"}).click()
await expect(page.getByText("Welcome to the space!")).toBeVisible()
await expect(page).toHaveURL(/\/spaces\/space\.test/)
})
test("US-015 view a space's details", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.admin, "general")
space.join(user.carol, "general")
space.join(user.alice, "general")
space.profile(user.alice, {name: "Alice Anchor"})
space.message(user.alice, "general", "the tide is high", at(1, HOUR))
})
const space = scenario.space("space")
// Contact, terms, privacy and the limitation warnings are nip-11 fields zooid doesn't publish
// on its own, so the scenario merges them over the relay's real document.
const relayInfo = {
[space.url]: {
icon: "https://space.test/icon.png",
contact: "harbormaster@space.test",
terms_of_service: "https://space.test/terms",
privacy_policy: "https://space.test/privacy",
limitation: {auth_required: true, payment_required: true, min_pow_difficulty: 20},
},
}
const carol = await as(users.carol, spacePath(space.url) + "/about", {relayInfo})
await expect(carol.locator('img[src="https://space.test/icon.png"]').first()).toBeVisible()
await expect(carol.getByRole("heading", {name: "space"})).toBeVisible()
await expect(carol.getByText("space.test", {exact: true}).first()).toBeVisible()
await expect(carol.getByText(/Throwaway relay/)).toBeVisible()
await expect(carol.getByRole("link", {name: "Terms of Service"})).toBeVisible()
await expect(carol.getByRole("link", {name: "Privacy Policy"})).toBeVisible()
await expect(carol.getByText(/Administrator:/)).toBeVisible()
await expect(carol.getByText("Contact: harbormaster@space.test")).toBeVisible()
await expect(carol.getByText(/Software:/)).toBeVisible()
await expect(carol.getByText(/Version:/)).toBeVisible()
await expect(carol.getByText("Auth Required")).toBeVisible()
await expect(carol.getByText("Payment Required")).toBeVisible()
await expect(carol.getByText("Min PoW: 20")).toBeVisible()
await expect(carol.getByRole("heading", {name: "Members"})).toBeVisible()
await expect(carol.getByText("Admins")).toBeVisible()
await expect(carol.getByText("New members")).toBeVisible()
// Nothing is featured yet, so recent activity stands in for it
await expect(carol.getByRole("heading", {name: "Recent Activity"})).toBeVisible()
await expect(carol.getByText("the tide is high")).toBeVisible()
const admin = await as(users.admin, spacePath(space.url) + "/about", {relayInfo})
const featuredHeader = admin.getByRole("heading", {name: "Featured"}).locator("xpath=..")
await featuredHeader.getByRole("button").click()
await admin.getByRole("button", {name: "Add content"}).click()
await admin.getByPlaceholder("URL or nevent...").fill("Start with the harbor rules")
await admin.getByRole("button", {name: "Save changes"}).click()
await expect(admin.getByText("Featured content updated!")).toBeVisible()
// What admin featured reaches every visitor, and takes the top slot from recent activity
await expect(carol.getByRole("heading", {name: "Featured"})).toBeVisible()
await expect(carol.getByText("Start with the harbor rules")).toBeVisible()
await carol.getByRole("link", {name: "View all members"}).click()
await expect(carol).toHaveURL(/\/spaces\/space\.test\/directory/)
})
test("US-016 catch up on a space's recent activity", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.room("quiet", {name: "Quiet Corner"})
space.join(user.alice, "general", "quiet")
space.join(user.bob, "general", "quiet")
space.message(user.alice, "general", "the tide is high", at(30, MINUTE))
space.message(user.alice, "quiet", "anyone still here?", at(3, HOUR))
space.event(
user.alice,
makeEvent(THREAD, {
created_at: at(2, HOUR),
content: "Where should we sail next?",
tags: [
["h", "general"],
["title", "Next voyage"],
],
}),
)
// More items than the page renders at once, so the oldest is only reachable by scrolling
for (let day = 1; day <= 20; day++) {
space.event(
user.alice,
makeEvent(THREAD, {
created_at: at(day, DAY),
content: `Log entry ${day}`,
tags: [
["h", "general"],
["title", `Old voyage ${day}`],
],
}),
)
}
// A space with a member and nothing else
other.join(user.bob)
})
const space = scenario.space("space")
const other = scenario.space("other")
const bob = await as(users.bob, spacePath(space.url) + "/recent")
const items = bob.locator(".cv")
await expect(bob.getByText("the tide is high")).toBeVisible()
await expect(bob.getByText("anyone still here?")).toBeVisible()
await expect(bob.getByText("Next voyage")).toBeVisible()
// Newest first: the latest message in each room alongside the thread, in one feed
await expect(items.first()).toContainText("the tide is high")
await bob.mouse.move(640, 400)
await bob.mouse.wheel(0, 8000)
await expect(bob.getByText("Old voyage 20")).toBeVisible()
// A message in the room that had gone quiet pulls it back to the top
const alice = await as(users.alice, roomPath(space.url, "quiet"))
await alice.locator(".chat-editor [contenteditable=true]").pressSequentially("still here!")
await alice.locator(".chat-editor [contenteditable=true]").press("Enter")
await expect(alice.getByText("still here!")).toBeVisible()
await expect(items.first()).toContainText("Quiet Corner")
await bob.goto(spacePath(other.url) + "/recent")
await expect(bob.getByText("No recent activity found!")).toBeVisible()
})
test("US-017 search across a space", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.room("random", {name: "Random"})
space.join(user.alice, "general", "random")
space.message(user.alice, "general", "the kraken surfaced at dawn", at(2, HOUR))
space.message(user.alice, "random", "kraken jokes only in here", at(3, DAY))
space.message(user.alice, "general", "ancient kraken lore, volume one", at(3, WEEK))
})
const space = scenario.space("space")
// The directory rather than a feed: search is reachable from every page in a space, and this is
// one where a result's own text can't also be on the page behind the dialog.
const page = await as(users.alice, spacePath(space.url) + "/directory")
await page.locator(".secondary-nav").getByRole("button", {name: "Search"}).click()
const term = page.getByPlaceholder("Search this space...")
await term.fill("narwhal")
await expect(page.getByText("No results found.")).toBeVisible()
await term.fill("kraken")
await expect(page.getByText("Last 24 Hours")).toBeVisible()
await expect(page.getByText("Last 7 Days")).toBeVisible()
await expect(page.getByText("Older")).toBeVisible()
await expect(page.getByText("the kraken surfaced at dawn")).toBeVisible()
await expect(page.getByText("ancient kraken lore, volume one")).toBeVisible()
const fromRandom = page.getByRole("button").filter({hasText: "kraken jokes only in here"})
await expect(fromRandom).toContainText("Random")
await fromRandom.click()
await expect(page).toHaveURL(/\/spaces\/space\.test\/random\?at=/)
})