From aa2483fe78680c21ff5667870ff7ee912c02b97b Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 20 Aug 2026 13:33:20 -0700 Subject: [PATCH] Add tests for all user stories --- e2e/ARCHITECTURE.md | 70 +- e2e/SUSPECTED_BUGS.md | 108 ++ e2e/harness/app/boot.ts | 6 +- e2e/harness/app/nip07.ts | 61 + e2e/harness/index.ts | 73 +- e2e/harness/keys.ts | 34 +- e2e/harness/net/http.ts | 288 ++++- e2e/harness/seed/space.ts | 97 +- e2e/harness/zooid/config.ts | 4 + e2e/harness/zooid/docker/compose.yaml | 8 +- e2e/harness/zooid/docker/config/closed.toml | 48 + e2e/harness/zooid/docker/config/unsigned.toml | 43 + e2e/harness/zooid/relay.ts | 65 +- e2e/harness/zooid/testRelay.ts | 5 +- e2e/harness/zooid/types.ts | 10 + e2e/specs/admin.spec.ts | 1120 ++++++++++++++++ e2e/specs/articles-threads.spec.ts | 937 ++++++++++++++ e2e/specs/community.spec.ts | 1142 +++++++++++++++++ e2e/specs/composer.spec.ts | 496 +++++++ e2e/specs/content-rendering.spec.ts | 496 +++++++ e2e/specs/delivery.spec.ts | 608 +++++++++ e2e/specs/dms.spec.ts | 613 +++++++++ e2e/specs/notifications.spec.ts | 420 ++++++ e2e/specs/onboarding.spec.ts | 413 ++++++ e2e/specs/people.spec.ts | 771 +++++++++++ e2e/specs/rooms.spec.ts | 771 +++++++++++ e2e/specs/settings.spec.ts | 589 +++++++++ e2e/specs/spaces.spec.ts | 545 ++++++++ 28 files changed, 9744 insertions(+), 97 deletions(-) create mode 100644 e2e/SUSPECTED_BUGS.md create mode 100644 e2e/harness/app/nip07.ts create mode 100644 e2e/harness/zooid/docker/config/closed.toml create mode 100644 e2e/harness/zooid/docker/config/unsigned.toml create mode 100644 e2e/specs/admin.spec.ts create mode 100644 e2e/specs/articles-threads.spec.ts create mode 100644 e2e/specs/community.spec.ts create mode 100644 e2e/specs/composer.spec.ts create mode 100644 e2e/specs/content-rendering.spec.ts create mode 100644 e2e/specs/delivery.spec.ts create mode 100644 e2e/specs/dms.spec.ts create mode 100644 e2e/specs/notifications.spec.ts create mode 100644 e2e/specs/onboarding.spec.ts create mode 100644 e2e/specs/people.spec.ts create mode 100644 e2e/specs/rooms.spec.ts create mode 100644 e2e/specs/settings.spec.ts create mode 100644 e2e/specs/spaces.spec.ts diff --git a/e2e/ARCHITECTURE.md b/e2e/ARCHITECTURE.md index bafc6a07..98a15e24 100644 --- a/e2e/ARCHITECTURE.md +++ b/e2e/ARCHITECTURE.md @@ -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 `.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 diff --git a/e2e/SUSPECTED_BUGS.md b/e2e/SUSPECTED_BUGS.md new file mode 100644 index 00000000..aca432ad --- /dev/null +++ b/e2e/SUSPECTED_BUGS.md @@ -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:`) 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. diff --git a/e2e/harness/app/boot.ts b/e2e/harness/app/boot.ts index 0ff377a8..707d36ba 100644 --- a/e2e/harness/app/boot.ts +++ b/e2e/harness/app/boot.ts @@ -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 } 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, ) diff --git a/e2e/harness/app/nip07.ts b/e2e/harness/app/nip07.ts new file mode 100644 index 00000000..ed3b39a5 --- /dev/null +++ b/e2e/harness/app/nip07.ts @@ -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) +} diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts index 6db81b91..19aeb232 100644 --- a/e2e/harness/index.ts +++ b/e2e/harness/index.ts @@ -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 + // 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): Promise // 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 + // the relays every other user is talking to. + as(user: TestUser, path?: string, options?: PageOptions): Promise + // 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 } 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({ // 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({ 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({ 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({ // 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({ }, seed: async ({harness}, use) => use(harness.seed), as: async ({harness}, use) => use(harness.as), + visit: async ({harness}, use) => use(harness.visit), }) diff --git a/e2e/harness/keys.ts b/e2e/harness/keys.ts index d17e7a69..55c10053 100644 --- a/e2e/harness/keys.ts +++ b/e2e/harness/keys.ts @@ -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() + +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) +} diff --git a/e2e/harness/net/http.ts b/e2e/harness/net/http.ts index 5c14b8d6..8970063c 100644 --- a/e2e/harness/net/http.ts +++ b/e2e/harness/net/http.ts @@ -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 + +/** + * 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}[] } +/** + * 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 + +// 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() + +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. diff --git a/e2e/harness/seed/space.ts b/e2e/harness/seed/space.ts index d4cb9992..bedd9b55 100644 --- a/e2e/harness/seed/space.ts +++ b/e2e/harness/seed/space.ts @@ -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) + 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>( + factory: KindFactory, + ): ConfiguredKind } 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): SeededEvent => { - let signed: Maybe + // Queues a write and hands back a getter for whatever it produced, which only reads once the + // scenario's queue has drained. + const seeded = (write: () => Promise) => { + let value: Maybe - 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): 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 = >(factory: KindFactory) => + 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, } } diff --git a/e2e/harness/zooid/config.ts b/e2e/harness/zooid/config.ts index ed92de58..893fa728 100644 --- a/e2e/harness/zooid/config.ts +++ b/e2e/harness/zooid/config.ts @@ -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 diff --git a/e2e/harness/zooid/docker/compose.yaml b/e2e/harness/zooid/docker/compose.yaml index 4e708166..1f215a55 100644 --- a/e2e/harness/zooid/docker/compose.yaml +++ b/e2e/harness/zooid/docker/compose.yaml @@ -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: diff --git a/e2e/harness/zooid/docker/config/closed.toml b/e2e/harness/zooid/docker/config/closed.toml new file mode 100644 index 00000000..aafa650e --- /dev/null +++ b/e2e/harness/zooid/docker/config/closed.toml @@ -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 diff --git a/e2e/harness/zooid/docker/config/unsigned.toml b/e2e/harness/zooid/docker/config/unsigned.toml new file mode 100644 index 00000000..59a42310 --- /dev/null +++ b/e2e/harness/zooid/docker/config/unsigned.toml @@ -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 diff --git a/e2e/harness/zooid/relay.ts b/e2e/harness/zooid/relay.ts index fb419da2..0ac10acd 100644 --- a/e2e/harness/zooid/relay.ts +++ b/e2e/harness/zooid/relay.ts @@ -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>> // 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)) diff --git a/e2e/harness/zooid/testRelay.ts b/e2e/harness/zooid/testRelay.ts index 741aaa58..b9a730c8 100644 --- a/e2e/harness/zooid/testRelay.ts +++ b/e2e/harness/zooid/testRelay.ts @@ -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 + publish: (event: SignedEvent, options?: PublishOptions) => Promise } // 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. diff --git a/e2e/harness/zooid/types.ts b/e2e/harness/zooid/types.ts index 021b688c..c85c57c1 100644 --- a/e2e/harness/zooid/types.ts +++ b/e2e/harness/zooid/types.ts @@ -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 // Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs. event(user: TestUser, event: StampedEvent): Promise + // 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 } // One client's connection to a relay. Every connection to a url reaches the same container, which diff --git a/e2e/specs/admin.spec.ts b/e2e/specs/admin.spec.ts new file mode 100644 index 00000000..25a8f578 --- /dev/null +++ b/e2e/specs/admin.spec.ts @@ -0,0 +1,1120 @@ +import type {Locator, Page} from "@playwright/test" +import {DAY, HOUR, MINUTE, MONTH, now} from "@welshman/lib" +import {Article} from "@welshman/domain" +import { + expect, + getHosting, + makeTestUser, + mockBlossom, + roomPath, + spacePath, + test, + users, +} from "../harness" + +// Where an icon upload lands. SpaceEdit and RelayForm both call uploadFileOrFallback with no relay +// of their own, so the space's blossom probe never runs and VITE_DEFAULT_BLOSSOM_SERVERS is what's +// left. +const BLOSSOM_ORIGIN = "https://blossom.primal.net" + +// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched +// instead of re-encoding it through a canvas, so what the mock hashes is what was picked. +const ICON = { + name: "icon.gif", + mimeType: "image/gif", + buffer: Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"), +} + +// The plans the hosting backend offers. `free` is what RelayForm starts on, and `basic` is the paid +// one every upgrade story moves to. PricingTable names a plan by its member limit, which is the +// only part of a card that differs between the two. +const PLANS = [ + {id: "free", name: "Free", amount: 0, hidden: false, members: 50, blossom: false, livekit: false}, + { + id: "basic", + name: "Basic", + amount: 500, + hidden: false, + // Plan["members"] is number | null upstream; null is how the hosting API spells "unlimited". + // eslint-disable-next-line no-restricted-syntax + members: null, + blossom: true, + livekit: true, + }, +] + +// One relay as the hosting api serves it. Every field RelayDetailCard reads is present, since it +// calls .trim() and .replace() on several of them. +const hostedRelay = (overrides: Record = {}) => ({ + id: "relay-1", + tenant_pubkey: users.alice.pubkey, + subdomain: "space", + zooid_domain: "test", + plan_id: "free", + status: "active", + sync_error: "", + synced: now(), + custom_domain: "", + custom_domain_verified: 0, + info_name: "Space", + info_icon: "", + info_description: "", + policy_public_read: 0, + policy_public_write: 0, + policy_public_join: 1, + policy_strip_signatures: 0, + groups_enabled: 1, + management_enabled: 1, + blossom_enabled: 0, + livekit_enabled: 0, + push_enabled: 1, + ...overrides, +}) + +// 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() + +// Tippy mounts a menu the first time it is opened and leaves it in the dom when it hides, so a page +// that has opened two of them holds both — only the one on screen is visible. Exact, because +// "Edit role" and "Edit roles" are two different menus in the same directory. +const menuItem = (page: Page, name: string) => + page.getByRole("button", {name, exact: true}).filter({visible: true}) + +// Every space-level admin action hangs off the space menu, which opens from the header button in +// the secondary nav. That button is labeled with the space's name and its host, and only the host +// survives a rename. +const openSpaceMenu = (page: Page) => page.getByRole("button", {name: /space\.test/}).click() + +// SpaceMember covers its card with a button whose aria-label is baked from the profile display at +// first render — before the profile has loaded — so the card is found by the name it shows rather +// than by that label. It is the only interactive card in the directory, and the member's own menu +// is the last button inside it. +const memberCard = (page: Page, name: string) => + page.locator(".card-interactive").filter({hasText: name}) + +// RoomItem gives its hover actions no accessible names — every one is an icon — and the menu is +// the last of them. +const openMessageMenu = (page: Page, text: string) => + page + .locator(".room__item") + .filter({hasText: text}) + .locator(".room__item-actions button") + .last() + .click() + +// EventActions renders zap, emoji and menu into one join, so the menu is the last button in it. +const openEventMenu = (card: Locator) => card.locator(".join").getByRole("button").last().click() + +const articleCard = (page: Page, title: string) => + page.getByRole("link", {name: /Written by/}).filter({hasText: title}) + +// A FieldInline puts its control in the div immediately after its label, which is how one row of +// the hosting card is told apart from the others in the same grid. +const setting = (page: Page, label: string) => + page.locator("label").filter({hasText: label}).locator("xpath=following-sibling::div") + +const settingToggle = (page: Page, label: string) => setting(page, label).getByRole("checkbox") + +// A hosted relay's status and its plan each render as a badge beside its name. +const badge = (page: Page, text: string) => page.locator(".badge").filter({hasText: text}) + +test("US-092 edit a space's profile and featured content", 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, spacePath(url) + "/about") + + await mockBlossom(admin.context(), {server: BLOSSOM_ORIGIN}) + + await openSpaceMenu(admin) + await menuItem(admin, "Edit Space").click() + + const editor = dialog(admin, "Edit this Space") + + await expect(editor).toBeVisible() + + // The icon picker's file input is hidden inside its own label, so the chooser is opened by + // clicking the label rather than by writing to the input. + const chooser = admin.waitForEvent("filechooser") + + await editor.locator('label:has(input[type="file"])').click() + await (await chooser).setFiles(ICON) + + await expect(editor.getByText("Selected:")).toBeVisible() + + await editor.locator('input[type="text"]').fill("Harbor") + await editor.locator("textarea").fill("Where the fleet ties up.") + await editor.getByRole("button", {name: "Save Changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Your changes have been saved!") + + // All three come back off the relay's own nip-11 document + await expect(admin.getByRole("heading", {name: "Harbor"})).toBeVisible() + await expect(admin.getByText("Where the fleet ties up.")).toBeVisible() + await expect(admin.locator(`img[src^="${BLOSSOM_ORIGIN}"]`).first()).toBeVisible() + + const featured = admin + .locator(".card") + .filter({has: admin.getByRole("heading", {name: "Featured"})}) + + await expect(featured.getByText("No featured content yet.")).toBeVisible() + + await featured.getByRole("button").first().click() + + const featuredEditor = dialog(admin, "Featured Content") + + await featuredEditor.getByRole("button", {name: "Add content"}).click() + await featuredEditor.getByPlaceholder("URL or nevent...").fill("Read the harbor rules first") + await featuredEditor.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Featured content updated!") + await expect(featured.getByText("Read the harbor rules first")).toBeVisible() + + // What admin published reaches every visitor, without any of the controls that produced it + const bob = await as(users.bob, spacePath(url) + "/about") + + await expect(bob.getByRole("heading", {name: "Harbor"})).toBeVisible() + + const bobsFeatured = bob + .locator(".card") + .filter({has: bob.getByRole("heading", {name: "Featured"})}) + + await expect(bobsFeatured.getByText("Read the harbor rules first")).toBeVisible() + await expect(bobsFeatured.getByRole("button")).toHaveCount(0) + + await openSpaceMenu(bob) + + await expect(menuItem(bob, "Create Invite")).toBeVisible() + await expect(bob.getByRole("button", {name: "Edit Space", exact: true})).toHaveCount(0) +}) + +test("US-093 create roles and assign them", 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") + space.join(user.carol, "general") + space.profile(user.bob, {name: "Bob Barnacle"}) + space.profile(user.carol, {name: "Carol Cutter"}) + }) + + const {url} = scenario.space("space") + const admin = await as(users.admin, spacePath(url) + "/directory") + + await expect(memberCard(admin, "Bob Barnacle")).toBeVisible() + + await admin.getByRole("button", {name: "More options"}).click() + await menuItem(admin, "Manage Roles").click() + + const roles = dialog(admin, "Manage Roles") + const emptyState = roles.getByText("No roles yet. Create one to start organizing members.") + + await expect(emptyState).toBeVisible() + + await roles.getByRole("button", {name: "Create Role"}).click() + + const roleForm = dialog(admin, "Create Role") + + await roleForm.getByPlaceholder("Moderator").fill("Deckhand") + await roleForm.locator("textarea").fill("Keeps the decks in order.") + await roleForm.getByRole("slider").fill("200") + await roleForm.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Role created!") + await expect(roles.locator(".role-badge")).toHaveText("Deckhand") + + // Editing the label rewrites the role rather than adding a second one + await roles.locator(".card").filter({hasText: "Deckhand"}).getByRole("button").last().click() + await menuItem(admin, "Edit role").click() + + const roleEditor = dialog(admin, "Edit Role") + + await roleEditor.getByPlaceholder("Moderator").fill("Bosun") + await roleEditor.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Role updated!") + await expect(roles.locator(".role-badge")).toHaveText("Bosun") + + // Handed out from the role's own menu... + await roles.locator(".card").filter({hasText: "Bosun"}).getByRole("button").last().click() + await menuItem(admin, "Add members").click() + + const addMembers = dialog(admin, "Add to Bosun") + + await addMembers.getByPlaceholder("Search for profiles...").fill("Bob") + await admin.locator(".tiptap-suggestions").getByRole("button", {name: users.bob.pubkey}).click() + await addMembers.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Members assigned!") + + await roles.getByRole("button", {name: "Go back"}).click() + + const bobsBadge = memberCard(admin, "Bob Barnacle").locator(".role-badge") + + await expect(bobsBadge).toHaveText("Bosun") + await expect(bobsBadge).toHaveAttribute("style", /--role-color: hsl\(200/) + + // ...or from a member's own card, which is also how it comes back off + await memberCard(admin, "Carol Cutter").getByRole("button").last().click() + await menuItem(admin, "Edit roles").click() + + const carolsRoles = dialog(admin, "Edit Member") + + await carolsRoles.getByRole("checkbox").check() + await carolsRoles.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Roles updated!") + await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveText("Bosun") + + // The directory's search covers the roles a member holds as well as their name + const term = admin.getByPlaceholder("Search people or roles...") + + await term.fill("Bosun") + + await expect(admin.locator(".card-interactive")).toHaveCount(2) + await expect(memberCard(admin, "Bob Barnacle")).toBeVisible() + await expect(memberCard(admin, "Carol Cutter")).toBeVisible() + + await term.fill("") + + await memberCard(admin, "Carol Cutter").getByRole("button").last().click() + await menuItem(admin, "Edit roles").click() + await carolsRoles.getByRole("checkbox").uncheck() + await carolsRoles.getByRole("button", {name: "Save changes"}).click() + + await expect(admin.getByRole("alert")).toContainText("Roles updated!") + await expect(memberCard(admin, "Carol Cutter")).toBeVisible() + await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveCount(0) + + await admin.getByRole("button", {name: "More options"}).click() + await menuItem(admin, "Manage Roles").click() + await roles.locator(".card").filter({hasText: "Bosun"}).getByRole("button").last().click() + await menuItem(admin, "Delete role").click() + + const confirm = dialog(admin, "Delete Role") + + await expect( + confirm.getByText('Delete the "Bosun" role? Members will keep their space membership.'), + ).toBeVisible() + + await confirm.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Role deleted!") + await expect(emptyState).toBeVisible() + + await roles.getByRole("button", {name: "Go back"}).click() + + await expect(memberCard(admin, "Bob Barnacle")).toBeVisible() + await expect(memberCard(admin, "Bob Barnacle").locator(".role-badge")).toHaveCount(0) + + const bob = await as(users.bob, spacePath(url) + "/directory") + + await expect(memberCard(bob, "Carol Cutter")).toBeVisible() + await expect(bob.getByRole("button", {name: "More options"})).toHaveCount(0) +}) + +test("US-094 invite people to a space", async ({seed, as}) => { + const newcomer = makeTestUser("newcomer") + + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + const other = relay("other") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.bob, "general") + space.profile(user.bob, {name: "Bob Barnacle"}) + + // Somebody who belongs to a different space, so adding her here is a change to this space's + // member list rather than something the scenario already seeded. + other.room("lounge", {name: "Lounge"}) + other.join(user.admin, "lounge") + other.join(newcomer, "lounge") + other.profile(newcomer, {name: "Nadia Newcomer"}) + other.message(newcomer, "lounge", "hello from the lounge", at(2, HOUR)) + }) + + const other = scenario.space("other") + + // Arriving through the other space is what loads Nadia's profile: the invite dialog's search + // reads the profiles this client already holds. + const page = await as(users.admin, roomPath(other.url, "lounge"), { + context: {permissions: ["clipboard-read", "clipboard-write"]}, + }) + + await expect(page.getByText("Nadia Newcomer").first()).toBeVisible() + + // In-app navigation rather than a reload, which would empty the repository she was loaded into + await page.locator('.primary-nav [data-tip^="space"]').click() + await openSpaceMenu(page) + await menuItem(page, "Create Invite").click() + + const invite = dialog(page, "Create an Invite") + const link = invite.locator("input[readonly]") + + await expect(invite.locator("canvas")).toBeVisible() + await expect(link).toHaveValue(/\/join\?r=space\.test&c=.+/) + + await invite.locator("label:has(input[readonly])").getByRole("button").click() + + await expect(page.getByRole("alert")).toContainText("Copied to clipboard!") + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(await link.inputValue()) + + await invite.getByPlaceholder("Search for profiles...").fill("Nadia") + await page.locator(".tiptap-suggestions").getByRole("button", {name: newcomer.pubkey}).click() + await invite.getByRole("button", {name: "Save", exact: true}).click() + + await expect(page.getByRole("alert")).toContainText("Members have successfully been added!") + + await page.locator(".secondary-nav").getByRole("link", {name: "Directory"}).click() + + await expect(memberCard(page, "Nadia Newcomer")).toBeVisible() + + // A room's own invite carries the space's claim and that room's join code alongside it + await page.locator(".space-menu__scroll").getByRole("link", {name: "General"}).click() + await page.locator('[data-component="PageBar"]').getByRole("button").last().click() + await page.getByRole("button", {name: "Create invite"}).click() + + const roomInvite = dialog(page, "Create a Room Invite") + + await expect(roomInvite.locator("canvas")).toBeVisible() + await expect(roomInvite.locator("input[readonly]")).toHaveValue( + /\/join\?r=space\.test&c=[^&]+&h=general&code=.+/, + ) +}) + +test("US-095 remove, ban, and restore members", 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") + space.join(user.carol, "general") + space.profile(user.bob, {name: "Bob Barnacle"}) + space.profile(user.carol, {name: "Carol Cutter"}) + }) + + const {url} = scenario.space("space") + + // An ordinary member reads the same directory without any of the controls that manage it + const bob = await as(users.bob, spacePath(url) + "/directory") + const carolsCard = memberCard(bob, "Carol Cutter") + + await expect(carolsCard).toBeVisible() + await expect(carolsCard.getByRole("button")).toHaveCount(1) + await expect(bob.getByRole("button", {name: "More options"})).toHaveCount(0) + + const admin = await as(users.admin, spacePath(url) + "/directory") + + await memberCard(admin, "Bob Barnacle").getByRole("button").last().click() + await menuItem(admin, "Remove member").click() + + const removal = dialog(admin, "Remove Member") + + await expect(removal.getByText("Remove @Bob Barnacle from the space?")).toBeVisible() + + await removal.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Member has successfully been removed!") + await expect(memberCard(admin, "Bob Barnacle")).toHaveCount(0) + + await memberCard(admin, "Carol Cutter").getByRole("button").last().click() + await menuItem(admin, "Ban member").click() + + const ban = dialog(admin, "Ban Member") + + await expect(ban.getByText("Ban @Carol Cutter from the space?")).toBeVisible() + + await ban.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Member has successfully been banned!") + await expect(memberCard(admin, "Carol Cutter")).toHaveCount(0) + + await admin.getByRole("button", {name: "More options"}).click() + await menuItem(admin, "Banned Members").click() + + const banned = dialog(admin, "Banned users") + + await expect(banned.getByText("Carol Cutter")).toBeVisible() + + await banned.locator(".card").filter({hasText: "Carol Cutter"}).getByRole("button").last().click() + await menuItem(admin, "Restore User").click() + + await expect(admin.getByRole("alert")).toContainText( + "User has successfully been restored to membership!", + ) + await expect(memberCard(admin, "Carol Cutter")).toBeVisible() +}) + +test("US-096 moderate messages and posts", 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.alice, "general") + space.join(user.bob, "general") + space.join(user.carol, "general") + space.profile(user.alice, {name: "Alice Anchor"}) + space.profile(user.bob, {name: "Bob Barnacle"}) + space.message(user.alice, "general", "the hull is patched", at(2, HOUR)) + space.message(user.bob, "general", "aye captain", at(90, MINUTE)) + + space.event( + user.bob, + () => + space + .kind(Article) + .writer() + .setIdentifier("charting-the-reef") + .setTitle("Charting the Reef") + .setSummary("Where the shallows are.") + .setPublishedAt(at(3, HOUR)) + .setContent("Mind the coral.") + .renderTemplate(), + at(3, HOUR), + ) + + // A second article, so a page with the deleted one missing is still a page that loaded + space.event( + user.alice, + () => + space + .kind(Article) + .writer() + .setIdentifier("tides-and-timetables") + .setTitle("Tides and Timetables") + .setSummary("When to sail.") + .setPublishedAt(at(4, HOUR)) + .setContent("Slack water is best.") + .renderTemplate(), + at(4, HOUR), + ) + }) + + const {url} = scenario.space("space") + + // An author gets a delete on their own content, and nothing to report + const alice = await as(users.alice, roomPath(url, "general")) + + await expect(alice.getByText("the hull is patched")).toBeVisible() + + await openMessageMenu(alice, "the hull is patched") + + await expect(menuItem(alice, "Delete Message")).toBeVisible() + await expect(alice.getByRole("button", {name: "Report Content", exact: true})).toHaveCount(0) + + await alice.goto(spacePath(url) + "/articles") + await openEventMenu(articleCard(alice, "Tides and Timetables")) + + await expect(menuItem(alice, "Delete Article")).toBeVisible() + + // Everybody else gets a report instead, and the report needs a reason + const bob = await as(users.bob, roomPath(url, "general")) + + await openMessageMenu(bob, "the hull is patched") + + await expect(menuItem(bob, "Report Content")).toBeVisible() + await expect(bob.getByRole("button", {name: "Delete Message", exact: true})).toHaveCount(0) + + await menuItem(bob, "Report Content").click() + + const report = dialog(bob, "Report Content") + + await report.getByRole("button", {name: "Send Report"}).click() + + await expect(bob.getByRole("alert")).toContainText("Please select a reason for your report.") + await expect(report).toBeVisible() + + await report.getByRole("combobox").selectOption("Spam") + await report.getByRole("button", {name: "Send Report"}).click() + + await expect(bob.getByRole("alert")).toContainText("Your report has been sent!") + + // An admin gets a delete on anything, authored by them or not + const admin = await as(users.admin, roomPath(url, "general")) + + await expect(admin.getByText("aye captain")).toBeVisible() + + await openMessageMenu(admin, "aye captain") + await menuItem(admin, "Delete Message").click() + + const messageConfirm = dialog(admin, "Delete Message") + + await expect( + messageConfirm.getByText("Are you sure you want to delete this message from the space?"), + ).toBeVisible() + + await messageConfirm.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Event has successfully been deleted!") + + await admin.goto(spacePath(url) + "/articles") + await openEventMenu(articleCard(admin, "Charting the Reef")) + await menuItem(admin, "Delete Article").click() + + const articleConfirm = dialog(admin, "Delete Article") + + await expect( + articleConfirm.getByText("Are you sure you want to delete this article from the space?"), + ).toBeVisible() + + await articleConfirm.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Event has successfully been deleted!") + + // A viewer arriving after the fact reads the space as the relay now holds it + const carol = await as(users.carol, roomPath(url, "general")) + + await expect(carol.getByText("the hull is patched")).toBeVisible() + await expect(carol.getByText("aye captain")).toHaveCount(0) + + await carol.goto(spacePath(url) + "/articles") + + await expect(articleCard(carol, "Tides and Timetables")).toBeVisible() + await expect(articleCard(carol, "Charting the Reef")).toHaveCount(0) +}) + +test("US-097 work through the action-items queue", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + // A room the relay will not admit anyone to on their own say-so, so a join request stays + // pending instead of being granted the moment it lands. + space.room("vault", {name: "Vault", closed: true, private: true}) + space.join(user.admin, "general", "vault") + space.join(user.alice, "general") + space.join(user.bob, "general") + space.join(user.carol, "general") + space.profile(user.alice, {name: "Alice Anchor"}) + space.profile(user.bob, {name: "Bob Barnacle"}) + space.profile(user.carol, {name: "Carol Cutter"}) + space.message(user.alice, "general", "the hull is patched", at(2, HOUR)) + space.message(user.alice, "general", "the sails are torn", at(90, MINUTE)) + }) + + const {url} = scenario.space("space") + const admin = await as(users.admin, spacePath(url) + "/about") + const queueButton = admin.getByRole("button", {name: /^Action Items/}) + + await openSpaceMenu(admin) + + await expect(queueButton).toHaveText("Action Items (0)") + await expect(queueButton.locator(".bg-primary")).toHaveCount(0) + + await admin.keyboard.press("Escape") + + const report = async (page: Page, text: string, reason: string) => { + await openMessageMenu(page, text) + await menuItem(page, "Report Content").click() + + const form = dialog(page, "Report Content") + + await form.getByRole("combobox").selectOption(reason) + await form.getByRole("button", {name: "Send Report"}).click() + + await expect(page.getByRole("alert")).toContainText("Your report has been sent!") + } + + const askToJoinVault = async (page: Page) => { + await page.goto(roomPath(url, "vault")) + await page.getByRole("button", {name: "Join Room"}).click() + + await expect(page.getByRole("button", {name: "Access Pending"})).toBeVisible() + } + + const bob = await as(users.bob, roomPath(url, "general")) + + await expect(bob.getByText("the hull is patched")).toBeVisible() + + await report(bob, "the hull is patched", "Spam") + await askToJoinVault(bob) + + const carol = await as(users.carol, roomPath(url, "general")) + + await expect(carol.getByText("the sails are torn")).toBeVisible() + + await report(carol, "the sails are torn", "Other") + await askToJoinVault(carol) + + await openSpaceMenu(admin) + + await expect(queueButton).toHaveText("Action Items (4)") + await expect(queueButton.locator(".bg-primary")).toHaveCount(1) + + await queueButton.click() + + const queue = dialog(admin, "Action Items") + const item = (text: string, kind: string) => + queue.locator(".card").filter({hasText: text}).filter({hasText: kind}) + + // Each report names its reporter, its reason and the message it was filed against + const spamReport = item("Bob Barnacle", "Reported this event") + + await expect(spamReport).toContainText('as "spam"') + await expect(spamReport).toContainText("the hull is patched") + + const otherReport = item("Carol Cutter", "Reported this event") + + await expect(otherReport).toContainText('as "other"') + await expect(otherReport).toContainText("the sails are torn") + + // ...and each join request names its requester and the room they asked for + const carolsRequest = item("Carol Cutter", "requested membership") + const bobsRequest = item("Bob Barnacle", "requested membership") + + await expect(carolsRequest).toContainText("requested membership in #Vault") + await expect(bobsRequest).toContainText("requested membership in #Vault") + + await carolsRequest.getByRole("button", {name: "Accept"}).click() + + await expect(admin.getByRole("alert")).toContainText("Member has been added to the room!") + await expect(carolsRequest).toHaveCount(0) + + await bobsRequest.getByRole("button", {name: "Dismiss"}).click() + + await expect(admin.getByRole("alert")).toContainText("Join request has been dismissed.") + await expect(bobsRequest).toHaveCount(0) + + // Removing content deletes the message the report was filed against... + await spamReport.getByRole("button").first().click() + await menuItem(admin, "Remove Content").click() + + const removal = dialog(admin, "Remove Content") + + await expect( + removal.getByText("Are you sure you want to delete this content from the space?"), + ).toBeVisible() + + await removal.getByRole("button", {name: "Confirm"}).click() + + await expect(admin.getByRole("alert")).toContainText("Content has successfully been deleted!") + await expect(spamReport).toHaveCount(0) + + // ...while dismissing only clears the report + await otherReport.getByRole("button").first().click() + await menuItem(admin, "Dismiss Report").click() + + await expect(otherReport).toHaveCount(0) + + // Accepting granted membership, dismissing did not + await admin.goto(roomPath(url, "vault")) + await admin.locator('[data-component="PageBar"]').getByRole("button").last().click() + await admin.getByRole("button", {name: "View All"}).click() + + const members = dialog(admin, "Members") + + await expect(members.getByText("Carol Cutter")).toBeVisible() + await expect(members.getByText("Bob Barnacle")).toHaveCount(0) + + // The removed message is gone from the space, the dismissed one is not + const alice = await as(users.alice, roomPath(url, "general")) + + await expect(alice.getByText("the sails are torn")).toBeVisible() + await expect(alice.getByText("the hull is patched")).toHaveCount(0) + + // A member never sees the queue at all + const bobsSpace = await as(users.bob, spacePath(url) + "/about") + + await openSpaceMenu(bobsSpace) + + await expect(menuItem(bobsSpace, "Create Invite")).toBeVisible() + await expect(bobsSpace.getByRole("button", {name: /^Action Items/})).toHaveCount(0) +}) + +test("US-098 browse and create hosted spaces", 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") + other.room("lounge", {name: "Lounge"}) + other.join(user.alice, "lounge") + }) + + const page = await as(users.alice, "/settings/hosting", { + hosting: {plans: PLANS}, + // A space created here is opened at wss://./, so the domain has to be one + // the container serves and the subdomain has to name one of its tenants. + env: {VITE_HOSTING_RELAY_DOMAIN: "test"}, + }) + + await expect(page.getByText("You don't host any spaces yet.")).toBeVisible() + await expect(page.getByText("Create a space to get started.")).toBeVisible() + + await page.getByRole("button", {name: "Create a Space"}).click() + + const form = dialog(page, "New Space") + + await form.getByPlaceholder("My Space").fill("Space") + + // The subdomain follows the name until it is edited + await expect(form.getByPlaceholder("my-space")).toHaveValue("space") + + await form.getByRole("button").filter({hasText: "Up to 50 members"}).click() + await form.getByRole("button", {name: "Create Space", exact: true}).click() + + await expect(page).toHaveURL(/\/spaces\/space\.test\/admin/) + await expect(page.getByRole("heading", {name: "Space", exact: true})).toBeVisible() + + await page.goto("/settings/hosting") + + const listed = page.locator(".card").filter({hasText: "space.test"}).first() + + await expect(listed).toContainText("Space") + await expect(listed).toContainText("Active") + await expect(listed).toContainText("Free") + await expect(listed.getByRole("link", {name: "Manage"})).toBeVisible() + + // A paid plan has to be paid for, so creation hands straight over to payment + await page.getByRole("button", {name: "Create a Space"}).click() + await form.getByPlaceholder("My Space").fill("Other") + + await expect(form.getByPlaceholder("my-space")).toHaveValue("other") + + await form.getByRole("button").filter({hasText: "Unlimited members"}).click() + await form.getByRole("button", {name: "Create Space", exact: true}).click() + + await expect(page).toHaveURL(/\/spaces\/other\.test\/admin/) + await expect(page.getByRole("heading", {name: "Set Up Payments"})).toBeVisible() +}) + +test("US-099 configure a hosted 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") + }) + + const {url} = scenario.space("space") + const relayRecord = hostedRelay({ + activity: [ + { + id: "activity-1", + tenant_pubkey: users.alice.pubkey, + created_at: scenario.at(1, DAY), + activity_type: "create_relay", + resource_type: "relay", + resource_id: "relay-1", + }, + ], + }) + + const alice = await as(users.alice, spacePath(url) + "/admin", { + hosting: {plans: PLANS, relays: [relayRecord]}, + }) + + await expect(alice.getByRole("heading", {name: "Space", exact: true})).toBeVisible() + await expect(alice.getByText("Relay created")).toBeVisible() + + await alice.getByRole("button", {name: "Relay actions"}).click() + await menuItem(alice, "Edit details").click() + + const editor = dialog(alice, "Edit Space") + + await editor.getByPlaceholder("My Space").fill("Harbor") + await editor.locator("textarea").fill("Where the fleet ties up.") + await editor.getByRole("button", {name: "Save Changes"}).click() + + await expect(alice.getByRole("heading", {name: "Harbor", exact: true})).toBeVisible() + await expect(alice.getByText("Where the fleet ties up.")).toBeVisible() + + const publicRead = settingToggle(alice, "Public read") + + await expect(publicRead).not.toBeChecked() + + await publicRead.click() + + await expect(publicRead).toBeChecked() + + await alice.reload() + + await expect(settingToggle(alice, "Public read")).toBeChecked() + + // The free plan doesn't include either of these, and says so rather than failing on save + const blossom = settingToggle(alice, "Media storage") + + await expect(blossom).toBeDisabled() + await expect(settingToggle(alice, "LiveKit support")).toBeDisabled() + + // The tooltip hangs off a wrapper rather than the input, so it still shows while it is disabled + await blossom.locator("xpath=..").hover() + + await expect(alice.getByText("Not available on your current plan").first()).toBeVisible() + + // A member of the space who doesn't own it has nothing to manage here + const bob = await as(users.bob, spacePath(url) + "/admin") + + await expect(bob.getByRole("heading", {name: "Not a Coracle-hosted space"})).toBeVisible() + await expect(bob.getByRole("link", {name: "Go to Hosting settings"})).toHaveAttribute( + "href", + "/settings/hosting", + ) +}) + +test("US-100 change a hosted relay's plan", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, spacePath(url) + "/admin", { + hosting: {plans: PLANS, relays: [hostedRelay()]}, + }) + + await expect(setting(page, "Current plan")).toHaveText("Free") + + await page.getByRole("button", {name: "Relay actions"}).click() + await menuItem(page, "Change plan").click() + + const plans = dialog(page, "Change plan") + + await plans.getByRole("button").filter({hasText: "Unlimited members"}).click() + await plans.getByRole("button", {name: "Save", exact: true}).click() + + await expect(page.getByRole("alert")).toContainText("Plan updated.") + + // Nothing is on file to charge, so the upgrade hands over to payment setup + await expect(page.getByRole("heading", {name: "Set Up Payments"})).toBeVisible() + + await page.getByRole("button", {name: "Set up later"}).click() + + await expect(badge(page, "Basic")).toBeVisible() + await expect(setting(page, "Current plan")).toHaveText("Basic") + + // The paid plan unlocks both gated features + const blossom = settingToggle(page, "Media storage") + const livekit = settingToggle(page, "LiveKit support") + + await expect(blossom).toBeEnabled() + + await blossom.click() + await livekit.click() + + await expect(blossom).toBeChecked() + await expect(livekit).toBeChecked() + + await page.getByRole("button", {name: "Relay actions"}).click() + await menuItem(page, "Change plan").click() + await plans.getByRole("button").filter({hasText: "Up to 50 members"}).click() + await plans.getByRole("button", {name: "Save", exact: true}).click() + + await expect(page.getByRole("alert")).toContainText("Plan updated.") + await expect(badge(page, "Free")).toBeVisible() + await expect(setting(page, "Current plan")).toHaveText("Free") + await expect(settingToggle(page, "Media storage")).not.toBeChecked() + await expect(settingToggle(page, "LiveKit support")).not.toBeChecked() +}) + +test("US-101 point a custom domain at a hosted relay", 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") + + // Where the space ends up once the domain verifies, so the client has somewhere real to follow + // it to. + other.room("general", {name: "General"}) + other.join(user.alice, "general") + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, spacePath(url) + "/admin", { + hosting: {plans: PLANS, relays: [hostedRelay()]}, + context: {permissions: ["clipboard-read", "clipboard-write"]}, + }) + + await expect(page.getByText("Not configured")).toBeVisible() + + await page.getByRole("button", {name: "Add domain"}).click() + + const domainForm = dialog(page, "Custom domain") + + await domainForm.getByPlaceholder("relay.example.com").fill("other.test") + await domainForm.getByRole("button", {name: "Save", exact: true}).click() + + await expect(page.getByRole("alert")).toContainText("Custom domain saved.") + await expect(page.getByText("other.test", {exact: true})).toBeVisible() + await expect(page.getByText("Pending", {exact: true})).toBeVisible() + await expect(page.getByText("other.test CNAME space.test")).toBeVisible() + + await page.locator('[data-tip="Copy CNAME target"]').click() + + await expect(page.getByRole("alert")).toContainText("Copied to clipboard!") + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("space.test") + + // The backend hasn't seen the record yet, so verifying says so and leaves the badge alone + await page.getByRole("button", {name: "Verify DNS record"}).click() + + await expect(page.getByRole("alert")).toContainText( + "Not verified yet. DNS changes can take a while to propagate.", + ) + await expect(page.getByText("Pending", {exact: true})).toBeVisible() + + getHosting(page.context()).setRelay("relay-1", {custom_domain_verified: 1}) + + await page.getByRole("button", {name: "Verify DNS record"}).click() + + await expect(page.getByRole("alert")).toContainText("Custom domain verified.") + await expect(page.getByText("Verified", {exact: true})).toBeVisible() + + // The space is now reached through the domain its owner controls + await expect(page.getByRole("link", {name: "wss://other.test"})).toBeVisible() + await expect(page).toHaveURL(/\/spaces\/other\.test\/admin/) +}) + +test("US-102 pause a relay and settle the bill", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + }) + + const {url} = scenario.space("space") + const paidPeriod = {start: scenario.at(2, MONTH), end: scenario.at(1, MONTH)} + const openPeriod = {start: scenario.at(1, MONTH), end: scenario.startedAt} + + const page = await as(users.alice, spacePath(url) + "/admin", { + hosting: { + plans: PLANS, + relays: [hostedRelay({plan_id: "basic"})], + // Declared oldest first, which is not the order payment history reads in. Invoice's + // paid_at/voided_at/method are number | null / InvoiceMethod | null upstream, so null is + // how the hosting API spells "hasn't happened yet". + /* eslint-disable no-restricted-syntax */ + invoices: [ + { + id: "invoice-paid", + tenant_pubkey: users.alice.pubkey, + amount: 500, + period_start: paidPeriod.start, + period_end: paidPeriod.end, + created_at: paidPeriod.start, + paid_at: paidPeriod.end, + voided_at: null, + method: "nwc", + }, + { + id: "invoice-open", + tenant_pubkey: users.alice.pubkey, + amount: 900, + period_start: openPeriod.start, + period_end: openPeriod.end, + created_at: openPeriod.start, + paid_at: null, + voided_at: null, + method: null, + }, + ], + /* eslint-enable no-restricted-syntax */ + }, + }) + + await expect(page.getByText("Active", {exact: true})).toBeVisible() + + await page.getByRole("button", {name: "Relay actions"}).click() + await menuItem(page, "Deactivate").click() + + const deactivate = dialog(page, "Deactivate relay?") + + await expect(deactivate.getByText(/All client connections are dropped/)).toBeVisible() + + await deactivate.getByRole("button", {name: "Confirm"}).click() + + await expect(page.getByText("Inactive", {exact: true})).toBeVisible() + + await page.getByRole("button", {name: "Relay actions"}).click() + await menuItem(page, "Reactivate").click() + + await dialog(page, "Reactivate relay?").getByRole("button", {name: "Confirm"}).click() + + await expect(page.getByText("Active", {exact: true})).toBeVisible() + + await page.goto("/settings/hosting") + + const banner = page.getByText( + "You have an unpaid invoice. Pay it now to keep your relays running.", + ) + + await expect(banner).toBeVisible() + + await page.getByRole("button", {name: "Pay now"}).first().click() + + const payment = dialog(page, "Pay invoice") + + await expect(payment.getByText("$9.00")).toBeVisible() + await expect(payment.locator("canvas")).toBeVisible() + await expect(payment.locator("input[readonly]")).toHaveValue("lnbcinvoice-open") + + await payment.getByRole("button", {name: "Check payment"}).click() + + await expect(page.getByRole("alert")).toContainText( + "Payment not yet confirmed. Please try again after sending.", + ) + + getHosting(page.context()).setInvoice("invoice-open", {paid_at: now(), method: "nwc"}) + + await payment.getByRole("button", {name: "Check payment"}).click() + + await expect(payment.getByText("Payment confirmed!")).toBeVisible() + + await payment.getByRole("button", {name: "Done"}).click() + + await expect(banner).toHaveCount(0) + + // With the balance settled, the standing prompt is to keep it that way + await page.getByRole("button", {name: "Set up autopay"}).click() + + const autopay = dialog(page, "Set Up Payments") + + await autopay + .getByPlaceholder("nostr+walletconnect://") + .fill(`nostr+walletconnect://${users.alice.pubkey}?relay=${url}&secret=abc`) + await autopay.getByRole("button", {name: "Save", exact: true}).click() + + await expect(autopay.getByText("Wallet connected!")).toBeVisible() + + await autopay.getByRole("button", {name: "Done"}).click() + + await expect(page.getByRole("listitem").filter({hasText: "Lightning (NWC)"})).toContainText( + "Connected", + ) + + // Payment history, newest first + const history = page.locator(".card").filter({hasText: "Payment History"}).first() + const invoices = history.getByRole("listitem") + + // The whole period, not just its start: the two invoices meet at a month boundary, so either + // date on its own reads the same on both of them. + const period = ({start, end}: {start: number; end: number}) => + `${new Date(start * 1000).toLocaleDateString()} – ${new Date(end * 1000).toLocaleDateString()}` + + await expect(invoices.first()).toContainText("$9.00") + await expect(invoices.first()).toContainText(period(openPeriod)) + await expect(invoices.nth(1)).toContainText("$5.00") + await expect(invoices.nth(1)).toContainText(period(paidPeriod)) +}) diff --git a/e2e/specs/articles-threads.spec.ts b/e2e/specs/articles-threads.spec.ts new file mode 100644 index 00000000..b926e755 --- /dev/null +++ b/e2e/specs/articles-threads.spec.ts @@ -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() +}) diff --git a/e2e/specs/community.spec.ts b/e2e/specs/community.spec.ts new file mode 100644 index 00000000..57df101b --- /dev/null +++ b/e2e/specs/community.spec.ts @@ -0,0 +1,1142 @@ +import * as nip19 from "nostr-tools/nip19" +import {DAY, HOUR, MINUTE, bech32ToHex, int} from "@welshman/lib" +import {MESSAGE, POLL_RESPONSE, getLnUrl, tagSpec, tagValues, toMsats} from "@welshman/util" +import {ClientMessageType} from "@welshman/net" +import { + Comment, + Poll, + PollResponse, + Profile, + TimeEvent, + ZapGoal, + ZapReceipt, + ZapRequest, +} from "@welshman/domain" +import type {Locator, Page} from "@playwright/test" +import { + expect, + getTranscript, + makeTestUser, + mockBlossom, + mockDufflepud, + roomPath, + spacePath, + test, + users, +} from "../harness" +import type {TestUser} from "../harness" + +// A handle to a seeded event, which only reads once seed() has drained its queue. +type Seeded = {readonly id: string} + +// A modal is mounted alongside the page it covers, so a page's own "Create" and the modal's submit +// are both in the dom at once. Form assertions are scoped to the modal's own form to say which one +// is meant. +const dialog = (page: Page, title: string) => + page.locator("form").filter({has: page.getByRole("heading", {name: title})}) + +// The library's modals are plain Modals rather than forms, so the dialog wrapper is what scopes +// them. Dialog nests two elements carrying the class; the inner one holds the content. +const panel = (page: Page, title: string) => + page + .locator(".dialog") + .filter({has: page.getByRole("heading", {name: title, exact: true})}) + .last() + +const editorOf = (scope: Locator) => scope.locator(".note-editor [contenteditable=true]") + +// EventActions renders zap, emoji and menu into one join, in that order. +const emojiOf = (scope: Locator) => scope.locator(".join").getByRole("button").nth(1) + +const menuOf = (scope: Locator) => scope.locator(".join").getByRole("button").last() + +// A shelf is a card and its menu button side by side, so the menu is reached through the wrapper +// the two share. +const shelfCard = (page: Page, title: string) => + page.getByRole("button", {name: new RegExp(title)}).locator("xpath=..") + +// A list item is one big link, but Button swallows both the default and the propagation of every +// click it handles, and the middle of a card is usually one of those — a poll's radio, a goal's +// zap button, a listing's image. Opening a card by its title lands on inert text instead. +const openCard = (card: Locator, title: string) => card.getByText(title).click() + +// One option of a poll, which PollOption renders as a small card carrying its label, its count and +// its progress bar. +const pollOption = (page: Page, label: string) => page.locator(".card-sm").filter({hasText: label}) + +// dateFormatter in @welshman/lib resolves the environment's own locale, which node shares. +const longDate = (seconds: number) => + new Intl.DateTimeFormat(undefined, {year: "numeric", month: "long", day: "numeric"}).format( + new Date(seconds * 1000), + ) + +// The selections of every poll response this page put on the wire, oldest first. A multiple choice +// vote goes out once the delay window closes, so this is where "both publish" is visible. +const pollResponses = (page: Page, pollId: string) => + getTranscript(page.context()) + .filter( + ({direction, message}) => + direction === "toRelay" && + message[0] === ClientMessageType.Event && + message[1].kind === POLL_RESPONSE && + tagValues(tagSpec("e"), message[1].tags).includes(pollId), + ) + .map(({message}) => tagValues(tagSpec("response"), message[1].tags)) + +const PARTY = "🎉" + +// 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, so wait for there to be exactly one + // picker 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 + // more than one result can carry the annotation being matched. + await picker.locator("input.search").fill("party popper") + await picker + .getByRole("option", {name: /party popper/}) + .first() + .click() +} + +// A one pixel gif. Gif goes to the uploader untouched by the compressor, so this is uploaded byte +// for byte and the blossom mock can hash it. +const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64") + +test("US-046 create and browse a calendar event", 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"}) + + const addEvent = (title: string, start: number) => + space.event( + user.bob, + () => + space + .kind(TimeEvent) + .writer() + .setIdentifier(title.toLowerCase().replace(/\W+/g, "-")) + .setTitle(title) + .setStart(start) + .setEnd(start + int(1, HOUR)) + .setContent(`${title} happens here.`) + .renderTemplate(), + at(1, HOUR), + ) + + // Ten events that have already happened, so the list is taller than the viewport and "opens + // scrolled to the next upcoming event" is a statement about where it sits rather than about a + // list that fits on one screen anyway. + for (let i = 1; i <= 10; i++) { + addEvent(`Past Meetup ${String(i).padStart(2, "0")}`, at((11 - i) * 4, DAY)) + } + + // at() counts backwards from the moment the test started, so a negative count is the future. + addEvent("Autumn Fair", at(-3, DAY)) + addEvent("Winter Solstice", at(-10, DAY)) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, `${spacePath(url)}/calendar`) + + const cards = page.getByRole("link").filter({hasText: "Posted by"}) + const card = (title: string) => page.getByRole("link").filter({hasText: title}) + + await expect(cards).toHaveCount(12) + + // The calendar opens on the first event that hasn't happened yet, with the oldest one scrolled + // out of the way above it. + await expect(card("Autumn Fair")).toBeInViewport() + await expect(card("Past Meetup 01")).not.toBeInViewport() + + await page.getByRole("button", {name: "Create", exact: true}).click() + + const composer = dialog(page, "Create an Event") + + // Field renders its label and its input as siblings rather than wiring them together, so the + // form's two writable text fields are taken in document order: title, then location. The date + // range's own input is readonly, which is what leaves those two. + const textInputs = composer.locator('input[type="text"]:not([readonly])') + const title = textInputs.first() + const location = textInputs.last() + const dates = composer.getByPlaceholder("Select dates") + + await expect(composer).toBeVisible() + + await composer.getByRole("button", {name: "Save Event"}).click() + await expect(page.getByRole("alert")).toContainText("Please provide a title.") + + await title.fill("Winter Market") + await location.fill("Town Hall") + await composer.getByRole("button", {name: "Save Event"}).click() + await expect(page.getByRole("alert")).toContainText("Please provide start and end times.") + + // The picker takes two clicks for a range: the first is the start, the second the end, each at + // the time its own time input carries — noon and one, for a range that starts empty. + const target = new Date() + + target.setMonth(target.getMonth() + 1, 15) + target.setHours(12, 0, 0, 0) + + await dates.click() + await composer.getByRole("button", {name: "Next month"}).click() + await composer.getByRole("button", {name: "15", exact: true}).click() + await composer.getByRole("button", {name: "15", exact: true}).click() + + await expect(dates).not.toHaveValue("") + + await composer.getByRole("button", {name: "Save Event"}).click() + + await expect(page.getByRole("alert")).toContainText("Your event has been saved!") + await expect(page.getByRole("heading", {name: "Create an Event"})).toHaveCount(0) + + const market = card("Winter Market") + + await expect(market).toBeVisible() + await expect(market).toContainText(longDate(Math.floor(target.getTime() / 1000))) +}) + +test("US-047 manage your own calendar event", 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"}) + + const addEvent = (author: TestUser, d: string, name: string, place: string) => + space.event( + author, + () => + space + .kind(TimeEvent) + .writer() + .setIdentifier(d) + .setTitle(name) + .setLocation(place) + .setStart(at(-2, DAY)) + .setEnd(at(-2, DAY) + int(2, HOUR)) + .setContent("Bring a dish to share.") + .renderTemplate(), + at(1, HOUR), + ) + + addEvent(user.alice, "harvest-supper", "Harvest Supper", "The Old Mill") + addEvent(user.bob, "quiz-night", "Quiz Night", "The Anchor") + }) + + const {at} = scenario + const {url} = scenario.space("space") + const calendarPath = `${spacePath(url)}/calendar` + const page = await as(users.alice, calendarPath) + + await openCard(page.getByRole("link").filter({hasText: "Harvest Supper"}), "Harvest Supper") + + // The event has no replies, so the page carries exactly one feature card. + const eventCard = page.locator(".card.z-feature") + + await expect(page.getByRole("heading", {name: "Harvest Supper", exact: true})).toBeVisible() + await expect(eventCard).toContainText(longDate(at(-2, DAY))) + await expect(eventCard).toContainText("The Old Mill") + await expect(eventCard).toContainText("Alice Anderson") + + await menuOf(eventCard).click() + await page.getByRole("button", {name: "Edit Event"}).click() + + const composer = dialog(page, "Edit this Event") + const textInputs = composer.locator('input[type="text"]:not([readonly])') + + await expect(textInputs.first()).toHaveValue("Harvest Supper") + await expect(textInputs.last()).toHaveValue("The Old Mill") + await expect(composer.getByPlaceholder("Select dates")).not.toHaveValue("") + + await textInputs.first().fill("Harvest Supper & Ceilidh") + await textInputs.last().fill("The Village Hall") + await composer.getByRole("button", {name: "Save Event"}).click() + + await expect(page.getByRole("heading", {name: "Edit this Event"})).toHaveCount(0) + await expect(page.getByRole("heading", {name: "Harvest Supper & Ceilidh"})).toBeVisible() + await expect(eventCard).toContainText("The Village Hall") + + await menuOf(eventCard).click() + await page.getByRole("button", {name: "Delete Event"}).click() + await page.getByRole("button", {name: "Confirm"}).click() + + // The detail page keeps a deleted event on screen and marks it, so this is what says the + // retraction landed before the calendar is asked for the list again. + await expect(eventCard.getByText("Deleted", {exact: true})).toBeVisible() + + await page.goto(calendarPath) + + await expect(page.getByRole("link").filter({hasText: "Quiz Night"})).toBeVisible() + await expect(page.getByRole("link").filter({hasText: "Harvest Supper"})).toHaveCount(0) +}) + +test("US-048 create a poll and vote on it", async ({seed, as}) => { + let snacks!: Seeded + let drinks!: 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"}) + + // The multiple choice half of the story is about voting rather than about creating, and a + // wire assertion needs option ids known up front, so these two are fixtures. + snacks = space.event( + user.alice, + () => + space + .kind(Poll) + .writer() + .setTitle("Which snacks should we order?") + .setPollType("multiplechoice") + .addOption("Chips", "snack-chips") + .addOption("Salsa", "snack-salsa") + .addOption("Olives", "snack-olives") + .setUrls([space.url]) + .renderTemplate(), + at(2, HOUR), + ) + + drinks = space.event( + user.alice, + () => + space + .kind(Poll) + .writer() + .setTitle("Which drinks should we order?") + .setPollType("multiplechoice") + .addOption("Coffee", "drink-coffee") + .addOption("Tea", "drink-tea") + .setUrls([space.url]) + .renderTemplate(), + at(2, HOUR), + ) + }) + + const {url} = scenario.space("space") + const alice = await as(users.alice, `${spacePath(url)}/polls`) + + await alice.getByRole("button", {name: "Create", exact: true}).click() + + const composer = dialog(alice, "Create a Poll") + const question = composer.getByPlaceholder("What would you like to ask?") + + await composer.getByRole("button", {name: "Create Poll"}).click() + await expect(alice.getByRole("alert")).toContainText("Please provide a title for your poll.") + + await question.fill("Where should we meet?") + await composer.getByPlaceholder("Option 2").fill("") + await composer.getByRole("button", {name: "Create Poll"}).click() + await expect(alice.getByRole("alert")).toContainText("Please provide at least two options.") + + await composer.getByPlaceholder("Option 1").fill("The park") + await composer.getByPlaceholder("Option 2").fill("The pub") + await composer.getByRole("button", {name: "Add option"}).click() + await composer.getByPlaceholder("Option 3").fill("The pier") + + // Dragging the third option onto the second puts it there and pushes the pub down. + const rows = composer.getByRole("listitem") + + await rows.nth(2).dragTo(rows.nth(1)) + + await expect(composer.getByPlaceholder("Option 1")).toHaveValue("The park") + await expect(composer.getByPlaceholder("Option 2")).toHaveValue("The pier") + await expect(composer.getByPlaceholder("Option 3")).toHaveValue("The pub") + + await composer.getByRole("button", {name: "Create Poll"}).click() + + await expect(alice.getByRole("heading", {name: "Create a Poll"})).toHaveCount(0) + + const meetup = alice.getByRole("link").filter({hasText: "Where should we meet?"}) + + await expect(meetup).toBeVisible() + + await openCard(meetup, "Where should we meet?") + + await expect(alice.getByRole("heading", {name: "Where should we meet?"})).toBeVisible() + await expect(alice.locator(".card-sm")).toContainText(["The park", "The pier", "The pub"]) + + await pollOption(alice, "The park").getByRole("radio").check() + + await expect(pollOption(alice, "The park")).toContainText("1 vote") + await expect(pollOption(alice, "The park").locator("progress")).toHaveJSProperty("value", 1) + + const pollPath = new URL(alice.url()).pathname + const bob = await as(users.bob, pollPath) + + // Alice's vote reached bob over the wire, and his comes back to her without a reload. + await expect(pollOption(bob, "The park")).toContainText("1 vote") + + await pollOption(bob, "The pub").getByRole("radio").check() + + await expect(pollOption(alice, "The pub")).toContainText("1 vote") + await expect(alice.getByText("2 votes")).toBeVisible() + + // Two boxes checked inside the delay window go out as one vote for both. + await alice.goto(`${spacePath(url)}/polls/${snacks.id}`) + + await pollOption(alice, "Chips").getByRole("checkbox").check() + await pollOption(alice, "Salsa").getByRole("checkbox").check() + + await expect(pollOption(alice, "Chips")).toContainText("1 vote") + await expect(pollOption(alice, "Salsa")).toContainText("1 vote") + await expect(pollOption(alice, "Olives")).toContainText("0 votes") + await expect.poll(() => pollResponses(alice, snacks.id)).toEqual([["snack-chips", "snack-salsa"]]) + + // Unchecking one before the delay elapses keeps it out of the vote that is finally sent. + await alice.goto(`${spacePath(url)}/polls/${drinks.id}`) + + await pollOption(alice, "Coffee").getByRole("checkbox").check() + await pollOption(alice, "Tea").getByRole("checkbox").check() + await pollOption(alice, "Tea").getByRole("checkbox").uncheck() + + await expect(pollOption(alice, "Coffee")).toContainText("1 vote") + await expect(pollOption(alice, "Tea")).toContainText("0 votes") + await expect.poll(() => pollResponses(alice, drinks.id)).toEqual([["drink-coffee"]]) +}) + +test("US-049 a closed poll shows final results only", async ({seed, as}) => { + let closed!: 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"}) + space.profile(user.bob, {name: "Bob Barker"}) + space.profile(user.carol, {name: "Carol Chen"}) + + const poll = space.event( + user.alice, + () => + space + .kind(Poll) + .writer() + .setTitle("What colour should the shed be?") + .setPollType("singlechoice") + .addOption("Blue", "shed-blue") + .addOption("Green", "shed-green") + .setEndsAt(at(3, HOUR)) + .setUrls([space.url]) + .renderTemplate(), + at(5, HOUR), + ) + + for (const voter of [user.bob, user.carol]) { + space.event( + voter, + () => + space + .kind(PollResponse) + .writer() + .setPollId(poll.id) + .addSelection("shed-blue") + .renderTemplate(), + at(4, HOUR), + ) + } + + closed = poll + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, `${spacePath(url)}/polls/${closed.id}`) + + const blue = pollOption(page, "Blue") + const green = pollOption(page, "Green") + + await expect(page.getByRole("heading", {name: "What colour should the shed be?"})).toBeVisible() + + // Voting is over, and the poll says so in place of the controls that would have taken a vote. + await expect(page.getByText(/Ended 3 hours ago/)).toBeVisible() + await expect(page.locator("input[type=radio]")).toHaveCount(0) + await expect(page.locator("input[type=checkbox]")).toHaveCount(0) + + // The votes already cast still count and still draw. + await expect(blue).toContainText("2 votes") + await expect(green).toContainText("0 votes") + await expect(blue.locator("progress")).toHaveJSProperty("value", 2) + await expect(blue.locator("progress")).toHaveJSProperty("max", 2) + await expect(green.locator("progress")).toHaveJSProperty("value", 0) +}) + +test("US-050 create a funding goal and track its progress", async ({seed, as}) => { + // The lightning address on bob's profile, and the lnurl endpoint the client resolves it to. A + // receipt is only counted once that endpoint answers with the zapper that signed it. + const lud16 = "bob@zap.test" + const lnurl = getLnUrl(lud16)! + // A zap receipt is signed by the recipient's lightning provider rather than by either party to + // the zap, so the provider is an identity of its own — and one zooid will take a write from. + const provider = makeTestUser("zapper") + + let running!: Seeded + let soundproofing!: 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.member(provider) + space.profile(user.alice, {name: "Alice Anderson"}) + space.event(user.bob, () => + space.kind(Profile).writer().update({name: "Bob Barker", lud16}).renderTemplate(), + ) + + // Two and a half days old, so "how long it has been running" rounds to three whatever second + // of the run this renders on. + running = space.event( + user.bob, + () => + space + .kind(ZapGoal) + .writer() + .setTitle("New PA system") + .setSummary("The old one hums through every song.") + .setAmount(50000) + .setUrls([space.url]) + .renderTemplate(), + at(60, HOUR), + ) + + soundproofing = space.event( + user.bob, + () => + space + .kind(ZapGoal) + .writer() + .setTitle("Soundproofing") + .setSummary("The neighbours have opinions about band practice.") + .setAmount(20000) + .setUrls([space.url]) + .renderTemplate(), + at(30, HOUR), + ) + + // What a wallet publishes once an invoice is paid: the payer's own zap request carried as the + // receipt's description, signed by the provider the recipient's lnurl names. A receipt whose + // invoice disagrees with the amount its request asked for is thrown away by the client, so the + // two are rendered from the same number. + const contribute = (from: TestUser, sats: number, createdAt: number) => + space.event( + provider, + async () => { + const request = await from.signer.sign({ + ...(await space + .kind(ZapRequest) + .writer() + .setAmount(toMsats(sats)) + .setLnurl(lnurl) + .setRecipient(user.bob.pubkey) + .setEventId(soundproofing.id) + .setUrls([space.url]) + .renderTemplate()), + created_at: createdAt, + }) + + // What was paid is read straight out of the invoice's human-readable part, where an `n` + // is a tenth of a sat, so that prefix is all of a bolt11 that has to be real. + return space + .kind(ZapReceipt) + .writer() + .setBolt11(`lnbc${sats * 10}n1p${from.name}`) + .setDescription(JSON.stringify(request)) + .setRecipient(user.bob.pubkey) + .setEventId(soundproofing.id) + .renderTemplate() + }, + createdAt, + ) + + contribute(user.alice, 1000, at(20, HOUR)) + contribute(user.carol, 500, at(10, HOUR)) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, `${spacePath(url)}/goals`) + + await page.getByRole("button", {name: "Create", exact: true}).click() + + const composer = dialog(page, "Create a Funding Goal") + const title = composer.getByPlaceholder("What do funds go towards?") + const amount = composer.locator('input[type="number"]') + const slider = composer.locator('input[type="range"]') + + await expect(amount).toHaveValue("1000") + await expect(slider).toHaveValue("1000") + + await composer.getByRole("button", {name: "Create Goal"}).click() + await expect(page.getByRole("alert")).toContainText( + "Please provide a title for your funding goal.", + ) + + await title.fill("Repair the roof") + await composer.getByRole("button", {name: "Create Goal"}).click() + await expect(page.getByRole("alert")).toContainText( + "Please provide details about your funding goal.", + ) + + await editorOf(composer).pressSequentially("Three tiles came off in the storm.") + + // The target answers to the field and to the slider alike. + await amount.fill("5000") + await expect(slider).toHaveValue("5000") + + await slider.press("ArrowRight") + await expect(amount).toHaveValue("6000") + + await composer.getByRole("button", {name: "Create Goal"}).click() + + await expect(page.getByRole("heading", {name: "Create a Funding Goal"})).toHaveCount(0) + + const roof = page.getByRole("link").filter({hasText: "Repair the roof"}) + + await expect(roof).toBeVisible() + + await openCard(roof, "Repair the roof") + + await expect(page.getByRole("heading", {name: "Repair the roof"})).toBeVisible() + + const goalCard = page.locator(".card.z-feature").filter({hasText: "funded of 6000 sats"}) + + // Exactly, since "funded of 6000 sats" carries "0 sats" inside it. + await expect(goalCard.getByText("0 sats", {exact: true})).toBeVisible() + await expect(goalCard.locator("progress")).toHaveJSProperty("value", 0) + await expect(goalCard.locator("progress")).toHaveJSProperty("max", 6000) + + await page.goto(`${spacePath(url)}/goals/${running.id}`) + + const paCard = page.locator(".card.z-feature").filter({hasText: "funded of 50000 sats"}) + + await expect(paCard.getByText("contributors", {exact: true}).locator("xpath=..")).toContainText( + "0", + ) + await expect(paCard.getByText("days old", {exact: true}).locator("xpath=..")).toContainText("3") + + // Registered after the page was opened, so it answers ahead of the empty dufflepud `as()` + // installs, and before the navigation below, since a zapper is looked up once per page load. + await mockDufflepud(page.context(), { + zappers: [ + { + lnurl: bech32ToHex(lnurl), + info: {pubkey: users.bob.pubkey, nostrPubkey: provider.pubkey, allowsNostr: true}, + }, + ], + }) + + await page.goto(`${spacePath(url)}/goals/${soundproofing.id}`) + + const soundCard = page.locator(".card.z-feature").filter({hasText: "funded of 20000 sats"}) + + await expect(soundCard.getByText("1500 sats", {exact: true})).toBeVisible() + await expect(soundCard.locator("progress")).toHaveJSProperty("value", 1500) + await expect( + soundCard.getByText("contributors", {exact: true}).locator("xpath=.."), + ).toContainText("2") + await expect(soundCard.getByText("days old", {exact: true}).locator("xpath=..")).toContainText( + "2", + ) +}) + +test("US-051 post, edit, and close out a classified listing", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.profile(user.alice, {name: "Alice Anderson"}) + }) + + const {url} = scenario.space("space") + const classifiedsPath = `${spacePath(url)}/classifieds` + const page = await as(users.alice, classifiedsPath) + + await mockBlossom(page.context(), {server: url.replace(/^wss:/, "https:")}) + + await page.getByRole("button", {name: "Create", exact: true}).click() + + const composer = dialog(page, "Create a Classified Listing") + const title = composer.getByPlaceholder("What is this listing for?") + const price = composer.locator('input[type="number"]') + const currency = composer.locator("button.input") + const topics = composer.getByPlaceholder("Add topics...") + + await expect(price).toHaveValue("0") + await expect(currency).toHaveText("SAT (Satoshi)") + + await composer.getByRole("button", {name: "Save Listing"}).click() + await expect(page.getByRole("alert")).toContainText("Please provide a title for your listing.") + + await title.fill("Vintage Road Bike") + await composer.getByRole("button", {name: "Save Listing"}).click() + await expect(page.getByRole("alert")).toContainText( + "Please provide a description for your listing.", + ) + + await editorOf(composer).pressSequentially("Steel frame, new tires, barely ridden.") + + await currency.click() + await currency.locator("input").fill("USD") + await page.getByRole("button", {name: "USD", exact: true}).click() + await expect(currency).toHaveText("USD (United States Dollar)") + + await price.fill("1200") + + await topics.fill("bicycles") + await topics.press("Enter") + + await expect(composer.getByText("#bicycles")).toBeVisible() + + await composer + .locator('input[type="file"]') + .setInputFiles({name: "bike.gif", mimeType: "image/gif", buffer: GIF}) + + await expect(composer.getByAltText("Upload preview")).toBeVisible() + + await composer.getByRole("button", {name: "Save Listing"}).click() + + await expect(page.getByRole("heading", {name: "Create a Classified Listing"})).toHaveCount(0) + + const listing = page.getByRole("link").filter({hasText: "Vintage Road Bike"}) + + await expect(listing).toBeVisible() + await expect(listing).toContainText("1200") + await expect(listing).toContainText("#bicycles") + await expect(listing.locator('img[src^="https://space.test/"]')).toBeVisible() + + await openCard(listing, "Vintage Road Bike") + + const detail = page.locator(".card.z-feature").filter({hasText: "Vintage Road Bike"}) + + await expect(detail).toContainText("1200") + + await menuOf(detail).click() + await page.getByRole("button", {name: "Edit Listing"}).click() + + const editor = dialog(page, "Edit this Listing") + + await expect(editor.getByPlaceholder("What is this listing for?")).toHaveValue( + "Vintage Road Bike", + ) + await expect(editor.locator('input[type="number"]')).toHaveValue("1200") + await expect(editor.locator("button.input")).toHaveText("USD (United States Dollar)") + await expect(editor.getByText("#bicycles")).toBeVisible() + await expect(editor.getByRole("combobox")).toHaveValue("active") + + await editor.getByRole("combobox").selectOption("sold") + await editor.getByRole("button", {name: "Save Listing"}).click() + + await expect(page.getByRole("heading", {name: "Edit this Listing"})).toHaveCount(0) + await expect(detail.getByText("Sold", {exact: true})).toBeVisible() + + await page.goto(classifiedsPath) + + await expect(listing.getByText("Sold", {exact: true})).toBeVisible() +}) + +test("US-052 comment on and react to community posts", 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"}) + + const fair = space.event( + user.alice, + () => + space + .kind(TimeEvent) + .writer() + .setIdentifier("autumn-fair") + .setTitle("Autumn Fair") + .setLocation("The Green") + .setStart(at(-4, DAY)) + .setEnd(at(-4, DAY) + int(4, HOUR)) + .setContent("Stalls, cider and a tug of war.") + .renderTemplate(), + at(3, HOUR), + ) + + // Three comments already there, so bob's fills the four the page shows without asking and + // alice's tips it past them. + for (let i = 1; i <= 3; i++) { + space.event( + user.carol, + () => + space + .kind(Comment) + .writer() + .setRootFromEvent(fair.event) + .setParentFromEvent(fair.event) + .setContent(`Question ${String(i).padStart(2, "0")} for the organisers`) + .renderTemplate(), + at(3, HOUR) + i * 60, + ) + } + }) + + const {url} = scenario.space("space") + const calendarPath = `${spacePath(url)}/calendar` + const bob = await as(users.bob, calendarPath) + + await openCard(bob.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair") + + const eventCard = bob.locator(".card.z-feature").filter({hasText: "Stalls, cider"}) + + await expect(eventCard).toBeVisible() + + await pickParty(bob, emojiOf(eventCard)) + + const bobsPill = eventCard.getByRole("button", {name: PARTY}) + + await expect(bobsPill).toHaveCount(1) + await expect(bobsPill).toHaveClass(/button-primary/) + await expect(bobsPill).toHaveAttribute("data-tip", "Bob Barker reacted") + + await bob.getByRole("button", {name: "Leave comment"}).click() + + const reply = bob.locator("form").filter({has: bob.locator(".note-editor")}) + + await editorOf(reply).pressSequentially("Is there parking at The Green?") + await reply.getByRole("button", {name: "Post Reply"}).click() + + await expect(bob.getByText("Is there parking at The Green?")).toBeVisible() + + const alice = await as(users.alice, calendarPath) + + await openCard(alice.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair") + + const alicesView = alice.locator(".card.z-feature").filter({hasText: "Stalls, cider"}) + + await expect(alicesView.getByRole("button", {name: PARTY})).toHaveAttribute( + "data-tip", + "Bob Barker reacted", + ) + await expect(alice.getByText("Is there parking at The Green?")).toBeVisible() + + await alice.getByRole("button", {name: "Leave comment"}).click() + + const alicesReply = alice.locator("form").filter({has: alice.locator(".note-editor")}) + + await editorOf(alicesReply).pressSequentially("Yes, in the field behind the pub.") + await alicesReply.getByRole("button", {name: "Post Reply"}).click() + + // Past four replies the rest are folded away behind a control that names how many there are. + const showAll = alice.getByRole("button", {name: "Show all 5 replies"}) + + await expect(showAll).toBeVisible() + await expect(alice.getByText("Yes, in the field behind the pub.")).toHaveCount(0) + + await showAll.click() + + await expect(alice.getByText("Question 01 for the organisers")).toBeVisible() + await expect(alice.getByText("Is there parking at The Green?")).toBeVisible() + await expect(alice.getByText("Yes, in the field behind the pub.")).toBeVisible() +}) + +test("US-053 browse and search the library", 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.alice, "general") + space.profile(user.admin, {name: "Ada Admin"}) + space.profile(user.alice, {name: "Alice Anderson"}) + space.message(user.admin, "general", "welcome to the space", at(2, HOUR)) + }) + + const {url} = scenario.space("space") + const libraryPath = `${spacePath(url)}/library` + + // A shelf is signed by the relay itself, so nothing this process holds a key for can seed one + // and the only way to stand one up is through the admin's own ui. + const admin = await as(users.admin, libraryPath) + + const createShelf = async (title: string, description: string) => { + await admin.getByRole("button", {name: "Create Shelf"}).click() + + const form = panel(admin, "Create Shelf") + + await form.getByPlaceholder("Shelf title").fill(title) + await form.getByPlaceholder("What's this shelf about?").fill(description) + await form.getByRole("button", {name: "Save changes"}).click() + await expect(admin.getByRole("alert")).toContainText("Shelf created!") + } + + await createShelf("Getting Started", "Reading for new members") + await createShelf("Recipes", "Food and drink from the kitchen") + + await expect(admin.getByRole("button", {name: /Getting Started/})).toBeVisible() + await expect(admin.getByRole("button", {name: /Recipes/})).toBeVisible() + + await shelfCard(admin, "Getting Started").getByRole("button", {name: "More options"}).click() + await admin.getByRole("button", {name: "Add link", exact: true}).click() + + const linkForm = panel(admin, "Add Link") + + await linkForm.getByPlaceholder("URL or nevent...").fill("https://handbook.test/start-here") + await linkForm.getByPlaceholder("Optional title").fill("The Handbook") + await linkForm.getByRole("button", {name: "Add link"}).click() + + await expect(admin.getByRole("alert")).toContainText("Link added!") + + const alice = await as(users.alice, libraryPath) + const term = alice.getByPlaceholder("Search library...") + + await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible() + await expect(alice.getByRole("button", {name: /Recipes/})).toBeVisible() + + // A term matches a shelf on its title... + await term.fill("recipes") + + await expect(alice.getByRole("button", {name: /Recipes/})).toBeVisible() + await expect(alice.getByRole("button", {name: /Getting Started/})).toHaveCount(0) + + // ...or on its description. + await term.fill("new members") + + await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible() + await expect(alice.getByRole("button", {name: /Recipes/})).toHaveCount(0) + + await term.fill("") + + await alice.getByRole("button", {name: /Getting Started/}).click() + + await expect(alice.getByText("The Handbook")).toBeVisible() + + await alice.getByRole("button", {name: /Recipes/}).click() + + await expect(alice.getByText("This shelf doesn't have any links yet.")).toBeVisible() + + // Curating is the admin's, so none of it is offered to an ordinary member — not even on the + // empty shelf where the admin is offered it directly. + await expect(alice.getByRole("button", {name: "Create Shelf"})).toHaveCount(0) + await expect(alice.getByRole("button", {name: "Add a link"})).toHaveCount(0) +}) + +test("US-054 curate the library", async ({seed, as}) => { + let poll!: Seeded + let message!: 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.bob, "general") + space.profile(user.admin, {name: "Ada Admin"}) + space.profile(user.bob, {name: "Bob Barker"}) + + message = space.message(user.bob, "general", "the deploy broke again", at(30, MINUTE)) + + poll = space.event( + user.bob, + () => + space + .kind(Poll) + .writer() + .setTitle("Should we move standup to 10?") + .setPollType("singlechoice") + .addOption("Yes", "standup-yes") + .addOption("No", "standup-no") + .setUrls([space.url]) + .renderTemplate(), + at(2, HOUR), + ) + }) + + const {url} = scenario.space("space") + const libraryPath = `${spacePath(url)}/library` + const pollPath = `${spacePath(url)}/polls/${poll.id}` + const page = await as(users.admin, pollPath) + + const pollCard = page + .locator(".card.z-feature") + .filter({hasText: "Should we move standup to 10?"}) + + // With nothing on the shelves yet, the dialog says so and points at where one is made. + await menuOf(pollCard).click() + await page.getByRole("button", {name: "Add to Library"}).click() + + await expect(page.getByText("This space doesn't have any shelves yet.")).toBeVisible() + await expect(page.getByRole("link", {name: "Go to Library"})).toBeVisible() + + await page.goto(libraryPath) + + await page.getByRole("button", {name: "Create Shelf"}).click() + + const shelfForm = panel(page, "Create Shelf") + + await shelfForm.getByPlaceholder("Shelf title").fill("Reading List") + await shelfForm.getByPlaceholder("What's this shelf about?").fill("Things worth reading") + await shelfForm.getByRole("button", {name: "Save changes"}).click() + + // Creating a shelf lists it and drops the admin straight into it. + await expect(page.getByRole("alert")).toContainText("Shelf created!") + await expect(page).toHaveURL(/[?&]board=/) + await expect(page.getByRole("button", {name: /Reading List/})).toHaveAttribute( + "aria-pressed", + "true", + ) + await expect(page.getByText("This shelf doesn't have any links yet.")).toBeVisible() + + // An external url becomes a card of its own... + await page.getByRole("button", {name: "Add a link"}).click() + + const externalLink = panel(page, "Add Link") + + await externalLink.getByPlaceholder("URL or nevent...").fill("https://handbook.test/style-guide") + await externalLink.getByPlaceholder("Optional title").fill("The Style Guide") + await externalLink.getByRole("button", {name: "Add link"}).click() + + await expect(page.getByRole("alert")).toContainText("Link added!") + + const styleGuide = page.locator(".card").filter({hasText: "The Style Guide"}) + + await expect(styleGuide).toBeVisible() + await expect(styleGuide.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible() + + // ...and a nostr link becomes the note it points at. + const nevent = nip19.neventEncode({id: message.id, kind: MESSAGE, relays: [url]}) + + await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click() + await page.getByRole("button", {name: "Add link", exact: true}).click() + + const nostrLink = panel(page, "Add Link") + + await nostrLink.getByPlaceholder("URL or nevent...").fill(`nostr:${nevent}`) + await nostrLink.getByPlaceholder("Optional title").fill("Yesterday's incident") + await nostrLink.getByRole("button", {name: "Add link"}).click() + + const incident = page.locator(".card").filter({hasText: "Yesterday's incident"}) + + await expect(incident).toContainText("the deploy broke again") + await expect(incident).toContainText("Bob Barker") + + // Editing the shelf from its menu updates the list. + await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click() + await page.getByRole("button", {name: "Edit shelf"}).click() + + const shelfEdit = panel(page, "Edit Shelf") + + await shelfEdit.getByPlaceholder("What's this shelf about?").fill("Things worth reading twice") + await shelfEdit.getByRole("button", {name: "Save changes"}).click() + + await expect(page.getByRole("alert")).toContainText("Shelf updated!") + await expect(page.getByRole("button", {name: /Reading List/})).toContainText( + "Things worth reading twice", + ) + + // A post filed from its own menu lands on the shelf that was picked for it. + await page.goto(pollPath) + await menuOf(pollCard).click() + await page.getByRole("button", {name: "Add to Library"}).click() + await page.getByRole("button", {name: /Reading List/}).click() + + const fromPoll = panel(page, "Add Link") + + await expect(fromPoll.getByPlaceholder("URL or nevent...")).not.toHaveValue("") + + await fromPoll.getByPlaceholder("Optional title").fill("Standup poll") + await fromPoll.getByRole("button", {name: "Add link"}).click() + + await expect(page.getByRole("alert")).toContainText("Link added!") + + await page.goto(libraryPath) + await page.getByRole("button", {name: /Reading List/}).click() + + await expect(page.getByText("Standup poll")).toBeVisible() + await expect(page.getByText("Should we move standup to 10?")).toBeVisible() + + // Deleting the shelf from its menu takes it off the list. + await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click() + await page.getByRole("button", {name: "Delete shelf"}).click() + await page.getByRole("button", {name: "Confirm"}).click() + + await expect(page.getByRole("alert")).toContainText("Shelf deleted!") + await expect(page.getByRole("button", {name: /Reading List/})).toHaveCount(0) + await expect(page.getByText("No shelves found.")).toBeVisible() +}) + +test("US-055 create community content 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")) + + const compose = page.locator("form").filter({has: page.locator(".chat-editor")}) + + await compose.locator(".join").getByRole("button").nth(1).click() + + // Everything the room can be given from the composer, named as the menu names it. + for (const name of [ + "Funding Goal", + "Calendar Event", + "Classified Listing", + "Create Thread", + "Write an Article", + "Ask a Question", + ]) { + await expect(page.getByRole("button", {name, exact: true})).toBeVisible() + } + + await page.getByRole("button", {name: "Ask a Question", exact: true}).click() + + const composer = dialog(page, "Create a Poll") + + await composer.getByPlaceholder("What would you like to ask?").fill("Pizza or tacos?") + await composer.getByPlaceholder("Option 1").fill("Pizza") + await composer.getByPlaceholder("Option 2").fill("Tacos") + await composer.getByRole("button", {name: "Create Poll"}).click() + + await expect(page.getByRole("heading", {name: "Create a Poll"})).toHaveCount(0) + + // The room gets a quote of the poll rather than a second post written by hand. A room item is + // itself a role=button tap target wrapping its content, so the quote is the inner of the two. + const quote = page.getByRole("button").filter({hasText: "Pizza or tacos?"}).last() + + await expect(quote).toBeVisible() + + await quote.click() + + await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/polls/[0-9a-f]{64}$`)) + await expect(page.getByRole("heading", {name: "Pizza or tacos?"})).toBeVisible() + await expect(pollOption(page, "Pizza")).toBeVisible() + await expect(pollOption(page, "Tacos")).toBeVisible() +}) diff --git a/e2e/specs/composer.spec.ts b/e2e/specs/composer.spec.ts new file mode 100644 index 00000000..3ec2b9c1 --- /dev/null +++ b/e2e/specs/composer.spec.ts @@ -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() + + let held = Promise.resolve() + let open = () => {} + + return { + hold: () => { + held = new Promise(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 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) +}) diff --git a/e2e/specs/content-rendering.spec.ts b/e2e/specs/content-rendering.spec.ts new file mode 100644 index 00000000..9ea78c3e --- /dev/null +++ b/e2e/specs/content-rendering.spec.ts @@ -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(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) +}) diff --git a/e2e/specs/delivery.spec.ts b/e2e/specs/delivery.spec.ts new file mode 100644 index 00000000..4bfdc7ff --- /dev/null +++ b/e2e/specs/delivery.spec.ts @@ -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) +}) diff --git a/e2e/specs/dms.spec.ts b/e2e/specs/dms.spec.ts new file mode 100644 index 00000000..94ff6f0a --- /dev/null +++ b/e2e/specs/dms.spec.ts @@ -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() +}) diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts new file mode 100644 index 00000000..015048b8 --- /dev/null +++ b/e2e/specs/notifications.spec.ts @@ -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$/) +}) diff --git a/e2e/specs/onboarding.spec.ts b/e2e/specs/onboarding.spec.ts new file mode 100644 index 00000000..aaa97c9e --- /dev/null +++ b/e2e/specs/onboarding.spec.ts @@ -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() +}) diff --git a/e2e/specs/people.spec.ts b/e2e/specs/people.spec.ts new file mode 100644 index 00000000..92b7be27 --- /dev/null +++ b/e2e/specs/people.spec.ts @@ -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}}} + network: {get(): {publish(options: {event: object; relays: string[]}): Promise}} + 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) +}) diff --git a/e2e/specs/rooms.spec.ts b/e2e/specs/rooms.spec.ts new file mode 100644 index 00000000..23233cc1 --- /dev/null +++ b/e2e/specs/rooms.spec.ts @@ -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() +}) diff --git a/e2e/specs/settings.spec.ts b/e2e/specs/settings.spec.ts new file mode 100644 index 00000000..f7c21698 --- /dev/null +++ b/e2e/specs/settings.spec.ts @@ -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") +}) diff --git a/e2e/specs/spaces.spec.ts b/e2e/specs/spaces.spec.ts new file mode 100644 index 00000000..83139fb7 --- /dev/null +++ b/e2e/specs/spaces.spec.ts @@ -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=/) +})