Add tests for all user stories
This commit is contained in:
parent
c53aaf8df8
commit
aa2483fe78
28 changed files with 9744 additions and 97 deletions
|
|
@ -17,7 +17,12 @@ what keeps outbox routing and cross-space isolation testable.
|
|||
|
||||
A relay's policy is its toml and nothing else. A scenario says what is _on_ a relay — its rooms, its
|
||||
members, its messages — never what the relay _is_, so there is no policy negotiation anywhere in the
|
||||
harness.
|
||||
harness. `space` and `other` are the same permissive policy twice over, for the specs that need two
|
||||
of them; `closed` refuses a join that carries no invite claim, which is what raises "Request
|
||||
Access"; `unsigned` serves events with their signatures stripped, which is what raises "Do you trust
|
||||
this space?". Seeding a membership on `closed` is therefore not possible — `join()` and `member()`
|
||||
publish a claimless join and the relay refuses it — so a scenario there seeds admin-created rooms
|
||||
and lets the spec do the joining.
|
||||
|
||||
### Why the relays are called `<name>.test`
|
||||
|
||||
|
|
@ -63,11 +68,11 @@ Every socket the browser opens is terminated here, in this process, and the only
|
|||
is the loopback connection `zooid/transport.ts` makes to the container the test started.
|
||||
`assertNoLeaks()` fails a test that touched a url the scenario never declared.
|
||||
|
||||
Interception is installed by `as()`, on a context it creates, so a page that came from anywhere else
|
||||
has none of it. Playwright's own `context` fixture — and the `page` fixture built on it — is
|
||||
therefore overridden to throw, so a spec written the ordinary way fails immediately with a message
|
||||
saying so rather than quietly dialling the relays in `.env`. The built-in `request` fixture goes the
|
||||
same way: an `APIRequestContext` is an http client in the node process that belongs to no browser
|
||||
Interception is installed by `as()` and `visit()`, on a context each of them creates, so a page that
|
||||
came from anywhere else has none of it. Playwright's own `context` fixture — and the `page` fixture
|
||||
built on it — is therefore overridden to throw, so a spec written the ordinary way fails immediately
|
||||
with a message saying so rather than quietly dialling the relays in `.env`. The built-in `request`
|
||||
fixture goes the same way: an `APIRequestContext` is an http client in the node process that belongs to no browser
|
||||
context, so the block-all below cannot see it and nothing records what it sent.
|
||||
|
||||
`playwright` is the one fixture left alone, and it has to be: it is where the run's own browser comes
|
||||
|
|
@ -112,6 +117,15 @@ analytics script hard-coded in `src/app.html` is mocked with an empty body: it i
|
|||
navigation whatever the scenario is doing, so leaving it to the block-all would make
|
||||
`assertNoBlockedRequests()` a statement about the page shell rather than about the test.
|
||||
|
||||
Two of those answers are the scenario's own. NIP-11 fields a spec names are merged over the relay's
|
||||
real document — a `redirect_to`, a `limitation`, a NIP the relay does not implement — rather than
|
||||
replacing it, because `self`, `pubkey` and `supported_nips` are what room state is trusted from, and
|
||||
the merge is installed with the page, since the document is read at startup and cached from then on.
|
||||
The hosting API is a small stateful fake rather than fixed answers: a write mutates the record it
|
||||
names and the next read sees it, so editing a space, deactivating it or paying an invoice is
|
||||
observable. `getHosting(context)` changes the backend's mind between two of the user's clicks — a
|
||||
custom domain that verifies, an invoice that gets paid.
|
||||
|
||||
## Containment
|
||||
|
||||
A browser has a fixed set of ways to put bytes on a wire.
|
||||
|
|
@ -199,6 +213,8 @@ would have refused fails the test instead of appearing in a query.
|
|||
|
||||
Test identities are deterministic secp256k1 keypairs derived from fixed secrets (`alice`, `bob`,
|
||||
`carol`, `admin`), so a pubkey is stable across runs and can be asserted on directly.
|
||||
`makeTestUser(name)` derives a fifth, sixth or hundredth one from its name and registers it, for the
|
||||
directory and search specs that need more people than there are named ones.
|
||||
|
||||
A logged-in user is created by injecting a NIP-01 session before the app boots. `src/app/session.ts`
|
||||
carries a DEV-only hook: `restoreSession()` prefers `window.__TEST_SESSION__` when it is present, and
|
||||
|
|
@ -232,6 +248,24 @@ in for an indexer.
|
|||
Each user is a separate `BrowserContext`, which also gives each one its own IndexedDB and
|
||||
localStorage, so nothing bleeds between users.
|
||||
|
||||
An injected session is re-applied on every navigation, so through `as()` a reload, a logout and a
|
||||
login are all unobservable. `visit(path, options)` is the same page without one — same context, same
|
||||
interception, same env — and it is what a spec that watches someone arrive, sign in and come back
|
||||
starts from. Both take the same options, over and above the scenario's own relays:
|
||||
|
||||
| option | what it does |
|
||||
| ----------- | ------------------------------------------------------------------------------------ |
|
||||
| `context` | merged over the project's context options: a viewport, a colour scheme, a permission |
|
||||
| `env` | `VITE_` values applied over the ones derived from the scenario's relays |
|
||||
| `nip07` | a `window.nostr` backed by that identity's own signer, for an extension login |
|
||||
| `relayInfo` | fields merged over a relay's own NIP-11 document, keyed by relay url |
|
||||
| `hosting` | what the hosting backend already knows about this user |
|
||||
|
||||
Whatever `env` names still has to be something the scenario owns — a platform relay, the domain
|
||||
hosted spaces are created under — or the app dials a host nothing serves and the test fails on a
|
||||
leak. The NIP-07 provider is a real signer rather than a stub, because the session it produces has to
|
||||
sign the NIP-42 challenges the members-only relays send.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
|
|
@ -256,9 +290,11 @@ e2e/
|
|||
boot.ts env overrides, navigate, wait for mount and for the app to have read
|
||||
the overrides
|
||||
session.ts NIP-01 session injection
|
||||
nip07.ts a window.nostr backed by a test identity's own signer
|
||||
seed/
|
||||
scenario.ts the `seed()` builder and relative-time helpers
|
||||
space.ts one space's fixtures: rooms, members, messages, replies, profiles
|
||||
space.ts one space's fixtures: rooms, members, messages, replies, profiles,
|
||||
direct messages, and anything a domain writer renders
|
||||
specs/
|
||||
*.spec.ts
|
||||
```
|
||||
|
|
@ -272,13 +308,23 @@ importing anything under `src` would pull sveltekit into the node process.
|
|||
|
||||
`Zooid.reset()` recreates the container rather than restarting it: with no volume mounted for
|
||||
`/app/data`, storage lives in the container's writable layer and a tmpfs, so a fresh container is a
|
||||
fresh database. The configs are bind-mounted read-only and survive, so every test starts against the
|
||||
same relays with nothing in them. The container is a worker fixture, so the docker start-up cost is
|
||||
paid once per worker rather than once per test.
|
||||
fresh database. What it mounts as its config is a copy of `docker/config`, staged outside the repo
|
||||
and refreshed on the way up — zooid saves a relay's toml back when a NIP-86 call edits its name — so
|
||||
every test starts against the same relays with nothing in them. The container is a worker fixture,
|
||||
so the docker start-up cost is paid once per worker rather than once per test.
|
||||
|
||||
Seeding then writes over a real authenticated socket, one per identity per relay, held open for the
|
||||
rest of the test. A fixture must therefore be signed by one of the identities in `harness/keys.ts`,
|
||||
since zooid authenticates every write.
|
||||
rest of the test. A fixture must therefore be signed by an identity `harness/keys.ts` holds, since
|
||||
zooid authenticates every write.
|
||||
|
||||
Anything richer than a room message is built by the domain writers the app itself publishes with:
|
||||
`space.kind(Article)` hands back the kind configured against this space, and `space.event(user, () =>
|
||||
…renderTemplate())` defers the render until the space has a url to render hints against, which is
|
||||
only true once the queue has drained. A NIP-17 conversation is `space.dm(from, to, content)`: it
|
||||
gift-wraps one rumor per participant and publishes each wrap over the sender's connection, since a
|
||||
wrap is signed by an ephemeral key nobody here can authenticate as. zooid stores it anyway, because
|
||||
it authorizes a kind-1059 by the member named in its `p` tag — and refuses one addressed to a
|
||||
stranger.
|
||||
|
||||
## Running
|
||||
|
||||
|
|
|
|||
108
e2e/SUSPECTED_BUGS.md
Normal file
108
e2e/SUSPECTED_BUGS.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Suspected app bugs
|
||||
|
||||
Places where the app source contradicts a story in `USER_STORIES.md`. The spec for each asserts
|
||||
the story, so it fails until the bug is fixed. None of this has been confirmed by running the
|
||||
suite.
|
||||
|
||||
## Rooms & membership
|
||||
|
||||
- **US-019 / US-020 — joined and created rooms never land under "Your Rooms".** `Rooms.joinRoom`
|
||||
publishes only kind-9021 and `RoomForm.submit` publishes create/edit/join — none of them write
|
||||
the user's kind-10009 room list, which is what `deriveUserRooms` reads. Only the Favorite toggle
|
||||
and the delete path touch the list.
|
||||
- **US-019 — leaving a room's member list is silent on success.** `RoomDetailMenu.handleLoading`
|
||||
only toasts when `waitForError()` returns a message.
|
||||
- **US-021 — private-room card says "Join Room" where the story (and the restricted-room card)
|
||||
say "Ask to Join"** (`RoomChat.svelte` isPrivate vs isRestricted branches).
|
||||
- **US-028 — no Share action exists on any message.** `shareEvent` is wired only into EventMenu
|
||||
("Share to Chat"), BoardMenu and PinMenu; the cross-space Share picker is reachable only from
|
||||
native share intents or `/share`.
|
||||
|
||||
## Spaces & access
|
||||
|
||||
- **US-011 — the "Request Access" state is unreachable on a `public_join = false` relay.**
|
||||
welshman's `RelayJoinWriter.setClaim("")` always writes a `claim` tag, so zooid answers
|
||||
"failed to validate invite code" instead of "no claim tag"; `attemptRelayAccess` swallows that
|
||||
error when no claim was supplied, so `SpaceJoin` shows "Join Space" and toasts "Welcome to the
|
||||
space!" for a space that refused the join.
|
||||
- **US-009 — "Browse Spaces" section renders as "More Spaces"** whenever the user has joined
|
||||
spaces, so the story's two named sections never appear together.
|
||||
|
||||
## DMs & composer
|
||||
|
||||
- **US-035 — up-arrow in an empty DM composer edits the oldest editable message, not the newest.**
|
||||
`Chat.svelte` does `messages.toReversed().find(canEditEvent)` but `chat.messages` is already
|
||||
newest-first; RoomChat uses the same expression over an ascending list, so the DM copy inverted
|
||||
the ordering.
|
||||
- **US-059 — a cancelled edit clobbers the room draft.** RoomChat re-keys RoomCompose on
|
||||
`initialValues` while passing the same `draftKey` during editing, so the draft store is
|
||||
overwritten with the edited message's text. Chat.svelte avoids this by passing
|
||||
`draftKey={eventToEdit ? undefined : draftKey}`.
|
||||
- **US-056 — mentions render with no avatar** (composer nodeview is a bare text span;
|
||||
`ContentMention` renders `@name` with no image).
|
||||
- **US-056 — `~room` references render as plain text once sent.** `parseRoom` pre-empts
|
||||
`parseLink` and `Content.svelte` has no `isRoom` branch, so the intended `#RoomName` link path
|
||||
in `ContentLinkUrl` is unreachable.
|
||||
- **US-057 — an unsupported file type is rejected silently.** `Uploader.addFile` returns false
|
||||
before any upload on a mime mismatch, so `onUploadError` (the only toast in the path) never runs.
|
||||
|
||||
## Delivery & deletion
|
||||
|
||||
- **US-072 — the "Deleted" pill never replaces reactions/menus.** In Article/Comment/Thread/Poll/
|
||||
Goal/CalendarEvent Actions, `ThunkStatusOrDeleted` is a sibling of `ReactionSummary`/
|
||||
`EventActions` instead of wrapping them; `ClassifiedActions` is the only one wired as intended.
|
||||
- **US-072 — a deleted event vanishes from lists and threads instead of staying with the pill.**
|
||||
`Repository` emits the target in `removed` and `makeFeed`/`deriveEventsById` prune on it; only
|
||||
`deriveEvent` (`includeDeleted: true`) retains it, so the pill is only reachable on detail pages.
|
||||
- **US-071 — the publish-status popover's timeout reason reads "timed out." not "request timed
|
||||
out".** `publishOne` always sets `detail: "timed out"`, making `ThunkStatusDetail`'s fallback
|
||||
branch unreachable.
|
||||
|
||||
## People & profiles
|
||||
|
||||
- **US-079 — profile notes show an absolute date+clock** (`NoteCard` uses `formatTimestamp`)
|
||||
where the story asks for a relative timestamp (`formatTimestampRelative` exists and is used for
|
||||
"Last active").
|
||||
- **US-079 — a note published live never appears in another user's open profile view without a
|
||||
reload.** `syncSpace`'s CONTENT_KINDS contains no NOTE kind and `makeFeed`'s forward scroller
|
||||
stops on its first tick.
|
||||
- **US-008 — deleting your profile can delete the blanked kind-0 it just published.** The kind-5
|
||||
requests carry an `a` tag (`0:<pubkey>`) newer than the blanked profile; zooid has no NIP-62
|
||||
handling, and in a fresh session no kind-5 may be sent at all.
|
||||
|
||||
## Settings
|
||||
|
||||
- **US-091 — the zero/negative zap-amount error toast is unreachable.** The inputs carry
|
||||
`min="1"`, so native constraint validation blocks submit before `onZapAmountsSubmit` runs.
|
||||
- **US-084 — blocked relays still appear in relay suggestions.** `RelayAdd` filters on
|
||||
matchRelay/already-selected/isIPAddress only; `BlockedRelayLists` is consumed solely by
|
||||
`policies.ts` to refuse sockets.
|
||||
|
||||
## Content rendering
|
||||
|
||||
- **US-067 — a bare `lnbc1…` invoice renders as plain prose.** `parseInvoice` only matches behind
|
||||
a `lightning:` scheme and `parseLink` skips it too, so the copyable chip appears only for the
|
||||
prefixed form.
|
||||
- **US-046 — the calendar "Today" divider keys off `created_at` instead of the event's start tag**
|
||||
(`isFuture = todayDateDisplay === newDateDisplay || event.created_at > now()`), so it lands on
|
||||
the wrong item for past-published events. (Not covered by the suite; the scroll effect uses the
|
||||
correct `getStart`.)
|
||||
|
||||
## Hosting & admin
|
||||
|
||||
- **US-102 — payment history is not sorted client-side.** The hosting settings page renders
|
||||
`listTenantInvoices` order verbatim, so "most recent first" depends on the backend.
|
||||
- **US-041 — the article list card lacks the "Posted in #room" badge** the story names for both
|
||||
surfaces; `ArticleItem` never passes `showRoom` and renders the room inline in the byline
|
||||
instead.
|
||||
- **US-092 — possible over-broad admin gate (inferred, unverified).** `deriveUserIsSpaceAdmin` is
|
||||
`supportedMethods().length > 0`, and zooid's migration writes
|
||||
`member_methods = ["listclaims", "createclaim"]` — if applied, every member reports as an admin.
|
||||
|
||||
## Known coverage gaps (not bugs)
|
||||
|
||||
- **US-070 bullet 3** (article/comment retry succeeds and clears the indicator) is uncoverable:
|
||||
no UI path makes an article publish fail then succeed in one session without discarding
|
||||
`$thunks.history`.
|
||||
- **US-065's "briefly shows a loading state"** is inherently racy (sync may already hold the
|
||||
quoted event); the assertion stands as the story requires but may flake.
|
||||
|
|
@ -20,11 +20,14 @@ export type BootOptions = {
|
|||
// What this user's client already has in local storage, e.g. their room list.
|
||||
events?: TrustedEvent[]
|
||||
path?: string
|
||||
// VITE_ values the scenario sets for itself, applied over the relay-derived ones below. Anything
|
||||
// named here still has to be something the test owns, or the app will reach for it.
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export const boot = async (
|
||||
context: BrowserContext,
|
||||
{relays, spaces = [], user, events = [], path = "/"}: BootOptions,
|
||||
{relays, spaces = [], user, events = [], path = "/", env = {}}: BootOptions,
|
||||
) => {
|
||||
const urls = relays.join(",")
|
||||
|
||||
|
|
@ -55,6 +58,7 @@ export const boot = async (
|
|||
// Nothing serves this url, so a push bridge connection is reported as a leak instead of
|
||||
// blending into the traffic of a relay the scenario did create.
|
||||
VITE_PUSH_BRIDGE: "ws://localhost:1/",
|
||||
...env,
|
||||
},
|
||||
] as const,
|
||||
)
|
||||
|
|
|
|||
61
e2e/harness/app/nip07.ts
Normal file
61
e2e/harness/app/nip07.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type {BrowserContext} from "@playwright/test"
|
||||
import type {StampedEvent} from "@welshman/util"
|
||||
import type {TestUser} from "../keys"
|
||||
|
||||
// The function playwright installs on window for the shim below to call into. A binding is the
|
||||
// only way across: the keys live in node, and a signer built in the page would be a different
|
||||
// thing from the one seeding signs with.
|
||||
const TEST_NIP07_KEY = "__TEST_NIP07__"
|
||||
|
||||
type Nip07Call =
|
||||
| {method: "getPublicKey"}
|
||||
| {method: "signEvent"; template: StampedEvent}
|
||||
| {method: "encrypt" | "decrypt"; scheme: "nip04" | "nip44"; pubkey: string; message: string}
|
||||
|
||||
/**
|
||||
* A NIP-07 provider backed by a test identity's real signer, so an extension login produces
|
||||
* signatures the relays accept — including the NIP-42 auth events a members-only relay demands.
|
||||
*
|
||||
* Both halves have to be installed before the page navigates: LogIn.svelte reads `window.nostr`
|
||||
* while it renders to decide whether to offer the button at all.
|
||||
*/
|
||||
export const injectNip07 = async (context: BrowserContext, user: TestUser) => {
|
||||
await context.exposeBinding(TEST_NIP07_KEY, (source, call: Nip07Call) => {
|
||||
if (call.method === "getPublicKey") {
|
||||
return user.pubkey
|
||||
}
|
||||
|
||||
if (call.method === "signEvent") {
|
||||
return user.signer.sign(call.template)
|
||||
}
|
||||
|
||||
const {encrypt, decrypt} = call.scheme === "nip04" ? user.signer.nip04 : user.signer.nip44
|
||||
|
||||
return call.method === "encrypt"
|
||||
? encrypt(call.pubkey, call.message)
|
||||
: decrypt(call.pubkey, call.message)
|
||||
})
|
||||
|
||||
await context.addInitScript(key => {
|
||||
const call = (request: Nip07Call) => Reflect.get(window, key)(request)
|
||||
|
||||
Object.assign(window, {
|
||||
nostr: {
|
||||
getPublicKey: () => call({method: "getPublicKey"}),
|
||||
signEvent: (template: StampedEvent) => call({method: "signEvent", template}),
|
||||
nip04: {
|
||||
encrypt: (pubkey: string, message: string) =>
|
||||
call({method: "encrypt", scheme: "nip04", pubkey, message}),
|
||||
decrypt: (pubkey: string, message: string) =>
|
||||
call({method: "decrypt", scheme: "nip04", pubkey, message}),
|
||||
},
|
||||
nip44: {
|
||||
encrypt: (pubkey: string, message: string) =>
|
||||
call({method: "encrypt", scheme: "nip44", pubkey, message}),
|
||||
decrypt: (pubkey: string, message: string) =>
|
||||
call({method: "decrypt", scheme: "nip44", pubkey, message}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}, TEST_NIP07_KEY)
|
||||
}
|
||||
|
|
@ -10,22 +10,32 @@ import {
|
|||
mockHosting,
|
||||
mockImages,
|
||||
mockPushServer,
|
||||
mockRelayInfo,
|
||||
} from "./net/http"
|
||||
import type {HostingFixtures, RelayInfoOverrides} from "./net/http"
|
||||
import {assertNoLeaks, installWebSocketRoutes} from "./net/websocket"
|
||||
import {boot} from "./app/boot"
|
||||
import {injectNip07} from "./app/nip07"
|
||||
import {seed} from "./seed/scenario"
|
||||
import type {Scenario, SeedTools} from "./seed/scenario"
|
||||
import type {TestUser} from "./keys"
|
||||
|
||||
export {expect}
|
||||
export {users} from "./keys"
|
||||
export {makeTestUser, users} from "./keys"
|
||||
export type {TestUser} from "./keys"
|
||||
export type {Scenario} from "./seed/scenario"
|
||||
export type {SeededSpace} from "./seed/space"
|
||||
export type {SeededRumor, SeededSpace} from "./seed/space"
|
||||
export type {TenantName} from "./zooid/config"
|
||||
export type {TranscriptEntry} from "./net/websocket"
|
||||
export {formatTranscript, getTranscript} from "./net/websocket"
|
||||
export {assertNoBlockedRequests, mockBlossom, mockLivekit} from "./net/http"
|
||||
export {
|
||||
assertNoBlockedRequests,
|
||||
getHosting,
|
||||
mockBlossom,
|
||||
mockDufflepud,
|
||||
mockLivekit,
|
||||
} from "./net/http"
|
||||
export type {DufflepudFixtures, HostingFixtures, HostingHandle, HostingRecord} from "./net/http"
|
||||
|
||||
// Mirrors encodeRelay in src/app/relays.ts, which can't be imported here — it reaches the app's
|
||||
// module graph, and with it sveltekit.
|
||||
|
|
@ -40,19 +50,40 @@ export const spacePath = (url: string) => `/spaces/${encodeRelay(url)}`
|
|||
|
||||
export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}`
|
||||
|
||||
// What a page is opened with, over and above the scenario's own relays.
|
||||
export type PageOptions = {
|
||||
// Overrides the project's context options, for a spec that needs a viewport, a colour scheme or
|
||||
// a permission of its own.
|
||||
context?: BrowserContextOptions
|
||||
// VITE_ values applied over the ones derived from the scenario's relays, e.g. a platform space
|
||||
// or the domain hosted relays are created under. Whatever is named here has to be something the
|
||||
// scenario owns, or the app will reach for it and the test will fail on a leak.
|
||||
env?: Record<string, string>
|
||||
// A NIP-07 provider signing as this user, for a login that goes through an extension.
|
||||
nip07?: TestUser
|
||||
// Fields merged over a relay's own nip-11 document, keyed by relay url.
|
||||
relayInfo?: RelayInfoOverrides
|
||||
// What the hosting backend already knows about this user. Read `getHosting(page.context())` for
|
||||
// the handle that changes it mid-test.
|
||||
hosting?: HostingFixtures
|
||||
}
|
||||
|
||||
export type Harness = {
|
||||
zooid: Zooid
|
||||
seed(build: (tools: SeedTools) => MaybeAsync<void>): Promise<Scenario>
|
||||
// A logged-in page for a user: its own browser context, its own storage, its own sockets into
|
||||
// the relays every other user is talking to. `options` overrides the project's context options,
|
||||
// for a spec that needs a viewport or colour scheme of its own.
|
||||
as(user: TestUser, path?: string, options?: BrowserContextOptions): Promise<Page>
|
||||
// the relays every other user is talking to.
|
||||
as(user: TestUser, path?: string, options?: PageOptions): Promise<Page>
|
||||
// The same page with no session injected — the app as someone who has never logged in sees it,
|
||||
// and the only way to watch a login, a reload or a logout happen.
|
||||
visit(path?: string, options?: PageOptions): Promise<Page>
|
||||
}
|
||||
|
||||
export type HarnessFixtures = {
|
||||
harness: Harness
|
||||
seed: Harness["seed"]
|
||||
as: Harness["as"]
|
||||
visit: Harness["visit"]
|
||||
}
|
||||
|
||||
export type HarnessWorkerFixtures = {
|
||||
|
|
@ -63,11 +94,13 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
// Playwright's own context — and the `page` fixture built on it — is unrouted: no websocket
|
||||
// interception, no http block-all, no injected env, and nothing collecting leaks from it. A page
|
||||
// born there boots the app against the relays baked into .env, which is the one thing this suite
|
||||
// exists to prevent, so it is refused outright and `as()` is the only way to get a page.
|
||||
// exists to prevent, so it is refused outright and `as()`/`visit()` are the only ways to get a
|
||||
// page.
|
||||
context: async () => {
|
||||
throw new Error(
|
||||
"The built-in `context` and `page` fixtures reach the real network. Open a page with the " +
|
||||
"harness's `as(user, path)` fixture, which installs interception before it navigates.",
|
||||
"harness's `as(user, path)` or `visit(path)` fixture, which install interception before " +
|
||||
"they navigate.",
|
||||
)
|
||||
},
|
||||
// Playwright builds this one with `playwright.request.newContext()`, so it is an http client in
|
||||
|
|
@ -111,7 +144,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
throw new Error("Seed a scenario before opening a page for a user")
|
||||
}
|
||||
|
||||
const as = async (user: TestUser, path = "/", options: BrowserContextOptions = {}) => {
|
||||
const open = async (path: string, options: PageOptions, user?: TestUser) => {
|
||||
const {urls, cache} = requireScenario()
|
||||
// The project's own `use` first, so a viewport, colour scheme or device descriptor set in
|
||||
// playwright.config.ts reaches the context rather than being silently dropped.
|
||||
|
|
@ -123,7 +156,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
const context = await browser.newContext({
|
||||
...testInfo.project.use,
|
||||
serviceWorkers: "block",
|
||||
...options,
|
||||
...options.context,
|
||||
})
|
||||
|
||||
contexts.push(context)
|
||||
|
|
@ -133,18 +166,31 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
// recognize, so the mocks have to be registered last to be reachable at all.
|
||||
await installHttpRoutes(context)
|
||||
await installWebSocketRoutes(context, zooid)
|
||||
await mockRelayInfo(context, options.relayInfo ?? {})
|
||||
await mockAnalytics(context)
|
||||
await mockDufflepud(context)
|
||||
await mockHosting(context)
|
||||
await mockHosting(context, options.hosting)
|
||||
await mockPushServer(context)
|
||||
await mockImages(context)
|
||||
|
||||
return boot(context, {user, path, relays: urls, spaces: urls, events: cache(user)})
|
||||
if (options.nip07) {
|
||||
await injectNip07(context, options.nip07)
|
||||
}
|
||||
|
||||
return boot(context, {
|
||||
user,
|
||||
path,
|
||||
env: options.env,
|
||||
relays: urls,
|
||||
spaces: urls,
|
||||
events: user ? cache(user) : [],
|
||||
})
|
||||
}
|
||||
|
||||
await use({
|
||||
zooid,
|
||||
as,
|
||||
as: (user, path = "/", options = {}) => open(path, options, user),
|
||||
visit: (path = "/", options = {}) => open(path, options),
|
||||
seed: async build => {
|
||||
scenario = await seed(zooid, build)
|
||||
|
||||
|
|
@ -162,4 +208,5 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
|||
},
|
||||
seed: async ({harness}, use) => use(harness.seed),
|
||||
as: async ({harness}, use) => use(harness.as),
|
||||
visit: async ({harness}, use) => use(harness.visit),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import {createHash} from "node:crypto"
|
||||
import {getPubkey} from "@welshman/util"
|
||||
import {Nip01Signer} from "@welshman/signer"
|
||||
|
||||
|
|
@ -8,12 +9,17 @@ export type TestUser = {
|
|||
signer: Nip01Signer
|
||||
}
|
||||
|
||||
const makeUser = (name: string, secret: string): TestUser => ({
|
||||
name,
|
||||
secret,
|
||||
pubkey: getPubkey(secret),
|
||||
signer: new Nip01Signer(secret),
|
||||
})
|
||||
// Every identity this process can sign as. zooid authenticates each write and refuses an event
|
||||
// whose author is not the authenticated pubkey, so seeding looks a fixture's author up here.
|
||||
export const testUsersByPubkey = new Map<string, TestUser>()
|
||||
|
||||
const makeUser = (name: string, secret: string): TestUser => {
|
||||
const user = {name, secret, pubkey: getPubkey(secret), signer: new Nip01Signer(secret)}
|
||||
|
||||
testUsersByPubkey.set(user.pubkey, user)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// The secrets are near-zero entropy on purpose: they never leave the test process, they are
|
||||
// stable across runs so a pubkey can be asserted on directly, and the leading nibbles make an
|
||||
|
|
@ -24,3 +30,19 @@ export const users = {
|
|||
carol: makeUser("carol", "ca20100000000000000000000000000000000000000000000000000000000003"),
|
||||
admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"),
|
||||
}
|
||||
|
||||
// secp256k1's group order. A secret is a scalar in [1, n), and a hash lands outside that range
|
||||
// only astronomically rarely, but reducing rather than rejecting keeps the derivation total.
|
||||
const CURVE_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")
|
||||
|
||||
/**
|
||||
* A fifth, sixth, hundredth identity, named rather than listed. The secret is derived from the
|
||||
* name, so the pubkey is as stable across runs as the four above, and minting one registers it —
|
||||
* a scenario can seed a profile, a message or a follow for it like any other test user.
|
||||
*/
|
||||
export const makeTestUser = (name: string) => {
|
||||
const digest = BigInt("0x" + createHash("sha256").update(name).digest("hex"))
|
||||
const secret = ((digest % (CURVE_ORDER - 1n)) + 1n).toString(16).padStart(64, "0")
|
||||
|
||||
return makeUser(name, secret)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {createHash} from "node:crypto"
|
||||
import type {BrowserContext} from "@playwright/test"
|
||||
import {now} from "@welshman/lib"
|
||||
import {HOUR, int, now, omit} from "@welshman/lib"
|
||||
import type {Handle} from "@welshman/util"
|
||||
import type {ZapperValues} from "@welshman/domain"
|
||||
import {tenantByUrl} from "../zooid/config"
|
||||
|
|
@ -16,6 +16,10 @@ const HOSTING_ORIGIN = "https://api.hosting.coracle.social"
|
|||
// Hard-coded in src/app.html, so every navigation asks for it whatever the scenario is doing.
|
||||
const PLAUSIBLE_ORIGIN = "https://plausible.coracle.social"
|
||||
|
||||
// Where the hosting api sends a browser to pay. Nothing serves it — `.test` resolves nowhere and
|
||||
// the block-all aborts the navigation — so a spec sees the redirect without one leaving.
|
||||
const CHECKOUT_ORIGIN = "https://checkout.test"
|
||||
|
||||
// Relay-hosted livekit lives under a well-known path rather than an origin of its own.
|
||||
const LIVEKIT_PATH = "/.well-known/nip29/livekit"
|
||||
|
||||
|
|
@ -31,6 +35,13 @@ const PNG = Buffer.from(
|
|||
export const isDevServerUrl = (url: URL) =>
|
||||
url.port === "1847" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
||||
|
||||
// A relay is reached over wss and its own http origin is where nip-11 and nip-86 live.
|
||||
const relayOrigin = (url: string) => new URL(url.replace(/^ws/, "http")).origin
|
||||
|
||||
const hostByOrigin = new Map(
|
||||
Array.from(tenantByUrl, ([url, host]): [string, string] => [relayOrigin(url), host]),
|
||||
)
|
||||
|
||||
export type BlockedRequest = {
|
||||
method: string
|
||||
url: string
|
||||
|
|
@ -57,12 +68,6 @@ export const getBlockedRequests = (context: BrowserContext) => {
|
|||
*/
|
||||
export const installHttpRoutes = (context: BrowserContext) => {
|
||||
const blocked: BlockedRequest[] = []
|
||||
const hostByOrigin = new Map(
|
||||
Array.from(tenantByUrl, ([url, host]): [string, string] => [
|
||||
new URL(url.replace(/^ws/, "http")).origin,
|
||||
host,
|
||||
]),
|
||||
)
|
||||
|
||||
blockedByContext.set(context, blocked)
|
||||
|
||||
|
|
@ -114,6 +119,56 @@ export const assertNoBlockedRequests = (context: BrowserContext) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Fields to merge over a relay's own nip-11 document, keyed by its url.
|
||||
export type RelayInfoOverrides = Record<string, object>
|
||||
|
||||
/**
|
||||
* A relay's real document with a few fields replaced: a `redirect_to`, a `limitation`, a NIP the
|
||||
* relay does not implement. Merged rather than fabricated, because `self`, `pubkey`, `name` and
|
||||
* `supported_nips` are what room state is trusted from — a hand-written document breaks every
|
||||
* room on the page.
|
||||
*
|
||||
* Install it before the page navigates: the document is read at startup and cached from then on.
|
||||
*/
|
||||
export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverrides) => {
|
||||
const overrideByOrigin = new Map(
|
||||
Object.entries(overrides).map(([url, override]): [string, object] => [
|
||||
relayOrigin(url),
|
||||
override,
|
||||
]),
|
||||
)
|
||||
|
||||
return context.route(
|
||||
url => url.pathname === "/" && overrideByOrigin.has(url.origin),
|
||||
async route => {
|
||||
const request = route.request()
|
||||
const {origin} = new URL(request.url())
|
||||
const host = hostByOrigin.get(origin)
|
||||
const override = overrideByOrigin.get(origin)
|
||||
|
||||
// The nip-86 management api posts to this same path, and what it answers decides what the
|
||||
// admin ui offers, so only the document request is touched.
|
||||
if (request.method() === "GET" && host && override) {
|
||||
const {status, headers, body} = await requestZooid(host, "GET", "/", {
|
||||
...request.headers(),
|
||||
// The merge has to read the document, and khatru will compress it if invited to.
|
||||
"accept-encoding": "identity",
|
||||
})
|
||||
|
||||
return route.fulfill({
|
||||
status,
|
||||
// khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's own
|
||||
// headers are kept — all but the length of a body that is about to change.
|
||||
headers: omit(["content-length"], headers),
|
||||
body: JSON.stringify({...JSON.parse(body.toString()), ...override}),
|
||||
})
|
||||
}
|
||||
|
||||
return route.fallback()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The analytics script src/app.html loads on every page. Answering with an empty body leaves
|
||||
* `window.plausible` as the queueing shim src/app/analytics.ts installs, so pageviews accumulate
|
||||
|
|
@ -135,6 +190,12 @@ export type DufflepudFixtures = {
|
|||
zappers?: {lnurl: string; info?: Omit<ZapperValues, "lnurl">}[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Dufflepud, whose origin is the one service url the app hard-codes rather than reading from a
|
||||
* `VITE_` value. `as()` installs it with no fixtures, so a spec that needs one — a zapper for a zap
|
||||
* receipt, a link preview — calls this again with its own: playwright matches the most recently
|
||||
* registered route first, so the spec's answers win over the empty defaults.
|
||||
*/
|
||||
export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtures = {}) =>
|
||||
context.route(`${DUFFLEPUD_ORIGIN}/**`, route => {
|
||||
const {pathname} = new URL(route.request().url())
|
||||
|
|
@ -232,48 +293,211 @@ export const mockPushServer = (context: BrowserContext) =>
|
|||
return route.fallback()
|
||||
})
|
||||
|
||||
// Records straight off the hosting api, whose shapes live in src/app/hosting.ts. They're typed as
|
||||
// plain objects here so that mocking a payment flow doesn't pull the app's module graph — and
|
||||
// with it import.meta.env — into the node process.
|
||||
// One record straight off the hosting api, whose shapes live in src/app/hosting.ts. They're left
|
||||
// as loose objects here so that mocking a payment flow doesn't pull the app's module graph — and
|
||||
// with it import.meta.env — into the node process. A relay record may also carry `members` and
|
||||
// `activity`, and an invoice `items` and `bolt11`, which is where those endpoints answer from.
|
||||
export type HostingRecord = Record<string, unknown>
|
||||
|
||||
// The backend's state when the page opens. Everything after that is what the app wrote, plus
|
||||
// whatever the handle below changed.
|
||||
export type HostingFixtures = {
|
||||
plans?: object[]
|
||||
plans?: HostingRecord[]
|
||||
// Provisioning runs on every login and ignores what it gets back, so the empty default carries
|
||||
// any spec that isn't actually exercising the hosting ui.
|
||||
tenant?: object
|
||||
relays?: object[]
|
||||
invoices?: object[]
|
||||
draftInvoice?: object
|
||||
tenant?: HostingRecord
|
||||
relays?: HostingRecord[]
|
||||
invoices?: HostingRecord[]
|
||||
draftInvoice?: HostingRecord
|
||||
}
|
||||
|
||||
export const mockHosting = (context: BrowserContext, fixtures: HostingFixtures = {}) =>
|
||||
context.route(`${HOSTING_ORIGIN}/**`, route => {
|
||||
const [resource, id, sub, detail] = new URL(route.request().url()).pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
// The backend changing its mind between two of the user's clicks — a custom domain that verifies,
|
||||
// an invoice that gets paid — which is the half of those flows no click can reach.
|
||||
export type HostingHandle = {
|
||||
setTenant(patch: HostingRecord): void
|
||||
setRelay(id: string, patch: HostingRecord): void
|
||||
setInvoice(id: string, patch: HostingRecord): void
|
||||
}
|
||||
|
||||
const hostingByContext = new WeakMap<BrowserContext, HostingHandle>()
|
||||
|
||||
export const getHosting = (context: BrowserContext) => {
|
||||
const handle = hostingByContext.get(context)
|
||||
|
||||
if (handle) {
|
||||
return handle
|
||||
}
|
||||
|
||||
throw new Error("mockHosting was never called for this browser context")
|
||||
}
|
||||
|
||||
/**
|
||||
* The hosting api as a small stateful fake: a write mutates the record it names and the next read
|
||||
* sees it, which is what makes editing a space's details, deactivating it, changing its plan or
|
||||
* saving a custom domain observable at all. Each browser context gets its own store, so one user's
|
||||
* spaces are not another's.
|
||||
*/
|
||||
export const mockHosting = async (context: BrowserContext, fixtures: HostingFixtures = {}) => {
|
||||
const plans = fixtures.plans ?? []
|
||||
const relays = new Map((fixtures.relays ?? []).map(relay => [String(relay.id), relay]))
|
||||
const invoices = new Map((fixtures.invoices ?? []).map(invoice => [String(invoice.id), invoice]))
|
||||
|
||||
let tenant: HostingRecord = {
|
||||
pubkey: "",
|
||||
return_url: "",
|
||||
nwc_is_set: false,
|
||||
stripe_customer_id: "",
|
||||
created_at: now(),
|
||||
...fixtures.tenant,
|
||||
}
|
||||
|
||||
const handle: HostingHandle = {
|
||||
setTenant: patch => {
|
||||
tenant = {...tenant, ...patch}
|
||||
},
|
||||
setRelay: (id, patch) => {
|
||||
relays.set(id, {...relays.get(id), ...patch})
|
||||
},
|
||||
setInvoice: (id, patch) => {
|
||||
invoices.set(id, {...invoices.get(id), ...patch})
|
||||
},
|
||||
}
|
||||
|
||||
hostingByContext.set(context, handle)
|
||||
|
||||
await context.route(`${HOSTING_ORIGIN}/**`, route => {
|
||||
const request = route.request()
|
||||
const method = request.method()
|
||||
const body: HostingRecord = request.postDataJSON() ?? {}
|
||||
const [resource, id, sub, detail] = new URL(request.url()).pathname.split("/").filter(Boolean)
|
||||
|
||||
const missing = (what: string) => route.fulfill({status: 404, json: {error: `No such ${what}`}})
|
||||
|
||||
if (resource === "plans") {
|
||||
return route.fulfill({json: {data: fixtures.plans ?? []}})
|
||||
return route.fulfill({json: {data: plans}})
|
||||
}
|
||||
|
||||
if (resource === "tenants") {
|
||||
if (sub) {
|
||||
if (sub === "relays") {
|
||||
return route.fulfill({json: {data: fixtures.relays ?? []}})
|
||||
}
|
||||
|
||||
if (sub === "invoices") {
|
||||
const data = detail === "draft" ? fixtures.draftInvoice : (fixtures.invoices ?? [])
|
||||
|
||||
return route.fulfill({json: {data}})
|
||||
}
|
||||
// Provisioning is the one tenant route with no pubkey in the path.
|
||||
if (id) {
|
||||
tenant = {...tenant, pubkey: id}
|
||||
} else {
|
||||
return route.fulfill({json: {data: {pubkey: id, ...fixtures.tenant}}})
|
||||
tenant = {...tenant, ...body}
|
||||
|
||||
return route.fulfill({json: {data: tenant}})
|
||||
}
|
||||
|
||||
if (sub === "relays") {
|
||||
return route.fulfill({json: {data: Array.from(relays.values())}})
|
||||
}
|
||||
|
||||
if (sub === "invoices") {
|
||||
const data = detail === "draft" ? fixtures.draftInvoice : Array.from(invoices.values())
|
||||
|
||||
return route.fulfill({json: {data}})
|
||||
}
|
||||
|
||||
if (sub === "stripe") {
|
||||
return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/portal/${id}`}}})
|
||||
}
|
||||
|
||||
// A wallet is the only thing the app updates a tenant with, so saving one is what sets it.
|
||||
if (method === "PUT") {
|
||||
tenant = {...tenant, ...body, nwc_is_set: Boolean(body.nwc_url)}
|
||||
}
|
||||
|
||||
// GET and reconcile both answer with the tenant as it now stands.
|
||||
return route.fulfill({json: {data: tenant}})
|
||||
}
|
||||
|
||||
if (resource === "relays") {
|
||||
if (!id) {
|
||||
const relay = {
|
||||
id: `relay-${relays.size + 1}`,
|
||||
status: "active",
|
||||
sync_error: "",
|
||||
synced: now(),
|
||||
custom_domain: "",
|
||||
custom_domain_verified: 0,
|
||||
...body,
|
||||
// A created space is opened straight away, so its url has to be one the container
|
||||
// serves. The client names the domain from VITE_HOSTING_RELAY_DOMAIN.
|
||||
zooid_domain: body.zooid_domain || "test",
|
||||
}
|
||||
|
||||
relays.set(String(relay.id), relay)
|
||||
|
||||
return route.fulfill({json: {data: relay}})
|
||||
}
|
||||
|
||||
const relay = relays.get(id)
|
||||
|
||||
if (!relay) {
|
||||
return missing(`relay ${id}`)
|
||||
}
|
||||
|
||||
if (sub === "members") {
|
||||
return route.fulfill({json: {data: {members: relay.members ?? []}}})
|
||||
}
|
||||
|
||||
if (sub === "activity") {
|
||||
return route.fulfill({json: {data: {activity: relay.activity ?? []}}})
|
||||
}
|
||||
|
||||
const updated = {...relay, ...(method === "PUT" ? body : {})}
|
||||
|
||||
if (sub === "deactivate") {
|
||||
updated.status = "inactive"
|
||||
}
|
||||
|
||||
if (sub === "reactivate") {
|
||||
updated.status = "active"
|
||||
}
|
||||
|
||||
relays.set(id, updated)
|
||||
|
||||
return route.fulfill({json: {data: updated}})
|
||||
}
|
||||
|
||||
if (resource === "invoices") {
|
||||
const invoice = invoices.get(id)
|
||||
|
||||
if (!invoice) {
|
||||
return missing(`invoice ${id}`)
|
||||
}
|
||||
|
||||
if (sub === "items") {
|
||||
return route.fulfill({json: {data: invoice.items ?? []}})
|
||||
}
|
||||
|
||||
if (sub === "bolt11") {
|
||||
const bolt11 = invoice.bolt11 ?? {
|
||||
id: `bolt11-${id}`,
|
||||
invoice_id: id,
|
||||
lnbc: `lnbc${id}`,
|
||||
msats: 0,
|
||||
created_at: now(),
|
||||
expires_at: now() + int(1, HOUR),
|
||||
}
|
||||
|
||||
return route.fulfill({json: {data: bolt11}})
|
||||
}
|
||||
|
||||
if (sub === "checkout") {
|
||||
return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/invoices/${id}`}}})
|
||||
}
|
||||
|
||||
// GET and reconcile both answer with the invoice as it now stands, which is how a spec
|
||||
// marks one paid: flip `paid_at` with the handle and let the dialog's next poll find it.
|
||||
return route.fulfill({json: {data: invoice}})
|
||||
}
|
||||
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
return handle
|
||||
}
|
||||
|
||||
export type LivekitOptions = {
|
||||
// Where the client is told to connect. Point it at something the test owns — the token this
|
||||
// hands out is accepted by nothing else.
|
||||
|
|
|
|||
|
|
@ -5,13 +5,15 @@ import {
|
|||
ROOM_ADD_MEMBER,
|
||||
Resolver,
|
||||
makeEvent,
|
||||
prep,
|
||||
tagSpec,
|
||||
tagValue,
|
||||
toNostrURI,
|
||||
} from "@welshman/util"
|
||||
import type {SignedEvent, StampedEvent} from "@welshman/util"
|
||||
import {EventWriter, Profile} from "@welshman/domain"
|
||||
import type {BaseEventReader} from "@welshman/domain"
|
||||
import type {EventTemplate, HashedEvent, SignedEvent, StampedEvent} from "@welshman/util"
|
||||
import {Nip59} from "@welshman/signer"
|
||||
import {DirectMessage, EventWriter, Profile} from "@welshman/domain"
|
||||
import type {BaseEventReader, ConfiguredKind, KindFactory} from "@welshman/domain"
|
||||
import type {RoomOptions, TestRelay} from "../zooid/types"
|
||||
import type {Zooid} from "../zooid/relay"
|
||||
import type {TenantName} from "../zooid/config"
|
||||
|
|
@ -30,6 +32,18 @@ export type SeededEvent = {
|
|||
readonly id: string
|
||||
}
|
||||
|
||||
// The kind-14 a direct message really is. It is never published — each participant gets it inside
|
||||
// a gift wrap — so this, rather than anything on the wire, is what a spec asserts on.
|
||||
export type SeededRumor = {
|
||||
readonly rumor: HashedEvent
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
// An event to seed, either already rendered or built when its turn comes up. A domain writer needs
|
||||
// a relay url to resolve its hints against, and this space has none until the queue has drained,
|
||||
// so anything built by one has to be deferred.
|
||||
export type SeededTemplate = StampedEvent | (() => MaybeAsync<EventTemplate>)
|
||||
|
||||
export type ProfileValues = {
|
||||
name?: string
|
||||
about?: string
|
||||
|
|
@ -61,7 +75,16 @@ export type SeededSpace = {
|
|||
message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent
|
||||
reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent
|
||||
profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent
|
||||
event(user: TestUser, template: StampedEvent): SeededEvent
|
||||
event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent
|
||||
// A nip-17 conversation: one kind-14 rumor, gift-wrapped once per participant — the sender
|
||||
// included, since their own copy is the half of the thread their client reads back.
|
||||
dm(from: TestUser, to: TestUser[], content: string, createdAt?: number): SeededRumor
|
||||
// This space's domain kinds, bound to a resolver that answers with its url, so a writer built
|
||||
// here renders its relay hints as this space. For everything `event()` takes a template for:
|
||||
// `space.event(user, () => space.kind(Article).writer().setTitle("x").renderTemplate())`.
|
||||
kind<R extends BaseEventReader, W extends EventWriter<R>>(
|
||||
factory: KindFactory<R, W>,
|
||||
): ConfiguredKind<R, W>
|
||||
}
|
||||
|
||||
export type SeedSpaceOptions = {
|
||||
|
|
@ -91,18 +114,24 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
|
|||
// Every fixture is published to this space, so a relay hint always resolves to its url.
|
||||
const context = {resolver: new Resolver(() => [relay().url])}
|
||||
|
||||
const publish = (write: () => Promise<SignedEvent>): SeededEvent => {
|
||||
let signed: Maybe<SignedEvent>
|
||||
// Queues a write and hands back a getter for whatever it produced, which only reads once the
|
||||
// scenario's queue has drained.
|
||||
const seeded = <T>(write: () => Promise<T>) => {
|
||||
let value: Maybe<T>
|
||||
|
||||
const event = () => {
|
||||
if (signed) return signed
|
||||
enqueue(async () => {
|
||||
value = await write()
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (value) return value
|
||||
|
||||
throw new Error(`An event seeded into "${name}" was read before seed() published it`)
|
||||
}
|
||||
}
|
||||
|
||||
enqueue(async () => {
|
||||
signed = await write()
|
||||
})
|
||||
const publish = (write: () => Promise<SignedEvent>): SeededEvent => {
|
||||
const event = seeded(write)
|
||||
|
||||
return {
|
||||
get event() {
|
||||
|
|
@ -122,7 +151,14 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
|
|||
|
||||
const member = (user: TestUser, h?: string) => enqueue(() => relay().member(user, h, startedAt))
|
||||
|
||||
const event = (user: TestUser, template: StampedEvent) => publishTemplate(user, () => template)
|
||||
const event = (user: TestUser, template: SeededTemplate, createdAt = startedAt) =>
|
||||
publishTemplate(user, async () => {
|
||||
if (typeof template === "function") {
|
||||
return {...(await template()), created_at: createdAt}
|
||||
}
|
||||
|
||||
return template
|
||||
})
|
||||
|
||||
const join = (user: TestUser, ...roomIds: string[]) => {
|
||||
memberships.push({user, rooms: roomIds})
|
||||
|
|
@ -174,6 +210,41 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
|
|||
created_at: createdAt,
|
||||
}))
|
||||
|
||||
const kind = <R extends BaseEventReader, W extends EventWriter<R>>(factory: KindFactory<R, W>) =>
|
||||
factory.configure(context)
|
||||
|
||||
// Every wrap is published over the sender's own connection: a gift wrap is signed by an
|
||||
// ephemeral key, so its author is nobody this process can authenticate as. zooid stores it
|
||||
// anyway, because it authorizes a kind-1059 by the member named in its p tag.
|
||||
const dm = (from: TestUser, to: TestUser[], content: string, createdAt = startedAt) => {
|
||||
const rumor = seeded(async () => {
|
||||
const writer = kind(DirectMessage).writer().setContent(content)
|
||||
|
||||
for (const user of to) {
|
||||
writer.addRecipient(user.pubkey)
|
||||
}
|
||||
|
||||
const template = {...(await writer.renderTemplate()), created_at: createdAt}
|
||||
const nip59 = Nip59.fromSigner(from.signer)
|
||||
|
||||
for (const {pubkey} of [from, ...to]) {
|
||||
await relay().publish(await nip59.wrap(pubkey, template), {as: from})
|
||||
}
|
||||
|
||||
// The same template the wraps were built from, so this is the id they decrypt to.
|
||||
return prep(template, from.pubkey)
|
||||
})
|
||||
|
||||
return {
|
||||
get rumor() {
|
||||
return rumor()
|
||||
},
|
||||
get id() {
|
||||
return rumor().id
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
get url() {
|
||||
|
|
@ -187,5 +258,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
|
|||
reply,
|
||||
profile,
|
||||
event,
|
||||
dm,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
48
e2e/harness/zooid/docker/config/closed.toml
Normal file
48
e2e/harness/zooid/docker/config/closed.toml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# A relay that will not let anyone join on their own say-so: `public_join = false` makes zooid
|
||||
# refuse a kind-28934 that carries no claim, which is what turns the join dialog into "Request
|
||||
# Access". Everything else matches space.toml.
|
||||
#
|
||||
# Seeding a membership here is therefore not possible — `join()` and `member()` publish a claimless
|
||||
# RELAY_JOIN and the relay refuses it — so a scenario using this relay seeds admin-created rooms
|
||||
# and lets the spec drive the join itself.
|
||||
#
|
||||
# `host` must equal the Host header the harness sends, which is the hostname of the url the client
|
||||
# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with
|
||||
# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name
|
||||
# that resolves nowhere.
|
||||
host = "closed.test"
|
||||
schema = "e2e_closed"
|
||||
secret = "c105ed0000000000000000000000000000000000000000000000000000000006"
|
||||
|
||||
[info]
|
||||
# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the
|
||||
# name scenarios pass to `relay()`. `pubkey` is the owner, and here it is the relay's own key (the
|
||||
# pubkey for `secret`) so that [roles.admin] below is the only thing granting a test identity
|
||||
# can_manage.
|
||||
name = "closed"
|
||||
pubkey = "9b78ed2f0054d1f6ce8307c81e7c4f5aefc78f2396416da05299caac2fb8662e"
|
||||
description = "Throwaway relay for Flotilla's end-to-end suite."
|
||||
|
||||
[policy]
|
||||
public_read = false
|
||||
public_write = false
|
||||
public_join = false
|
||||
strip_signatures = false
|
||||
|
||||
[groups]
|
||||
enabled = true
|
||||
|
||||
[management]
|
||||
enabled = true
|
||||
|
||||
# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have
|
||||
# the relay or the client talk to a service the test did not create.
|
||||
[blossom]
|
||||
enabled = false
|
||||
|
||||
[push]
|
||||
enabled = false
|
||||
|
||||
[roles.admin]
|
||||
pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"]
|
||||
can_manage = true
|
||||
43
e2e/harness/zooid/docker/config/unsigned.toml
Normal file
43
e2e/harness/zooid/docker/config/unsigned.toml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# A relay that serves events with their signatures stripped, which is what makes the client buffer
|
||||
# them and ask whether the space is trusted. Everything else matches space.toml.
|
||||
#
|
||||
# `host` must equal the Host header the harness sends, which is the hostname of the url the client
|
||||
# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with
|
||||
# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name
|
||||
# that resolves nowhere.
|
||||
host = "unsigned.test"
|
||||
schema = "e2e_unsigned"
|
||||
secret = "5169ed0000000000000000000000000000000000000000000000000000000007"
|
||||
|
||||
[info]
|
||||
# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the
|
||||
# name scenarios pass to `relay()`. `pubkey` is the owner, and here it is the relay's own key (the
|
||||
# pubkey for `secret`) so that [roles.admin] below is the only thing granting a test identity
|
||||
# can_manage.
|
||||
name = "unsigned"
|
||||
pubkey = "a3708596a61d275e9c825c09e616ff075eca5d276053dcb5e720f8863b1d84a9"
|
||||
description = "Throwaway relay for Flotilla's end-to-end suite."
|
||||
|
||||
[policy]
|
||||
public_read = false
|
||||
public_write = false
|
||||
public_join = true
|
||||
strip_signatures = true
|
||||
|
||||
[groups]
|
||||
enabled = true
|
||||
|
||||
[management]
|
||||
enabled = true
|
||||
|
||||
# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have
|
||||
# the relay or the client talk to a service the test did not create.
|
||||
[blossom]
|
||||
enabled = false
|
||||
|
||||
[push]
|
||||
enabled = false
|
||||
|
||||
[roles.admin]
|
||||
pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"]
|
||||
can_manage = true
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
import {execFile} from "node:child_process"
|
||||
import {chmod, cp, readdir, rm} from "node:fs/promises"
|
||||
import {tmpdir} from "node:os"
|
||||
import {join} from "node:path"
|
||||
import {fileURLToPath} from "node:url"
|
||||
import {promisify} from "node:util"
|
||||
import {ms, sleep} from "@welshman/lib"
|
||||
|
|
@ -7,10 +10,12 @@ import {makeRelayAuth} from "@welshman/util"
|
|||
import type {SignedEvent} from "@welshman/util"
|
||||
import {ClientMessageType, isRelayAuth, isRelayOk} from "@welshman/net"
|
||||
import type {ClientMessage} from "@welshman/net"
|
||||
import {users} from "../keys"
|
||||
import {testUsersByPubkey} from "../keys"
|
||||
import type {TestUser} from "../keys"
|
||||
import {tenantNames, tenantUrl, tenants} from "./config"
|
||||
import type {TenantName} from "./config"
|
||||
import {makeTestRelay} from "./testRelay"
|
||||
import type {PublishOptions} from "./types"
|
||||
import {connectToZooid, requestZooid} from "./transport"
|
||||
import type {ZooidConnection} from "./transport"
|
||||
|
||||
|
|
@ -18,7 +23,14 @@ const image = "gitea.coracle.social/coracle/zooid:latest"
|
|||
|
||||
const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url))
|
||||
|
||||
const testUsersByPubkey = new Map(Object.values(users).map(user => [user.pubkey, user]))
|
||||
const configSource = fileURLToPath(new URL("docker/config", import.meta.url))
|
||||
|
||||
// What the container mounts as /app/config. zooid saves a relay's toml back whenever a nip-86 call
|
||||
// edits that relay's name, description or icon, so it is handed a copy rather than the repo's own
|
||||
// directory: a read-only mount fails those calls, and a writable one would leave the fixtures every
|
||||
// other test reads rewritten by whatever the last one did. The copy is staged here rather than in
|
||||
// an entrypoint because the image is distroless — there is no shell in it to copy anything with.
|
||||
const configDir = join(tmpdir(), "flotilla-e2e-zooid-config")
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
|
|
@ -27,7 +39,11 @@ const execFileAsync = promisify(execFile)
|
|||
// typed. Name the executable to drive with E2E_DOCKER in that case.
|
||||
const dockerCommand = process.env.E2E_DOCKER ?? "docker"
|
||||
|
||||
const docker = (...args: string[]) => execFileAsync(dockerCommand, args)
|
||||
// Every invocation carries ZOOID_CONFIG, `down` included: the compose file names it without a
|
||||
// default, so a call that left it out would fail to interpolate rather than quietly mounting
|
||||
// something else.
|
||||
const docker = (...args: string[]) =>
|
||||
execFileAsync(dockerCommand, args, {env: {...process.env, ZOOID_CONFIG: configDir}})
|
||||
|
||||
const compose = (...args: string[]) => docker("compose", "-f", composeFile, ...args)
|
||||
|
||||
|
|
@ -58,6 +74,17 @@ const probeDocker = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
const getTestUser = (pubkey: string) => {
|
||||
const user = testUsersByPubkey.get(pubkey)
|
||||
|
||||
if (user) return user
|
||||
|
||||
throw new Error(
|
||||
`Cannot publish as ${pubkey}: zooid authenticates every write, so seeded events must be ` +
|
||||
"signed by one of the test identities in e2e/harness/keys.ts",
|
||||
)
|
||||
}
|
||||
|
||||
let problem: Maybe<Promise<Maybe<string>>>
|
||||
|
||||
// Asked once a worker: docker does not come and go mid-run, and the answer is also written to the
|
||||
|
|
@ -125,17 +152,17 @@ export class Zooid {
|
|||
makeTestRelay({
|
||||
name,
|
||||
url: tenantUrl(name),
|
||||
publish: event => this.publish(tenantUrl(name), event),
|
||||
publish: (event, options) => this.publish(tenantUrl(name), event, options),
|
||||
})
|
||||
|
||||
publish = async (url: string, event: SignedEvent) => {
|
||||
publish = async (url: string, event: SignedEvent, {as}: PublishOptions = {}) => {
|
||||
const relay = this.relays.get(url)
|
||||
|
||||
if (!relay) {
|
||||
throw new Error(`Attempted to publish to ${url}, which is not one of this container's relays`)
|
||||
}
|
||||
|
||||
const connection = await this.authenticate(relay.host, event.pubkey)
|
||||
const connection = await this.authenticate(relay.host, as ?? getTestUser(event.pubkey))
|
||||
const {ok, detail} = await this.send(connection, [ClientMessageType.Event, event], event.id)
|
||||
|
||||
if (!ok) {
|
||||
|
|
@ -176,6 +203,19 @@ export class Zooid {
|
|||
// Recreating rather than restarting is what makes this a reset: with storage in tmpfs and no
|
||||
// volume mounted for it, a new container is a new database.
|
||||
private up = async () => {
|
||||
// A fresh copy on every recreate is what makes a reset restore pristine relay metadata after a
|
||||
// test has edited some through nip-86. The modes are permissive because the container runs as
|
||||
// uid 65532 while the copy belongs to whoever ran the suite, and a runtime that keeps host
|
||||
// ownership — rootless podman, docker on linux — would otherwise leave zooid unable to write
|
||||
// the file it was told to save.
|
||||
await rm(configDir, {recursive: true, force: true})
|
||||
await cp(configSource, configDir, {recursive: true})
|
||||
await chmod(configDir, 0o777)
|
||||
|
||||
for (const name of await readdir(configDir)) {
|
||||
await chmod(join(configDir, name), 0o666)
|
||||
}
|
||||
|
||||
try {
|
||||
await compose("up", "-d", "--force-recreate", "--wait")
|
||||
} catch (error) {
|
||||
|
|
@ -215,21 +255,12 @@ export class Zooid {
|
|||
// url signed into the auth event is the one the client uses, because that is the one khatru
|
||||
// rebuilds from the headers transport.ts sends — a fixture is written over the same relay the app
|
||||
// talks to.
|
||||
private authenticate = async (host: string, pubkey: string) => {
|
||||
const key = `${host} ${pubkey}`
|
||||
private authenticate = async (host: string, user: TestUser) => {
|
||||
const key = `${host} ${user.pubkey}`
|
||||
const session = this.sessions.get(key)
|
||||
|
||||
if (session) return session
|
||||
|
||||
const user = testUsersByPubkey.get(pubkey)
|
||||
|
||||
if (!user) {
|
||||
throw new Error(
|
||||
`Cannot publish as ${pubkey}: zooid authenticates every write, so seeded events must be ` +
|
||||
"signed by one of the test identities in e2e/harness/keys.ts",
|
||||
)
|
||||
}
|
||||
|
||||
const connection = connectToZooid(host)
|
||||
const [, challenge] = await connection.wait(isRelayAuth)
|
||||
const event = await user.signer.sign(makeRelayAuth(`wss://${host}/`, challenge))
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ import {
|
|||
import type {SignedEvent, StampedEvent} from "@welshman/util"
|
||||
import {users} from "../keys"
|
||||
import type {TestUser} from "../keys"
|
||||
import type {TestRelay} from "./types"
|
||||
import type {PublishOptions, TestRelay} from "./types"
|
||||
|
||||
export type TestRelayOptions = {
|
||||
name: string
|
||||
url: string
|
||||
publish: (event: SignedEvent) => Promise<void>
|
||||
publish: (event: SignedEvent, options?: PublishOptions) => Promise<void>
|
||||
}
|
||||
|
||||
// Seeding signs the events a real client would send and publishes them over a socket. Room
|
||||
|
|
@ -36,6 +36,7 @@ export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay
|
|||
name,
|
||||
url,
|
||||
event,
|
||||
publish,
|
||||
room: async (h, meta, createdAt) => {
|
||||
// A relay stamps the metadata it derives from these ops with the op's own created_at, so
|
||||
// creation has to be strictly older than the edit or the edit is dropped as stale.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ import type {SignedEvent, StampedEvent} from "@welshman/util"
|
|||
import type {ClientMessage, RelayMessage} from "@welshman/net"
|
||||
import type {TestUser} from "../keys"
|
||||
|
||||
export type PublishOptions = {
|
||||
// Which identity the seeding connection authenticates as. Defaults to the event's own author,
|
||||
// which then has to be a test identity — a gift wrap is signed by an ephemeral key, so nothing
|
||||
// in this process can sign as its author and the connection has to belong to the sender.
|
||||
as?: TestUser
|
||||
}
|
||||
|
||||
export type RoomOptions = {
|
||||
name?: string
|
||||
about?: string
|
||||
|
|
@ -23,6 +30,9 @@ export type TestRelay = {
|
|||
member(user: TestUser, h: string | undefined, createdAt: number): Promise<void>
|
||||
// Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs.
|
||||
event(user: TestUser, event: StampedEvent): Promise<SignedEvent>
|
||||
// An event this process did not sign, sent over `as`'s connection — a gift wrap, whose author
|
||||
// is the ephemeral key that wrapped it.
|
||||
publish(event: SignedEvent, options: PublishOptions): Promise<void>
|
||||
}
|
||||
|
||||
// One client's connection to a relay. Every connection to a url reaches the same container, which
|
||||
|
|
|
|||
1120
e2e/specs/admin.spec.ts
Normal file
1120
e2e/specs/admin.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
937
e2e/specs/articles-threads.spec.ts
Normal file
937
e2e/specs/articles-threads.spec.ts
Normal file
|
|
@ -0,0 +1,937 @@
|
|||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {HOUR, MINUTE} from "@welshman/lib"
|
||||
import {LONG_FORM, MESSAGE, NOTE, makeEvent} from "@welshman/util"
|
||||
import type {SignedEvent, TrustedEvent} from "@welshman/util"
|
||||
import {ClientMessageType} from "@welshman/net"
|
||||
import {Article, Comment, RelayList, Thread} from "@welshman/domain"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
import {expect, getTranscript, mockBlossom, roomPath, spacePath, test, users} from "../harness"
|
||||
|
||||
// A handle to a seeded event, which only reads once seed() has drained its queue.
|
||||
type Seeded = {readonly id: string; readonly event: SignedEvent}
|
||||
|
||||
// Where an upload lands. getBlossomServer probes the space's own origin first — blossom is off in
|
||||
// every tenant's toml, so that probe is meant to fail — then the user's kind-10063 list, and
|
||||
// VITE_DEFAULT_BLOSSOM_SERVERS is what is left.
|
||||
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
|
||||
|
||||
// A real 1x1 gif. Gif rather than png because compressFileForUpload passes gif through untouched
|
||||
// instead of re-encoding it through a canvas.
|
||||
const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64")
|
||||
|
||||
const PARTY = "🎉"
|
||||
|
||||
// Every event of a kind this page put on the wire, oldest first.
|
||||
const publishedEvents = (page: Page, kind: number): TrustedEvent[] =>
|
||||
getTranscript(page.context())
|
||||
.filter(
|
||||
({direction, message}) =>
|
||||
direction === "toRelay" &&
|
||||
message[0] === ClientMessageType.Event &&
|
||||
message[1].kind === kind,
|
||||
)
|
||||
.map(({message}) => message[1])
|
||||
|
||||
// A modal is mounted alongside the page it covers, so the "Create Thread" in a room's compose menu
|
||||
// and the "Create Thread" that submits the composer are both reachable at once. Everything inside
|
||||
// a composer is scoped to the modal's own form to say which one is meant.
|
||||
const modal = (page: Page, title: string) =>
|
||||
page.locator("form").filter({has: page.getByRole("heading", {name: title})})
|
||||
|
||||
// The comment and thread-reply composers are the only forms on their pages carrying a rich text
|
||||
// editor.
|
||||
const composerForm = (page: Page) =>
|
||||
page.locator("form").filter({has: page.locator(".note-editor")})
|
||||
|
||||
const editorOf = (scope: Locator) => scope.locator(".note-editor [contenteditable=true]")
|
||||
|
||||
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
|
||||
|
||||
// EventActions renders zap, emoji and menu into one join, in that order, and every one of them is
|
||||
// an icon with no accessible name.
|
||||
const emojiButton = (scope: Locator) => scope.locator(".join").getByRole("button").nth(1)
|
||||
|
||||
// RoomCompose's join is the upload button and then the compose menu, which is where an article or
|
||||
// a thread written from inside a room is started.
|
||||
const openComposeMenu = (page: Page) =>
|
||||
page
|
||||
.locator("form")
|
||||
.filter({has: page.locator(".chat-editor")})
|
||||
.locator(".join")
|
||||
.getByRole("button")
|
||||
.nth(1)
|
||||
.click()
|
||||
|
||||
// The picker is a web component with an open shadow root, so its search field and its results are
|
||||
// reachable through it. Searching rather than browsing avoids depending on which category tab an
|
||||
// emoji happens to live under.
|
||||
const pickParty = async (page: Page, opener: Locator) => {
|
||||
await opener.click()
|
||||
|
||||
const picker = page.locator("emoji-picker").filter({visible: true})
|
||||
|
||||
// Tippy keeps a hidden popover mounted through its fade — a quarter of a second during which the
|
||||
// picker from the last card is still visible alongside this one — so wait for there to be one
|
||||
// rather than reaching into whichever resolves first.
|
||||
await expect(picker).toHaveCount(1)
|
||||
|
||||
// A result's label is the emoji's name, its annotation and every shortcode joined together, so
|
||||
// the annotation is matched rather than the whole of it.
|
||||
await picker.locator("input.search").fill("party popper")
|
||||
await picker
|
||||
.getByRole("option", {name: /party popper/})
|
||||
.first()
|
||||
.click()
|
||||
}
|
||||
|
||||
// One reaction, recorded as the reader's own, and taken back off again.
|
||||
const expectReactionRoundTrip = async (page: Page, scope: Locator, opener: Locator) => {
|
||||
const pill = scope.getByRole("button", {name: new RegExp(PARTY)})
|
||||
|
||||
await pickParty(page, opener)
|
||||
await expect(pill).toHaveCount(1)
|
||||
await expect(pill).toHaveClass(/button-primary/)
|
||||
|
||||
await pill.click()
|
||||
await expect(pill).toHaveCount(0)
|
||||
}
|
||||
|
||||
// formatTimestamp renders a short date and a short time in the environment's own locale, which node
|
||||
// shares, so the date half of it is what a spec can name without pinning a format.
|
||||
const shortDate = (seconds: number) =>
|
||||
new Intl.DateTimeFormat(undefined, {dateStyle: "short"}).format(new Date(seconds * 1000))
|
||||
|
||||
const articleCards = (page: Page) => page.getByRole("link", {name: /Written by/})
|
||||
|
||||
const openArticle = (page: Page, title: string) =>
|
||||
articleCards(page).filter({hasText: title}).getByText(title, {exact: true}).click()
|
||||
|
||||
test("US-037 write and publish an article", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
|
||||
// An older article, so "at the top of the list" is a statement about order.
|
||||
space.event(
|
||||
user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Article)
|
||||
.writer()
|
||||
.setIdentifier("old-news")
|
||||
.setTitle("Old News")
|
||||
.setSummary("Something published a while back.")
|
||||
.setPublishedAt(at(3, HOUR))
|
||||
.setContent("Yesterday's headlines.")
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, `${spacePath(url)}/articles`)
|
||||
|
||||
await expect(articleCards(page)).toHaveCount(1)
|
||||
|
||||
await pageBar(page).getByRole("button", {name: "Write"}).click()
|
||||
|
||||
const composer = modal(page, "Write an Article")
|
||||
const title = composer.getByPlaceholder("What is this article about?")
|
||||
const body = editorOf(composer)
|
||||
|
||||
await expect(composer).toBeVisible()
|
||||
|
||||
await composer.getByRole("button", {name: "Publish Article"}).click()
|
||||
await expect(page.getByRole("alert")).toContainText("Please provide a title for your article.")
|
||||
|
||||
await title.fill("Half Baked")
|
||||
await composer.getByRole("button", {name: "Publish Article"}).click()
|
||||
await expect(page.getByRole("alert")).toContainText("Please write something for your article.")
|
||||
|
||||
await body.pressSequentially("Only the beginning.")
|
||||
|
||||
// Neither refusal put anything on the wire.
|
||||
expect(publishedEvents(page, LONG_FORM)).toEqual([])
|
||||
|
||||
await composer.getByRole("button", {name: "Go back"}).click()
|
||||
await expect(page.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
|
||||
|
||||
// Reopening picks up where the composer was closed rather than starting over.
|
||||
await pageBar(page).getByRole("button", {name: "Write"}).click()
|
||||
await expect(title).toHaveValue("Half Baked")
|
||||
await expect(body).toContainText("Only the beginning.")
|
||||
|
||||
await title.fill("Signals in the Noise")
|
||||
await composer.getByRole("button", {name: "Publish Article"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
|
||||
await expect(articleCards(page)).toHaveCount(2)
|
||||
await expect(articleCards(page).first()).toContainText("Signals in the Noise")
|
||||
expect(publishedEvents(page, LONG_FORM)).toHaveLength(1)
|
||||
|
||||
// A published article leaves the composer empty for the next one.
|
||||
await pageBar(page).getByRole("button", {name: "Write"}).click()
|
||||
await expect(title).toHaveValue("")
|
||||
await expect(body).toHaveText("")
|
||||
})
|
||||
|
||||
test("US-038 browse, filter, and read articles", async ({seed, as}) => {
|
||||
const body = [
|
||||
"## Why gardens matter",
|
||||
"",
|
||||
"Gardens are **worth the trouble**.",
|
||||
"",
|
||||
"- Soil",
|
||||
"- Water",
|
||||
"- Sunlight",
|
||||
"",
|
||||
`Ask ${nip19.npubEncode(users.carol.pubkey)} what she thinks.`,
|
||||
].join("\n")
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.join(user.carol, "general")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
space.profile(user.carol, {name: "Carol Chen"})
|
||||
|
||||
space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Article)
|
||||
.writer()
|
||||
.setIdentifier("tending-the-garden")
|
||||
.setTitle("Tending the Garden")
|
||||
.setSummary("A short teaser about gardens.")
|
||||
.setImage("https://images.test/garden.jpg")
|
||||
.setTopics(["gardening"])
|
||||
.setPublishedAt(at(4, HOUR))
|
||||
.setContent(body)
|
||||
.renderTemplate(),
|
||||
at(4, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Article)
|
||||
.writer()
|
||||
.setIdentifier("cooking-with-fire")
|
||||
.setTitle("Cooking with Fire")
|
||||
.setSummary("Heat, and what to do with it.")
|
||||
.setTopics(["cooking"])
|
||||
.setPublishedAt(at(3, HOUR))
|
||||
.setContent("Build the fire first.")
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Article)
|
||||
.writer()
|
||||
.setIdentifier("notes-on-foraging")
|
||||
.setTitle("Notes on Foraging")
|
||||
.setSummary("What grows where nobody planted it.")
|
||||
.setTopics(["gardening"])
|
||||
.setPublishedAt(at(2, HOUR))
|
||||
.setContent("Look under the hedges.")
|
||||
.renderTemplate(),
|
||||
at(2, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.carol,
|
||||
() =>
|
||||
space
|
||||
.kind(Article)
|
||||
.writer()
|
||||
.setIdentifier("winter-reading")
|
||||
.setTitle("Winter Reading")
|
||||
.setSummary("Six books for the dark months.")
|
||||
.setTopics(["books"])
|
||||
.setPublishedAt(at(1, HOUR))
|
||||
.setContent("Start with the shortest one.")
|
||||
.renderTemplate(),
|
||||
at(1, HOUR),
|
||||
)
|
||||
})
|
||||
|
||||
const {at} = scenario
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.bob, `${spacePath(url)}/articles`)
|
||||
|
||||
const garden = articleCards(page).filter({hasText: "Tending the Garden"})
|
||||
|
||||
await expect(articleCards(page)).toHaveCount(4)
|
||||
|
||||
// Author, published date and a preview, all on the card itself.
|
||||
await expect(garden).toContainText("Written by")
|
||||
await expect(garden).toContainText("Alice Anderson")
|
||||
await expect(garden).toContainText("A short teaser about gardens.")
|
||||
await expect(garden).toContainText(shortDate(at(4, HOUR)))
|
||||
|
||||
const authors = page
|
||||
.locator("section")
|
||||
.filter({has: page.getByRole("heading", {name: "Authors"})})
|
||||
const topics = page.locator("section").filter({has: page.getByRole("heading", {name: "Topics"})})
|
||||
|
||||
await expect(authors.getByRole("button", {name: /Alice Anderson/})).toBeVisible()
|
||||
await expect(authors.getByRole("button", {name: /Carol Chen/})).toBeVisible()
|
||||
|
||||
await topics.getByRole("button", {name: /^#gardening/}).click()
|
||||
await expect(articleCards(page)).toHaveCount(2)
|
||||
await expect(articleCards(page).filter({hasText: "Notes on Foraging"})).toBeVisible()
|
||||
|
||||
// Author and topic together are narrower than either on its own.
|
||||
await authors.getByRole("button", {name: /Alice Anderson/}).click()
|
||||
await expect(articleCards(page)).toHaveCount(1)
|
||||
await expect(articleCards(page).first()).toContainText("Tending the Garden")
|
||||
|
||||
await openArticle(page, "Tending the Garden")
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Tending the Garden"}).first()).toBeVisible()
|
||||
await expect(page.locator('img[src="https://images.test/garden.jpg"]')).toBeVisible()
|
||||
await expect(page.getByText("A short teaser about gardens.")).toBeVisible()
|
||||
await expect(page.getByText(/^Published /)).toContainText(shortDate(at(4, HOUR)))
|
||||
|
||||
const markdown = page.locator(".content-markdown")
|
||||
|
||||
await expect(markdown.getByRole("heading", {name: "Why gardens matter"})).toBeVisible()
|
||||
await expect(markdown.locator("strong")).toHaveText("worth the trouble")
|
||||
await expect(markdown.locator("li")).toHaveText(["Soil", "Water", "Sunlight"])
|
||||
await expect(markdown.getByRole("link", {name: "@Carol Chen"})).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-039 comment on an article", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.join(user.carol, "general")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
space.profile(user.carol, {name: "Carol Chen"})
|
||||
|
||||
space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Article)
|
||||
.writer()
|
||||
.setIdentifier("tending-the-garden")
|
||||
.setTitle("Tending the Garden")
|
||||
.setSummary("A short teaser about gardens.")
|
||||
.setPublishedAt(at(4, HOUR))
|
||||
.setContent("Gardens are worth the trouble.")
|
||||
.renderTemplate(),
|
||||
at(4, HOUR),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const articlesPath = `${spacePath(url)}/articles`
|
||||
|
||||
const bob = await as(users.bob, articlesPath)
|
||||
|
||||
await openArticle(bob, "Tending the Garden")
|
||||
await bob.getByRole("button", {name: "Add a comment"}).click()
|
||||
await editorOf(composerForm(bob)).pressSequentially("The soil chapter is the good one.")
|
||||
await composerForm(bob).getByRole("button", {name: "Comment"}).click()
|
||||
|
||||
await expect(bob.getByText("The soil chapter is the good one.")).toBeVisible()
|
||||
|
||||
const carol = await as(users.carol, articlesPath)
|
||||
|
||||
await openArticle(carol, "Tending the Garden")
|
||||
|
||||
const bobsComment = carol
|
||||
.locator(".card.z-feature")
|
||||
.filter({hasText: "The soil chapter is the good one."})
|
||||
|
||||
await expect(bobsComment).toBeVisible()
|
||||
|
||||
await bobsComment.getByRole("button", {name: "Reply", exact: true}).click()
|
||||
await editorOf(composerForm(carol)).pressSequentially(
|
||||
"Only because you skipped the water chapter.",
|
||||
)
|
||||
await composerForm(carol).getByRole("button", {name: "Reply", exact: true}).click()
|
||||
|
||||
await expect(carol.getByText("Only because you skipped the water chapter.")).toBeVisible()
|
||||
|
||||
const alice = await as(users.alice, articlesPath)
|
||||
|
||||
await mockBlossom(alice.context(), {server: BLOSSOM_ORIGIN})
|
||||
await openArticle(alice, "Tending the Garden")
|
||||
|
||||
await expect(alice.getByText("The soil chapter is the good one.")).toBeVisible()
|
||||
await expect(alice.getByText("Only because you skipped the water chapter.")).toBeVisible()
|
||||
|
||||
// A reply is nested inside the comment it answers; a top level comment is not.
|
||||
const nested = alice.locator(".border-l.ml-4.pl-4")
|
||||
|
||||
await expect(nested.getByText("Only because you skipped the water chapter.")).toBeVisible()
|
||||
await expect(nested.getByText("The soil chapter is the good one.")).toHaveCount(0)
|
||||
|
||||
await alice.getByRole("button", {name: "Add a comment"}).click()
|
||||
|
||||
const composer = composerForm(alice)
|
||||
|
||||
await editorOf(composer).pressSequentially("Thanks both, here's the bed I meant.")
|
||||
await editorOf(composer).press("Enter")
|
||||
|
||||
const chooser = alice.waitForEvent("filechooser")
|
||||
|
||||
await composer.locator('[data-tip="Add an image"]').click()
|
||||
await (await chooser).setFiles({name: "bed.gif", mimeType: "image/gif", buffer: GIF})
|
||||
|
||||
// The attachment carries an uploading marker from the moment the request goes out until the
|
||||
// blossom descriptor comes back and replaces its blob url.
|
||||
await expect(composer.locator(".tiptap-object")).not.toHaveClass(/tiptap-uploading/)
|
||||
|
||||
await composer.getByRole("button", {name: "Comment"}).click()
|
||||
|
||||
const alicesComment = alice
|
||||
.locator(".card.z-feature")
|
||||
.filter({hasText: "Thanks both, here's the bed I meant."})
|
||||
|
||||
await expect(alicesComment).toBeVisible()
|
||||
await expect(alicesComment.locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-040 react to a post with an emoji", async ({seed, as}) => {
|
||||
let thread!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
|
||||
// A profile's notes are loaded through its author's outbox relays, so alice needs a relay list
|
||||
// for her note to be findable at all.
|
||||
space.event(user.alice, () =>
|
||||
space
|
||||
.kind(RelayList)
|
||||
.writer()
|
||||
.setReadUrls([space.url])
|
||||
.setWriteUrls([space.url])
|
||||
.renderTemplate(),
|
||||
)
|
||||
|
||||
const article = space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Article)
|
||||
.writer()
|
||||
.setIdentifier("tending-the-garden")
|
||||
.setTitle("Tending the Garden")
|
||||
.setPublishedAt(at(4, HOUR))
|
||||
.setContent("Gardens are worth the trouble.")
|
||||
.renderTemplate(),
|
||||
at(4, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Comment)
|
||||
.writer()
|
||||
.setRootFromEvent(article.event)
|
||||
.setParentFromEvent(article.event)
|
||||
.setContent("A note about the soil chapter.")
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
const topic = space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Thread)
|
||||
.writer()
|
||||
.setRoom(space.url, "general")
|
||||
.setTitle("Bed rotation")
|
||||
.setContent("How often do you move things around?")
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Comment)
|
||||
.writer()
|
||||
.setRootFromEvent(topic.event)
|
||||
.setParentFromEvent(topic.event)
|
||||
.setContent("Every other season, usually.")
|
||||
.renderTemplate(),
|
||||
at(2, HOUR),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.alice,
|
||||
makeEvent(NOTE, {content: "A short note from the garden.", created_at: at(2, HOUR)}),
|
||||
)
|
||||
|
||||
thread = topic
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.bob, `${spacePath(url)}/articles`)
|
||||
|
||||
await openArticle(page, "Tending the Garden")
|
||||
|
||||
const articleCard = page.locator(".card.z-feature").filter({hasText: "Gardens are worth"})
|
||||
const commentCard = page.locator(".card.z-feature").filter({hasText: "A note about the soil"})
|
||||
|
||||
await expect(articleCard).toBeVisible()
|
||||
await expectReactionRoundTrip(page, articleCard, emojiButton(articleCard))
|
||||
|
||||
await expect(commentCard).toBeVisible()
|
||||
await expectReactionRoundTrip(page, commentCard, emojiButton(commentCard))
|
||||
|
||||
await page.goto(`${spacePath(url)}/threads/${thread.id}`)
|
||||
|
||||
const threadPost = page.locator("article").filter({hasText: "Every other season, usually."})
|
||||
|
||||
await expect(threadPost).toBeVisible()
|
||||
await expectReactionRoundTrip(page, threadPost, emojiButton(threadPost))
|
||||
|
||||
await page.goto(`/people/${nip19.npubEncode(users.alice.pubkey)}`)
|
||||
|
||||
const noteCard = page
|
||||
.locator(".card.card-interactive")
|
||||
.filter({hasText: "A short note from the garden."})
|
||||
|
||||
await expect(noteCard).toBeVisible()
|
||||
|
||||
// NoteItem's picker is the last control on the card rather than part of an EventActions join.
|
||||
await expectReactionRoundTrip(page, noteCard, noteCard.getByRole("button").last())
|
||||
})
|
||||
|
||||
test("US-041 publish an article from a room", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("lounge", {name: "Lounge"})
|
||||
space.join(user.alice, "lounge")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, roomPath(url, "lounge"))
|
||||
|
||||
await openComposeMenu(page)
|
||||
await page.getByRole("button", {name: "Write an Article"}).click()
|
||||
|
||||
const composer = modal(page, "Write an Article")
|
||||
|
||||
await composer.getByPlaceholder("What is this article about?").fill("Repotting in Winter")
|
||||
await editorOf(composer).pressSequentially("Wait for a warm week.")
|
||||
await composer.getByRole("button", {name: "Publish Article"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
|
||||
|
||||
// The room hears about the article without alice posting it a second time.
|
||||
await expect(page.getByText("Repotting in Winter")).toBeVisible()
|
||||
|
||||
await page.goto(`${spacePath(url)}/articles`)
|
||||
|
||||
const card = articleCards(page).filter({hasText: "Repotting in Winter"})
|
||||
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card.getByRole("link", {name: "#Lounge"})).toBeVisible()
|
||||
|
||||
await openArticle(page, "Repotting in Winter")
|
||||
|
||||
const badge = page.getByRole("link", {name: "Posted in #Lounge"})
|
||||
|
||||
await expect(badge).toBeVisible()
|
||||
|
||||
await badge.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${roomPath(url, "lounge")}$`))
|
||||
await expect(page.locator(".chat-editor")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-042 start a thread and see it filed under its room", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("lounge", {name: "Lounge"})
|
||||
space.join(user.alice, "lounge")
|
||||
space.join(user.bob, "lounge")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
|
||||
// An older topic with replies, so the board's reply count and last-post time are statements
|
||||
// about the thread rather than about an empty row.
|
||||
const topic = space.event(
|
||||
user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Thread)
|
||||
.writer()
|
||||
.setRoom(space.url, "lounge")
|
||||
.setTitle("Chair procurement")
|
||||
.setContent("The old ones are done for.")
|
||||
.renderTemplate(),
|
||||
at(5, HOUR),
|
||||
)
|
||||
|
||||
const replies = ["Mesh, obviously.", "Anything but mesh."]
|
||||
|
||||
replies.forEach((content, index) => {
|
||||
space.event(
|
||||
index === 0 ? user.alice : user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Comment)
|
||||
.writer()
|
||||
.setRootFromEvent(topic.event)
|
||||
.setParentFromEvent(topic.event)
|
||||
.setContent(content)
|
||||
.renderTemplate(),
|
||||
at(4, HOUR) + index * 60,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const {at} = scenario
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, roomPath(url, "lounge"))
|
||||
|
||||
await openComposeMenu(page)
|
||||
await page.getByRole("button", {name: "Create Thread"}).click()
|
||||
|
||||
const fromRoom = modal(page, "Create a Thread")
|
||||
|
||||
await fromRoom.getByPlaceholder("What is this thread about?").fill("Bike shed colors")
|
||||
await editorOf(fromRoom).pressSequentially("Blue, surely?")
|
||||
await fromRoom.getByRole("button", {name: "Create Thread"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
|
||||
|
||||
// The room gets a quote of the thread rather than a second post written by hand.
|
||||
await expect(page.getByText("Bike shed colors")).toBeVisible()
|
||||
|
||||
await page.goto(`${spacePath(url)}/threads`)
|
||||
|
||||
const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})})
|
||||
const chairs = lounge.getByRole("row").filter({hasText: "Chair procurement"})
|
||||
|
||||
await expect(lounge.getByRole("row").filter({hasText: "Bike shed colors"})).toBeVisible()
|
||||
|
||||
// Topic, author, reply count and last post, in that order.
|
||||
await expect(chairs.getByRole("cell").nth(0)).toContainText("Chair procurement")
|
||||
await expect(chairs.getByRole("cell").nth(1)).toContainText("Bob Barker")
|
||||
await expect(chairs.getByRole("cell").nth(2)).toHaveText("2")
|
||||
await expect(chairs.getByRole("cell").nth(3)).toContainText(shortDate(at(4, HOUR) + 60))
|
||||
|
||||
await pageBar(page).getByRole("button", {name: "Create", exact: true}).click()
|
||||
|
||||
const fromThreads = modal(page, "Create a Thread")
|
||||
|
||||
await fromThreads.getByPlaceholder("What is this thread about?").fill("Open floor")
|
||||
await editorOf(fromThreads).pressSequentially("Anything goes in here.")
|
||||
await fromThreads.getByRole("button", {name: "Create Thread"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
|
||||
|
||||
const general = page
|
||||
.locator("section")
|
||||
.filter({has: page.getByRole("heading", {name: "General", exact: true})})
|
||||
|
||||
await expect(general.getByRole("row").filter({hasText: "Open floor"})).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
|
||||
let thread!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("lounge", {name: "Lounge"})
|
||||
space.join(user.alice, "lounge")
|
||||
space.join(user.bob, "lounge")
|
||||
space.join(user.carol, "lounge")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
space.profile(user.carol, {name: "Carol Chen"})
|
||||
|
||||
const topic = space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Thread)
|
||||
.writer()
|
||||
.setRoom(space.url, "lounge")
|
||||
.setTitle("Bed rotation")
|
||||
.setContent("How often do you move things around?")
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
// Twenty replies plus the opening post is one post past the first page, and the last of them
|
||||
// is alice's, so her OP badge has to survive the page change.
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
space.event(
|
||||
i === 20 ? user.alice : i % 2 === 0 ? user.carol : user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Comment)
|
||||
.writer()
|
||||
.setRootFromEvent(topic.event)
|
||||
.setParentFromEvent(topic.event)
|
||||
.setContent(`Reply ${String(i).padStart(2, "0")}`)
|
||||
.renderTemplate(),
|
||||
at(3, HOUR) + i * 60,
|
||||
)
|
||||
}
|
||||
|
||||
thread = topic
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const threadPath = `${spacePath(url)}/threads/${thread.id}`
|
||||
const bob = await as(users.bob, threadPath)
|
||||
|
||||
const openingPost = bob.locator(`[data-event="${thread.id}"]`)
|
||||
|
||||
await expect(openingPost).toBeVisible()
|
||||
await expect(openingPost.getByText("OP", {exact: true})).toBeVisible()
|
||||
await expect(bob.getByText("20 replies")).toBeVisible()
|
||||
|
||||
await bob.getByRole("button", {name: "Reply to thread"}).click()
|
||||
|
||||
const threadReply = composerForm(bob)
|
||||
|
||||
await expect(threadReply).toBeVisible()
|
||||
await expect(threadReply.getByText(/^Replying to/)).toHaveCount(0)
|
||||
|
||||
await editorOf(threadReply).pressSequentially("Twice a year here.")
|
||||
await threadReply.getByRole("button", {name: "Post Reply"}).click()
|
||||
|
||||
await expect(bob.getByText("21 replies")).toBeVisible()
|
||||
|
||||
// The thread's author is marked OP wherever their posts turn up, second page included.
|
||||
await bob.getByRole("button", {name: "2", exact: true}).click()
|
||||
await expect(bob.getByText("Page 2 of 2")).toBeVisible()
|
||||
|
||||
const alicesLastPost = bob.locator("article").filter({hasText: "Reply 20"})
|
||||
|
||||
await expect(alicesLastPost.getByText("OP", {exact: true})).toBeVisible()
|
||||
await expect(bob.getByText("Twice a year here.")).toBeVisible()
|
||||
|
||||
const carol = await as(users.carol, threadPath)
|
||||
const bobsFirstPost = carol.locator("article").filter({hasText: "Reply 01"})
|
||||
|
||||
await expect(bobsFirstPost).toBeVisible()
|
||||
await bobsFirstPost.getByRole("button", {name: "Reply", exact: true}).click()
|
||||
|
||||
const postReply = composerForm(carol)
|
||||
const replyingTo = postReply.locator(".border-l-2")
|
||||
|
||||
await expect(replyingTo).toContainText("Replying to @Bob Barker")
|
||||
await expect(replyingTo).toContainText("Reply 01")
|
||||
|
||||
await replyingTo.getByRole("button").last().click()
|
||||
|
||||
await expect(postReply.getByText(/^Replying to/)).toHaveCount(0)
|
||||
|
||||
await editorOf(postReply).pressSequentially("Answering the thread instead.")
|
||||
await postReply.getByRole("button", {name: "Post Reply"}).click()
|
||||
|
||||
// Twenty one replies before hers is already a page and a bit, so her post is appended to the
|
||||
// last one rather than to the page she wrote it from. The last control in the join is "go last",
|
||||
// which is the same button whatever the page count turns out to be.
|
||||
await carol
|
||||
.getByText(/^Page \d+ of \d+$/)
|
||||
.locator("xpath=..")
|
||||
.locator(".join")
|
||||
.getByRole("button")
|
||||
.last()
|
||||
.click()
|
||||
|
||||
await expect(carol.getByText("Answering the thread instead.")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-044 navigate a long thread", async ({seed, as}) => {
|
||||
let thread!: Seeded
|
||||
let lastPost!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("lounge", {name: "Lounge"})
|
||||
space.join(user.alice, "lounge")
|
||||
space.join(user.bob, "lounge")
|
||||
space.join(user.carol, "lounge")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
space.profile(user.carol, {name: "Carol Chen"})
|
||||
|
||||
const topic = space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Thread)
|
||||
.writer()
|
||||
.setRoom(space.url, "lounge")
|
||||
.setTitle("The long one")
|
||||
.setContent("Settle in.")
|
||||
.renderTemplate(),
|
||||
at(4, HOUR),
|
||||
)
|
||||
|
||||
// Forty one replies plus the opening post is three pages of twenty.
|
||||
for (let i = 1; i <= 41; i++) {
|
||||
const reply = space.event(
|
||||
i % 2 === 0 ? user.alice : user.bob,
|
||||
() =>
|
||||
space
|
||||
.kind(Comment)
|
||||
.writer()
|
||||
.setRootFromEvent(topic.event)
|
||||
.setParentFromEvent(topic.event)
|
||||
.setContent(`Reply ${String(i).padStart(2, "0")}`)
|
||||
.renderTemplate(),
|
||||
at(4, HOUR) + i * 60,
|
||||
)
|
||||
|
||||
if (i === 41) {
|
||||
lastPost = reply
|
||||
}
|
||||
}
|
||||
|
||||
thread = topic
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const threadPath = `${spacePath(url)}/threads/${thread.id}`
|
||||
const bob = await as(users.bob, threadPath, {
|
||||
context: {permissions: ["clipboard-read", "clipboard-write"]},
|
||||
})
|
||||
|
||||
const indicator = bob.getByText(/^Page \d+ of \d+$/)
|
||||
|
||||
await expect(indicator).toHaveText("Page 1 of 3")
|
||||
await expect(bob.getByText("Reply 19", {exact: true})).toBeVisible()
|
||||
|
||||
// ThreadPagination is first, prev, a button per page, next, last, and only the page numbers
|
||||
// carry an accessible name — the rest are icons — so with three pages the ends are 0, 1, 5, 6.
|
||||
const controls = indicator.locator("xpath=..").locator(".join").getByRole("button")
|
||||
const goFirst = controls.nth(0)
|
||||
const goPrev = controls.nth(1)
|
||||
const goNext = controls.nth(5)
|
||||
const goLast = controls.nth(6)
|
||||
|
||||
await goNext.click()
|
||||
await expect(indicator).toHaveText("Page 2 of 3")
|
||||
await expect(bob.getByText("Reply 20", {exact: true})).toBeVisible()
|
||||
|
||||
await goLast.click()
|
||||
await expect(indicator).toHaveText("Page 3 of 3")
|
||||
await expect(bob.getByText("Reply 41", {exact: true})).toBeVisible()
|
||||
|
||||
await goPrev.click()
|
||||
await expect(indicator).toHaveText("Page 2 of 3")
|
||||
await expect(bob.getByText("Reply 20", {exact: true})).toBeVisible()
|
||||
|
||||
await goFirst.click()
|
||||
await expect(indicator).toHaveText("Page 1 of 3")
|
||||
await expect(bob.getByText("Reply 19", {exact: true})).toBeVisible()
|
||||
|
||||
await bob.getByRole("button", {name: "3", exact: true}).click()
|
||||
await expect(indicator).toHaveText("Page 3 of 3")
|
||||
|
||||
const finalPost = bob.locator(`[data-event="${lastPost.id}"]`)
|
||||
|
||||
await finalPost.getByRole("button", {name: "Permalink"}).click()
|
||||
await expect(bob.getByRole("alert")).toContainText("Copied to clipboard!")
|
||||
|
||||
const permalink = await bob.evaluate(() => navigator.clipboard.readText())
|
||||
const {pathname, hash} = new URL(permalink)
|
||||
|
||||
expect(pathname).toBe(threadPath)
|
||||
expect(hash).toBe(`#${nip19.neventEncode({id: lastPost.id, relays: [url]})}`)
|
||||
|
||||
const carol = await as(users.carol, pathname + hash)
|
||||
const target = carol.locator(`[data-event="${lastPost.id}"]`)
|
||||
|
||||
await expect(carol.getByText(/^Page \d+ of \d+$/)).toHaveText("Page 3 of 3")
|
||||
await expect(target).toBeVisible()
|
||||
await expect(target).toBeInViewport()
|
||||
})
|
||||
|
||||
test("US-045 turn a chat message into a thread", async ({seed, as}) => {
|
||||
let promoted!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("lounge", {name: "Lounge"})
|
||||
space.join(user.alice, "lounge")
|
||||
space.join(user.bob, "lounge")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
|
||||
promoted = space.message(user.alice, "lounge", "the deploy broke again", at(20, MINUTE))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.bob, roomPath(url, "lounge"))
|
||||
|
||||
const message = page.locator(`[data-event="${promoted.id}"]`)
|
||||
|
||||
await expect(message).toBeVisible()
|
||||
await message.hover()
|
||||
|
||||
// RoomItem's hover actions are zap, emoji, reply and menu, all of them icons.
|
||||
await message.locator(".room__item-actions").getByRole("button").last().click()
|
||||
await page.getByRole("button", {name: "Create a Thread"}).click()
|
||||
|
||||
const composer = modal(page, "Create a Thread")
|
||||
const nevent = nip19.neventEncode({id: promoted.id, kind: MESSAGE, relays: [url]})
|
||||
|
||||
await expect(editorOf(composer)).toContainText(`nostr:${nevent}`)
|
||||
|
||||
await composer.getByPlaceholder("What is this thread about?").fill("Deploy failures")
|
||||
await composer.getByRole("button", {name: "Create Thread"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
|
||||
|
||||
await page.goto(`${spacePath(url)}/threads`)
|
||||
|
||||
const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})})
|
||||
const row = lounge.getByRole("row").filter({hasText: "Deploy failures"})
|
||||
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await row.click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Deploy failures"})).toBeVisible()
|
||||
await expect(page.getByText("the deploy broke again")).toBeVisible()
|
||||
})
|
||||
1142
e2e/specs/community.spec.ts
Normal file
1142
e2e/specs/community.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
496
e2e/specs/composer.spec.ts
Normal file
496
e2e/specs/composer.spec.ts
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
import {createHash} from "node:crypto"
|
||||
import type {BrowserContext, Locator, Page} from "@playwright/test"
|
||||
import {HOUR, now} from "@welshman/lib"
|
||||
import {MessagingRelayList, RelayList} from "@welshman/domain"
|
||||
import {expect, makeTestUser, roomPath, test, users} from "../harness"
|
||||
import type {SeededSpace, TestUser} from "../harness"
|
||||
|
||||
// The path the app builds for a conversation — see makeChatId in src/app/chats.ts.
|
||||
const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
|
||||
|
||||
// Where an upload lands: the space's own origin is probed first and blossom is off in every
|
||||
// tenant's toml, so what is left is VITE_DEFAULT_BLOSSOM_SERVERS.
|
||||
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
|
||||
|
||||
// A one pixel gif. Gif rather than png because compressFileForUpload passes it through untouched
|
||||
// rather than re-encoding it through a canvas, so the mock stores the bytes the browser was handed.
|
||||
const GIF = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
|
||||
const gifFile = (name: string) => ({
|
||||
name,
|
||||
mimeType: "image/gif",
|
||||
buffer: Buffer.from(GIF, "base64"),
|
||||
})
|
||||
|
||||
// A blossom server whose blobs are shared by every context in a test, and whose next upload can be
|
||||
// held open. The harness's own `mockBlossom` keeps blobs per browser context, which a conversation's
|
||||
// image cannot use: it is uploaded encrypted and decrypted by the recipient, so the bytes alice puts
|
||||
// in have to be the bytes bob reads back. Holding the upload makes the in-flight state a fact rather
|
||||
// than a race against a mock that answers in a microtask.
|
||||
const serveBlossom = () => {
|
||||
const blobs = new Map<string, {body: Buffer; type: string}>()
|
||||
|
||||
let held = Promise.resolve()
|
||||
let open = () => {}
|
||||
|
||||
return {
|
||||
hold: () => {
|
||||
held = new Promise<void>(resolve => {
|
||||
open = resolve
|
||||
})
|
||||
},
|
||||
release: () => open(),
|
||||
install: (context: BrowserContext) =>
|
||||
context.route(`${BLOSSOM_ORIGIN}/**`, async route => {
|
||||
const request = route.request()
|
||||
const method = request.method()
|
||||
const {pathname} = new URL(request.url())
|
||||
|
||||
if (pathname === "/upload" && method === "PUT") {
|
||||
await held
|
||||
|
||||
const body = request.postDataBuffer() ?? Buffer.alloc(0)
|
||||
const type = request.headers()["content-type"] ?? "application/octet-stream"
|
||||
const sha256 = createHash("sha256").update(body).digest("hex")
|
||||
|
||||
blobs.set(sha256, {body, type})
|
||||
|
||||
const url = `${BLOSSOM_ORIGIN}/${sha256}`
|
||||
|
||||
return route.fulfill({json: {sha256, type, url, size: body.length, uploaded: now()}})
|
||||
}
|
||||
|
||||
const blob = blobs.get(pathname.slice(1).split(".")[0])
|
||||
|
||||
if (blob) {
|
||||
return route.fulfill({contentType: blob.type, body: method === "HEAD" ? "" : blob.body})
|
||||
}
|
||||
|
||||
return route.fallback()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Outbox routing resolves everything about a person through their relay list, so a seeded profile
|
||||
// is only loadable by somebody else once its author has one.
|
||||
const seedRelayList = (space: SeededSpace, user: TestUser) =>
|
||||
space.event(user, () =>
|
||||
space
|
||||
.kind(RelayList)
|
||||
.writer()
|
||||
.setReadUrls([space.url])
|
||||
.setWriteUrls([space.url])
|
||||
.renderTemplate(),
|
||||
)
|
||||
|
||||
// The kind-10050 a conversation needs at both ends: the recipient's is what enables the composer,
|
||||
// the sender's is what routes their own copy of the wrap back to them.
|
||||
const seedMessagingRelays = (space: SeededSpace, user: TestUser) =>
|
||||
space.event(user, () =>
|
||||
space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(),
|
||||
)
|
||||
|
||||
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
|
||||
|
||||
// The send button carries the shortcut it advertises, which differs by platform.
|
||||
const sendButton = (page: Page) => page.locator("button[data-tip$='enter to send']")
|
||||
|
||||
const suggestions = (page: Page) => page.locator(".tiptap-suggestions__item")
|
||||
|
||||
const timeline = (page: Page) => page.locator(".room__content")
|
||||
|
||||
// RoomCompose leads with a two button group: upload, then the create menu.
|
||||
const roomUploadButton = (page: Page) => page.locator(".room__compose-inner .join-item").first()
|
||||
|
||||
const chatUploadButton = (page: Page) => page.locator("button[data-tip='Add an image']")
|
||||
|
||||
// Both things the composer puts above itself — the message being replied to and the editing
|
||||
// indicator — are the same bordered strip, each with a single button in it: its X.
|
||||
const banner = (page: Page, text: string) =>
|
||||
page.locator(".room__compose .border-l-2").filter({hasText: text})
|
||||
|
||||
// RoomItem gives its hover actions no accessible names — every one is an icon. Their order is
|
||||
// fixed by the component: zap, emoji, reply, edit (only on your own recent message), menu.
|
||||
const messageActions = (page: Page, text: string) =>
|
||||
page.locator(".room__item").filter({hasText: text}).locator(".room__item-actions button")
|
||||
|
||||
const chooseFile = async (
|
||||
page: Page,
|
||||
button: Locator,
|
||||
file: {name: string; mimeType: string; buffer: Buffer},
|
||||
) => {
|
||||
const chooser = page.waitForEvent("filechooser")
|
||||
|
||||
await button.click()
|
||||
await (await chooser).setFiles(file)
|
||||
}
|
||||
|
||||
// Both of these are DataTransfer dispatches rather than anything native: prosemirror reads the file
|
||||
// straight off the event, and playwright's own dispatchEvent builds a plain Event, which would drop
|
||||
// the dataTransfer the handler needs.
|
||||
const dropImage = (editor: Locator, name: string) =>
|
||||
editor.evaluate(
|
||||
(node, {name, data}) => {
|
||||
const transfer = new DataTransfer()
|
||||
const {left, top, width, height} = node.getBoundingClientRect()
|
||||
|
||||
transfer.items.add(
|
||||
new File([Uint8Array.from(atob(data), c => c.charCodeAt(0))], name, {type: "image/gif"}),
|
||||
)
|
||||
|
||||
node.dispatchEvent(
|
||||
new DragEvent("drop", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
dataTransfer: transfer,
|
||||
clientX: left + width / 2,
|
||||
clientY: top + height / 2,
|
||||
}),
|
||||
)
|
||||
},
|
||||
{name, data: GIF},
|
||||
)
|
||||
|
||||
const pasteImage = (editor: Locator, name: string) =>
|
||||
editor.evaluate(
|
||||
(node, {name, data}) => {
|
||||
const transfer = new DataTransfer()
|
||||
|
||||
transfer.items.add(
|
||||
new File([Uint8Array.from(atob(data), c => c.charCodeAt(0))], name, {type: "image/gif"}),
|
||||
)
|
||||
|
||||
node.dispatchEvent(
|
||||
new ClipboardEvent("paste", {bubbles: true, cancelable: true, clipboardData: transfer}),
|
||||
)
|
||||
},
|
||||
{name, data: GIF},
|
||||
)
|
||||
|
||||
test("US-056 autocomplete a mention or a room reference", async ({seed, as}) => {
|
||||
const outsider = makeTestUser("bobbin")
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.room("random", {name: "Random"})
|
||||
space.join(user.alice, "general", "random")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.bob, {name: "Bob Roberts", picture: "https://images.test/bob.png"})
|
||||
seedRelayList(space, user.bob)
|
||||
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
||||
|
||||
// Someone whose name matches the same term but who belongs to a different space, so the
|
||||
// dropdown has a non-member to rank below this space's own members.
|
||||
other.room("lounge", {name: "Lounge"})
|
||||
other.join(user.alice, "lounge")
|
||||
other.join(outsider, "lounge")
|
||||
other.profile(outsider, {name: "Bobbin Amaranth"})
|
||||
seedRelayList(other, outsider)
|
||||
other.message(outsider, "lounge", "hello from the lounge", at(2, HOUR))
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
const other = scenario.space("other")
|
||||
|
||||
// Alice arrives through the other space so that the outsider's profile is in her client before
|
||||
// she composes: a profile reaches her by being rendered, and his never renders in the space she
|
||||
// is about to type in.
|
||||
const page = await as(users.alice, roomPath(other.url, "lounge"))
|
||||
|
||||
await expect(timeline(page).getByText("Bobbin Amaranth")).toBeVisible()
|
||||
|
||||
// A space's nav item is labeled with the name its nip-11 document reports, and the two tenants
|
||||
// report "space" and "other".
|
||||
await page.locator('.primary-nav [data-tip="space"]').click()
|
||||
await page.locator(".secondary-nav").getByRole("link", {name: "General"}).click()
|
||||
|
||||
await expect(timeline(page).getByText("morning all")).toBeVisible()
|
||||
|
||||
const editor = composer(page)
|
||||
|
||||
await editor.click()
|
||||
await editor.pressSequentially("@bob")
|
||||
|
||||
// This space's own member first, the outsider from the other space under him.
|
||||
await expect(suggestions(page)).toContainText(["Bob Roberts", "Bobbin Amaranth"])
|
||||
|
||||
await editor.pressSequentially("bin")
|
||||
|
||||
await expect(suggestions(page)).toContainText(["Bobbin Amaranth"])
|
||||
|
||||
// Back to "@bob", where both of them are on offer again.
|
||||
await editor.press("Backspace")
|
||||
await editor.press("Backspace")
|
||||
await editor.press("Backspace")
|
||||
|
||||
await expect(suggestions(page)).toContainText(["Bob Roberts", "Bobbin Amaranth"])
|
||||
|
||||
await suggestions(page).filter({hasText: "Bob Roberts"}).click()
|
||||
|
||||
const mention = editor.locator(".tiptap-object")
|
||||
|
||||
await expect(mention).toHaveText("@Bob Roberts")
|
||||
await expect(mention.locator("img")).toBeVisible()
|
||||
|
||||
await editor.pressSequentially("are you around?")
|
||||
await editor.press("Enter")
|
||||
|
||||
const sent = page.locator(".room__item").filter({hasText: "are you around?"})
|
||||
const sentMention = sent.getByRole("button", {name: "@Bob Roberts"})
|
||||
|
||||
await expect(sentMention).toBeVisible()
|
||||
await expect(sentMention.locator("img")).toBeVisible()
|
||||
|
||||
await editor.pressSequentially("~gene")
|
||||
|
||||
await expect(suggestions(page)).toContainText(["General"])
|
||||
|
||||
await suggestions(page).filter({hasText: "General"}).click()
|
||||
|
||||
await expect(editor.locator(".tiptap-object")).toContainText("General")
|
||||
|
||||
await editor.press("Enter")
|
||||
|
||||
await expect(timeline(page).getByRole("link", {name: "#General"})).toHaveAttribute(
|
||||
"href",
|
||||
roomPath(space.url, "general"),
|
||||
)
|
||||
})
|
||||
|
||||
test("US-057 attach and send an image", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
||||
|
||||
for (const person of [user.alice, user.bob]) {
|
||||
space.profile(person, {name: person.name})
|
||||
seedRelayList(space, person)
|
||||
seedMessagingRelays(space, person)
|
||||
}
|
||||
})
|
||||
|
||||
const path = roomPath(scenario.space("space").url, "general")
|
||||
const blossom = serveBlossom()
|
||||
|
||||
const alice = await as(users.alice, path)
|
||||
const bob = await as(users.bob, path)
|
||||
|
||||
await blossom.install(alice.context())
|
||||
await blossom.install(bob.context())
|
||||
|
||||
await expect(timeline(alice).getByText("morning all")).toBeVisible()
|
||||
|
||||
blossom.hold()
|
||||
|
||||
await chooseFile(alice, roomUploadButton(alice), gifFile("photo.gif"))
|
||||
|
||||
await expect(alice.locator(".room__compose-inner .spinner")).toBeVisible()
|
||||
await expect(sendButton(alice)).toBeDisabled()
|
||||
|
||||
blossom.release()
|
||||
|
||||
await expect(sendButton(alice)).toBeEnabled()
|
||||
await expect(composer(alice)).toContainText("photo.gif")
|
||||
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
|
||||
|
||||
await composer(alice).press("Enter")
|
||||
|
||||
await expect(timeline(alice).locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
||||
await expect(timeline(bob).locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
||||
|
||||
await dropImage(composer(alice), "dropped.gif")
|
||||
|
||||
await expect(composer(alice)).toContainText("dropped.gif")
|
||||
|
||||
await pasteImage(composer(alice), "pasted.gif")
|
||||
|
||||
await expect(composer(alice)).toContainText("pasted.gif")
|
||||
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
|
||||
|
||||
// The same thing in a conversation. Its composer stays disabled until the recipient's messaging
|
||||
// relays have been read, which is what waiting on the send button waits out.
|
||||
await alice.goto(chatPath(users.bob.pubkey))
|
||||
await bob.goto(chatPath(users.alice.pubkey))
|
||||
|
||||
await expect(sendButton(alice)).toBeEnabled()
|
||||
|
||||
await chooseFile(alice, chatUploadButton(alice), gifFile("selfie.gif"))
|
||||
|
||||
await expect(composer(alice)).toContainText("selfie.gif")
|
||||
|
||||
// The file node appears the moment it is attached, which is also the moment `uploading` goes
|
||||
// true — and submit returns without a word while it is. Encryption makes that window wide enough
|
||||
// to press enter into, so wait the upload out rather than losing the message to it.
|
||||
await expect(sendButton(alice)).toBeEnabled()
|
||||
|
||||
await composer(alice).press("Enter")
|
||||
|
||||
// A conversation's image is uploaded encrypted, so the recipient fetches the ciphertext and
|
||||
// decrypts it into a blob url rather than pointing an <img> at the server.
|
||||
await expect(bob.locator('.chat-bubble img[src^="blob:"]')).toBeVisible()
|
||||
|
||||
// Back to the room on a fresh page, so no toast the conversation raised is still standing and the
|
||||
// one asserted below can only be the rejected upload's own.
|
||||
await alice.goto(path)
|
||||
|
||||
await expect(timeline(alice).getByText("morning all")).toBeVisible()
|
||||
await expect(alice.getByRole("alert")).toHaveCount(0)
|
||||
|
||||
await chooseFile(alice, roomUploadButton(alice), {
|
||||
name: "notes.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("not an image"),
|
||||
})
|
||||
|
||||
await expect(alice.getByRole("alert")).toBeVisible()
|
||||
await expect(composer(alice)).not.toContainText("notes.txt")
|
||||
})
|
||||
|
||||
test("US-058 drafts survive navigating away", async ({seed, as}) => {
|
||||
await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.room("random", {name: "Random"})
|
||||
space.join(user.alice, "general", "random")
|
||||
space.join(user.bob, "general")
|
||||
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
||||
|
||||
// Alice's own kind-10050 is what lets the Messages nav item navigate rather than stop to ask
|
||||
// her to enable chat; bob's is what enables the conversation's composer.
|
||||
for (const person of [user.alice, user.bob]) {
|
||||
space.profile(person, {name: person.name})
|
||||
seedRelayList(space, person)
|
||||
seedMessagingRelays(space, person)
|
||||
}
|
||||
})
|
||||
|
||||
// Drafts live in memory, so every move here is an in-app navigation — a reload would clear them
|
||||
// whether or not they were kept.
|
||||
const page = await as(users.alice, chatPath(users.bob.pubkey))
|
||||
const editor = composer(page)
|
||||
const rooms = page.locator(".secondary-nav")
|
||||
|
||||
await expect(sendButton(page)).toBeEnabled()
|
||||
|
||||
await editor.click()
|
||||
await editor.pressSequentially("still thinking about this")
|
||||
|
||||
await page.locator('.primary-nav [data-tip="space"]').click()
|
||||
await rooms.getByRole("link", {name: "General"}).click()
|
||||
|
||||
await expect(timeline(page).getByText("morning all")).toBeVisible()
|
||||
await expect(editor).toHaveText("")
|
||||
|
||||
await editor.click()
|
||||
await editor.pressSequentially("half a thought")
|
||||
|
||||
await rooms.getByRole("link", {name: "Random"}).click()
|
||||
|
||||
await expect(editor).toHaveText("")
|
||||
|
||||
await rooms.getByRole("link", {name: "General"}).click()
|
||||
|
||||
await expect(editor).toHaveText("half a thought")
|
||||
|
||||
await page.locator('.primary-nav [data-tip="Messages"]').click()
|
||||
|
||||
await expect(editor).toHaveText("still thinking about this")
|
||||
|
||||
await page.locator('.primary-nav [data-tip="space"]').click()
|
||||
await rooms.getByRole("link", {name: "General"}).click()
|
||||
|
||||
await expect(editor).toHaveText("half a thought")
|
||||
|
||||
await editor.press("Enter")
|
||||
|
||||
await expect(timeline(page).getByText("half a thought")).toBeVisible()
|
||||
await expect(editor).toHaveText("")
|
||||
|
||||
await rooms.getByRole("link", {name: "Random"}).click()
|
||||
await rooms.getByRole("link", {name: "General"}).click()
|
||||
|
||||
await expect(editor).toHaveText("")
|
||||
})
|
||||
|
||||
test("US-059 cancel a reply or an edit in progress", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.bob, {name: "Bob Roberts"})
|
||||
seedRelayList(space, user.bob)
|
||||
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
||||
})
|
||||
|
||||
const page = await as(users.alice, roomPath(scenario.space("space").url, "general"))
|
||||
const editor = composer(page)
|
||||
const replyBanner = banner(page, "Replying to @Bob Roberts")
|
||||
const editBanner = banner(page, "Editing message")
|
||||
|
||||
await expect(timeline(page).getByText("morning all")).toBeVisible()
|
||||
|
||||
// Only a message alice sent herself in the last five minutes offers an edit, so she sends one.
|
||||
await editor.click()
|
||||
await editor.pressSequentially("my first message")
|
||||
await editor.press("Enter")
|
||||
|
||||
await expect(timeline(page).getByText("my first message")).toBeVisible()
|
||||
|
||||
await editor.pressSequentially("half-written thought")
|
||||
|
||||
// Bob's message offers zap, emoji, reply and the menu; alice's own also offers the edit.
|
||||
await expect(messageActions(page, "morning all")).toHaveCount(4)
|
||||
await expect(messageActions(page, "my first message")).toHaveCount(5)
|
||||
|
||||
await messageActions(page, "morning all").nth(2).click()
|
||||
|
||||
await expect(replyBanner).toContainText("morning all")
|
||||
|
||||
await editor.press("Escape")
|
||||
|
||||
await expect(replyBanner).toHaveCount(0)
|
||||
await expect(editor).toHaveText("half-written thought")
|
||||
|
||||
await messageActions(page, "morning all").nth(2).click()
|
||||
|
||||
await expect(replyBanner).toBeVisible()
|
||||
|
||||
await replyBanner.getByRole("button").click()
|
||||
|
||||
await expect(replyBanner).toHaveCount(0)
|
||||
await expect(editor).toHaveText("half-written thought")
|
||||
|
||||
await messageActions(page, "my first message").nth(3).click()
|
||||
|
||||
await expect(editBanner).toBeVisible()
|
||||
await expect(editor).toHaveText("my first message")
|
||||
|
||||
await editor.press("Escape")
|
||||
|
||||
await expect(editBanner).toHaveCount(0)
|
||||
await expect(editor).toHaveText("half-written thought")
|
||||
|
||||
await messageActions(page, "my first message").nth(3).click()
|
||||
|
||||
await expect(editBanner).toBeVisible()
|
||||
|
||||
await editBanner.getByRole("button").click()
|
||||
|
||||
await expect(editBanner).toHaveCount(0)
|
||||
await expect(editor).toHaveText("half-written thought")
|
||||
|
||||
// Neither message was touched, and the draft never left the composer.
|
||||
await expect(timeline(page).getByText("morning all")).toHaveCount(1)
|
||||
await expect(timeline(page).getByText("my first message")).toHaveCount(1)
|
||||
await expect(timeline(page).getByText("half-written thought")).toHaveCount(0)
|
||||
})
|
||||
496
e2e/specs/content-rendering.spec.ts
Normal file
496
e2e/specs/content-rendering.spec.ts
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
import {npubEncode} from "nostr-tools/nip19"
|
||||
import {HOUR, MINUTE} from "@welshman/lib"
|
||||
import {MESSAGE, makeEvent} from "@welshman/util"
|
||||
import {Article, Thread} from "@welshman/domain"
|
||||
import {expect, roomPath, spacePath, test, users} from "../harness"
|
||||
|
||||
// A handle to a seeded event, which only reads once seed() has drained its queue.
|
||||
type Seeded = {readonly id: string}
|
||||
|
||||
// A bolt11 invoice as @welshman/content recognizes one: only behind a `lightning:` scheme, and
|
||||
// what the chip renders and copies is what follows the scheme.
|
||||
const INVOICE =
|
||||
"lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq" +
|
||||
"ypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3" +
|
||||
"agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9"
|
||||
|
||||
// A cashu token keeps its own scheme in the value, and needs fifty-odd payload characters after
|
||||
// it before the parser will take it.
|
||||
const CASHU =
|
||||
"cashu:cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJpZCI6IjAwOWExZjI5MzI1M2U0MWUiLCJhbW91bnQiOjIs" +
|
||||
"InNlY3JldCI6ImFhYSIsIkMiOiJiYmIifV0sIm1pbnQiOiJodHRwczovL21pbnQudGVzdCJ9XX0="
|
||||
|
||||
test("US-060 reveal a flagged sensitive message", async ({seed, as}) => {
|
||||
let flagged!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.message(user.alice, "general", "no spoilers please", at(45, MINUTE))
|
||||
|
||||
flagged = space.event(
|
||||
user.bob,
|
||||
makeEvent(MESSAGE, {
|
||||
content: "the butler did it",
|
||||
tags: [
|
||||
["h", "general"],
|
||||
["content-warning", "spoilers"],
|
||||
],
|
||||
created_at: at(30, MINUTE),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, roomPath(url, "general"))
|
||||
|
||||
// Scoped to bob's own message, so "in place of his text" is a claim about this message rather
|
||||
// than about the room as a whole.
|
||||
const message = page.locator(`[data-event="${flagged.id}"]`)
|
||||
const warning = message.getByText('flagged by the author as "spoilers"')
|
||||
|
||||
await expect(warning).toBeVisible()
|
||||
await expect(message.getByText("the butler did it")).toHaveCount(0)
|
||||
|
||||
await message.getByRole("button", {name: "Show anyway"}).click()
|
||||
|
||||
await expect(message.getByText("the butler did it")).toBeVisible()
|
||||
await expect(warning).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-061 expand a long post", async ({seed, as}) => {
|
||||
// Twelve of these is several times the three hundred characters an article card shows.
|
||||
const paragraphs = Array.from(
|
||||
{length: 12},
|
||||
(_, i) => `Paragraph ${i} works through another part of the plan in some detail`,
|
||||
)
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.carol, "general")
|
||||
space.profile(user.carol, {name: "Carol Chen"})
|
||||
|
||||
// No summary, so the card shows the body itself and truncating it is what the story is about.
|
||||
space.event(
|
||||
user.carol,
|
||||
() =>
|
||||
space
|
||||
.kind(Article)
|
||||
.writer()
|
||||
.setIdentifier("the-long-road")
|
||||
.setTitle("The Long Road")
|
||||
.setPublishedAt(at(3, HOUR))
|
||||
.setContent(paragraphs.join("\n\n"))
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, `${spacePath(url)}/articles`)
|
||||
|
||||
await expect(page.getByText("The Long Road")).toBeVisible()
|
||||
await expect(page.getByText(paragraphs[0])).toBeVisible()
|
||||
await expect(page.getByText(paragraphs[11])).toHaveCount(0)
|
||||
|
||||
const before = page.url()
|
||||
|
||||
await page.getByRole("button", {name: "Read more"}).click()
|
||||
|
||||
await expect(page.getByText(paragraphs[11])).toBeVisible()
|
||||
|
||||
// The whole card is a link into the article, so expanding in place means the click never
|
||||
// reached it.
|
||||
expect(page.url()).toBe(before)
|
||||
})
|
||||
|
||||
test("US-062 see images and video inline", async ({seed, as}) => {
|
||||
let picture!: Seeded
|
||||
let clip!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
|
||||
picture = space.message(user.bob, "general", "https://images.test/sunset.png", at(40, MINUTE))
|
||||
clip = space.message(user.bob, "general", "https://media.test/clip.mp4", at(35, MINUTE))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, roomPath(url, "general"))
|
||||
|
||||
const pictureMessage = page.locator(`[data-event="${picture.id}"]`)
|
||||
const clipMessage = page.locator(`[data-event="${clip.id}"]`)
|
||||
const inlineImage = pictureMessage.locator('img[src="https://images.test/sunset.png"]')
|
||||
|
||||
await expect(inlineImage).toBeVisible()
|
||||
await expect(pictureMessage.getByText("images.test/sunset.png")).toHaveCount(0)
|
||||
|
||||
await inlineImage.click()
|
||||
|
||||
// The lightbox is a fullscreen modal holding nothing but the image.
|
||||
const lightbox = page.locator(".dialog img")
|
||||
|
||||
await expect(lightbox).toBeVisible()
|
||||
|
||||
await lightbox.click()
|
||||
|
||||
await expect(page.locator(".dialog")).toHaveCount(0)
|
||||
await expect(inlineImage).toBeVisible()
|
||||
|
||||
const video = clipMessage.locator("video")
|
||||
|
||||
await expect(video).toBeVisible()
|
||||
await expect(video).toHaveJSProperty("controls", true)
|
||||
})
|
||||
|
||||
test("US-063 preview a shared link", async ({seed, as}) => {
|
||||
const announcement = "https://example.test/announcement"
|
||||
const unreadable = "https://example.test/unreadable"
|
||||
|
||||
let standalone!: Seeded
|
||||
let broken!: Seeded
|
||||
let sentence!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
|
||||
standalone = space.message(user.bob, "general", announcement, at(40, MINUTE))
|
||||
broken = space.message(user.bob, "general", unreadable, at(35, MINUTE))
|
||||
sentence = space.message(
|
||||
user.bob,
|
||||
"general",
|
||||
`Have a look at ${announcement} before the call.`,
|
||||
at(30, MINUTE),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
|
||||
// The room is opened by hand below rather than by as(): a preview is fetched once per url and
|
||||
// memoized for the life of the page, so the backend has to be standing before the first render.
|
||||
const page = await as(users.alice, "/")
|
||||
|
||||
let servePreview = () => {}
|
||||
|
||||
const previewReleased = new Promise<void>(resolve => {
|
||||
servePreview = resolve
|
||||
})
|
||||
|
||||
// Registered after as(), so it answers ahead of the harness's own dufflepud. Holding the one
|
||||
// preview open makes the loading state a fact rather than a race.
|
||||
await page.context().route(
|
||||
requestUrl => requestUrl.pathname === "/link/preview",
|
||||
async route => {
|
||||
const {url: target}: {url: string} = route.request().postDataJSON()
|
||||
|
||||
if (target === announcement) {
|
||||
await previewReleased
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
title: "Flotilla ships v1",
|
||||
description: "Everything new in this release.",
|
||||
image: "https://images.test/preview.png",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// What dufflepud answers with for a url it could make nothing of.
|
||||
return route.fulfill({json: {}})
|
||||
},
|
||||
)
|
||||
|
||||
await page.goto(roomPath(url, "general"))
|
||||
|
||||
const card = page.locator(`[data-event="${standalone.id}"]`)
|
||||
const failed = page.locator(`[data-event="${broken.id}"]`)
|
||||
const inline = page.locator(`[data-event="${sentence.id}"]`)
|
||||
const previewImage = 'img[src="https://images.test/preview.png"]'
|
||||
|
||||
await expect(card.locator(".spinner")).toBeVisible()
|
||||
await expect(failed.getByText(`Unable to load a preview for ${unreadable}`)).toBeVisible()
|
||||
|
||||
servePreview()
|
||||
|
||||
await expect(card.getByText("Flotilla ships v1")).toBeVisible()
|
||||
await expect(card.getByText("Everything new in this release.")).toBeVisible()
|
||||
await expect(card.locator(previewImage)).toBeVisible()
|
||||
|
||||
// The same url mid-sentence, now that the preview it would have shown is known to resolve: a
|
||||
// compact link, and none of the card.
|
||||
await expect(inline.getByRole("link", {name: "example.test/announcement"})).toBeVisible()
|
||||
await expect(inline.getByText("Flotilla ships v1")).toHaveCount(0)
|
||||
await expect(inline.locator(previewImage)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-064 follow a link to another space", async ({seed, as}) => {
|
||||
let reference!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
|
||||
other.room("lounge", {name: "Other Lounge"})
|
||||
other.join(user.alice, "lounge")
|
||||
|
||||
// The second space's url only exists once seeding has run, so the message is rendered late.
|
||||
reference = space.event(
|
||||
user.bob,
|
||||
() => makeEvent(MESSAGE, {content: other.url, tags: [["h", "general"]]}),
|
||||
at(30, MINUTE),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const other = scenario.space("other")
|
||||
const page = await as(users.alice, roomPath(url, "general"))
|
||||
|
||||
const message = page.locator(`[data-event="${reference.id}"]`)
|
||||
const spaceLink = message.getByRole("link", {name: "other.test"})
|
||||
|
||||
await expect(spaceLink).toBeVisible()
|
||||
await expect(message.getByText(other.url)).toHaveCount(0)
|
||||
await expect(spaceLink).toHaveAttribute("href", spacePath(other.url))
|
||||
await expect(spaceLink).not.toHaveAttribute("target", "_blank")
|
||||
|
||||
await spaceLink.click()
|
||||
|
||||
await page.waitForURL(next => next.pathname.startsWith(spacePath(other.url)))
|
||||
|
||||
await expect(
|
||||
page.locator(".secondary-nav").getByRole("link", {name: "Other Lounge"}),
|
||||
).toBeVisible()
|
||||
|
||||
expect(page.context().pages()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("US-065 see quoted and embedded content", async ({seed, as}) => {
|
||||
let original!: Seeded
|
||||
let reply!: Seeded
|
||||
let thread!: Seeded
|
||||
let threadQuote!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.join(user.carol, "general")
|
||||
space.profile(user.alice, {name: "Alice Anders"})
|
||||
space.profile(user.bob, {name: "Bob Boberton"})
|
||||
space.profile(user.carol, {name: "Carol Chen"})
|
||||
|
||||
const point = space.message(user.alice, "general", "the original point", at(6, HOUR))
|
||||
|
||||
// Enough between the two that the original is well off screen when the room opens.
|
||||
for (let i = 0; i < 30; i++) {
|
||||
space.message(user.bob, "general", `filler message number ${i}`, at(300 - i * 5, MINUTE))
|
||||
}
|
||||
|
||||
// Carol says something in the room too, so her profile is loaded by the time her name has to
|
||||
// render on a card quoting her thread.
|
||||
space.message(user.carol, "general", "posted a roadmap", at(140, MINUTE))
|
||||
|
||||
const topic = space.event(
|
||||
user.carol,
|
||||
() =>
|
||||
space
|
||||
.kind(Thread)
|
||||
.writer()
|
||||
.setRoom(space.url, "general")
|
||||
.setTitle("Roadmap for Q3")
|
||||
.setContent("Here is what we are planning for the next quarter.")
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
original = point
|
||||
thread = topic
|
||||
reply = space.reply(user.bob, point, "totally agree with this", at(20, MINUTE))
|
||||
threadQuote = space.reply(user.bob, topic, "worth a read", at(15, MINUTE))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, roomPath(url, "general"))
|
||||
|
||||
const threadMessage = page.locator(`[data-event="${threadQuote.id}"]`)
|
||||
|
||||
// First, because it is the state a quote card is in until the quoted event arrives, and a
|
||||
// thread is not part of the room's own feed — the card has to go and fetch it.
|
||||
await expect(threadMessage.getByText("Loading event...")).toBeVisible()
|
||||
|
||||
const replyMessage = page.locator(`[data-event="${reply.id}"]`)
|
||||
const quoteStrip = replyMessage.locator(".border-l-2")
|
||||
|
||||
await expect(quoteStrip).toContainText("the original point")
|
||||
await expect(replyMessage).toContainText("totally agree with this")
|
||||
|
||||
const originalMessage = page.locator(`[data-event="${original.id}"]`)
|
||||
|
||||
await expect(originalMessage).toBeAttached()
|
||||
await expect(originalMessage).not.toBeInViewport()
|
||||
|
||||
await quoteStrip.click()
|
||||
|
||||
await expect(originalMessage).toBeInViewport()
|
||||
|
||||
// The quoted thread's card, once it has loaded: the profile circle beside the message carries
|
||||
// the same border classes, so the title is what says which of them is meant.
|
||||
const quotedThread = threadMessage.locator(".border.border-solid").filter({
|
||||
hasText: "Roadmap for Q3",
|
||||
})
|
||||
|
||||
await expect(quotedThread).toBeVisible()
|
||||
await expect(quotedThread).toContainText("Carol Chen")
|
||||
await expect(quotedThread).toContainText("Here is what we are planning for the next quarter.")
|
||||
|
||||
await quotedThread.getByText("Roadmap for Q3").click()
|
||||
|
||||
await page.waitForURL(next => next.pathname === `${spacePath(url)}/threads/${thread.id}`)
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Roadmap for Q3"})).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-066 see distinctive inline tokens", async ({seed, as}) => {
|
||||
const mention = `nostr:${npubEncode(users.bob.pubkey)}`
|
||||
const content = [
|
||||
"Shipping #nostr today :partyparrot:",
|
||||
`Run \`npm install\` first, then ping ${mention} or email team@example.test`,
|
||||
"```",
|
||||
"const answer = 42",
|
||||
"```",
|
||||
].join("\n")
|
||||
|
||||
let tokens!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.join(user.carol, "general")
|
||||
space.profile(user.bob, {name: "Bob Boberton"})
|
||||
space.profile(user.carol, {name: "Carol Chen"})
|
||||
|
||||
// Bob posts too, so his profile is loaded against this space before he is mentioned.
|
||||
space.message(user.bob, "general", "morning all", at(50, MINUTE))
|
||||
|
||||
// The shortcode's image comes off the message's own emoji tag, which is what a client writes
|
||||
// when someone picks a custom emoji.
|
||||
tokens = space.event(
|
||||
user.carol,
|
||||
makeEvent(MESSAGE, {
|
||||
content,
|
||||
tags: [
|
||||
["h", "general"],
|
||||
["emoji", "partyparrot", "https://images.test/partyparrot.png"],
|
||||
],
|
||||
created_at: at(40, MINUTE),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, roomPath(url, "general"))
|
||||
|
||||
const message = page.locator(`[data-event="${tokens.id}"]`)
|
||||
|
||||
await expect(message.getByText("#nostr", {exact: true})).toHaveClass(/link-content/)
|
||||
await expect(message.getByAltText(":partyparrot:")).toBeVisible()
|
||||
// An img contributes no text, so this is the "instead of the raw text" half of the story: with
|
||||
// no emoji tag to resolve, the shortcode is rendered verbatim.
|
||||
await expect(message.getByText(":partyparrot:")).toHaveCount(0)
|
||||
|
||||
const inlineCode = message.locator("code").filter({hasText: "npm install"})
|
||||
const blockCode = message.locator("code").filter({hasText: "const answer = 42"})
|
||||
|
||||
await expect(inlineCode).toBeVisible()
|
||||
await expect(inlineCode).not.toHaveClass(/(^|\s)block(\s|$)/)
|
||||
await expect(blockCode).toBeVisible()
|
||||
await expect(blockCode).toHaveClass(/(^|\s)block(\s|$)/)
|
||||
|
||||
await expect(message.getByRole("link", {name: "team@example.test"})).toHaveAttribute(
|
||||
"href",
|
||||
"mailto:team@example.test",
|
||||
)
|
||||
|
||||
await message.getByRole("button", {name: "@Bob Boberton"}).click()
|
||||
|
||||
// Dialog wraps its content in a second element of its own, so the inner one is the modal.
|
||||
const profile = page.locator(".dialog").last()
|
||||
|
||||
await expect(profile.getByText("Bob Boberton", {exact: true})).toBeVisible()
|
||||
await expect(profile.getByRole("button", {name: "View Full Profile"})).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-067 copy a shared invoice or token", async ({seed, as}) => {
|
||||
let invoice!: Seeded
|
||||
let token!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
|
||||
invoice = space.message(user.bob, "general", `lightning:${INVOICE}`, at(40, MINUTE))
|
||||
token = space.message(user.bob, "general", CASHU, at(35, MINUTE))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, roomPath(url, "general"), {
|
||||
context: {permissions: ["clipboard-read", "clipboard-write"]},
|
||||
})
|
||||
|
||||
const toast = page.getByRole("alert")
|
||||
const invoiceChip = page
|
||||
.locator(`[data-event="${invoice.id}"]`)
|
||||
.getByRole("button", {name: INVOICE.slice(0, 16)})
|
||||
|
||||
await expect(invoiceChip).toBeVisible()
|
||||
await expect(page.getByText(INVOICE)).toHaveCount(0)
|
||||
|
||||
await invoiceChip.click()
|
||||
|
||||
await expect(toast).toContainText("Copied to clipboard!")
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(INVOICE)
|
||||
|
||||
// Both copies raise the same toast, so this one is dismissed rather than waited out — otherwise
|
||||
// the second assertion would pass on the first toast.
|
||||
await toast.getByRole("button").click()
|
||||
|
||||
await expect(toast).toHaveCount(0)
|
||||
|
||||
const tokenChip = page
|
||||
.locator(`[data-event="${token.id}"]`)
|
||||
.getByRole("button", {name: CASHU.slice(0, 16)})
|
||||
|
||||
await expect(tokenChip).toBeVisible()
|
||||
await expect(page.getByText(CASHU)).toHaveCount(0)
|
||||
|
||||
await tokenChip.click()
|
||||
|
||||
await expect(toast).toContainText("Copied to clipboard!")
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(CASHU)
|
||||
})
|
||||
608
e2e/specs/delivery.spec.ts
Normal file
608
e2e/specs/delivery.spec.ts
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
import {HOUR} from "@welshman/lib"
|
||||
import {LONG_FORM, MESSAGE} from "@welshman/util"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {ClientMessageType} from "@welshman/net"
|
||||
import {Article, Comment, MessagingRelayList, Reaction, RelayList} from "@welshman/domain"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
import {expect, getTranscript, mockBlossom, roomPath, spacePath, test, users} from "../harness"
|
||||
import type {SeededSpace, TestUser} from "../harness"
|
||||
|
||||
// The path the app builds for a conversation — see makeChatId in src/app/chats.ts.
|
||||
const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
|
||||
|
||||
// makeSpacePath percent-encodes each segment it is given, and an address is full of colons.
|
||||
const articlePath = (url: string, address: string) =>
|
||||
`${spacePath(url)}/articles/${encodeURIComponent(address)}`
|
||||
|
||||
// Where an upload lands: the space's own origin is probed first and blossom is off in every
|
||||
// tenant's toml, so what is left is VITE_DEFAULT_BLOSSOM_SERVERS.
|
||||
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
|
||||
|
||||
// A one pixel gif. Gif rather than png because compressFileForUpload passes it through untouched
|
||||
// rather than re-encoding it through a canvas.
|
||||
const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64")
|
||||
|
||||
// Outbox routing resolves everything about a person through their relay list, so a person here is
|
||||
// a membership, a profile and a kind-10002.
|
||||
const seedPerson = (space: SeededSpace, user: TestUser, name: string) => {
|
||||
space.profile(user, {name})
|
||||
space.event(user, () =>
|
||||
space
|
||||
.kind(RelayList)
|
||||
.writer()
|
||||
.setReadUrls([space.url])
|
||||
.setWriteUrls([space.url])
|
||||
.renderTemplate(),
|
||||
)
|
||||
}
|
||||
|
||||
// The kind-10050 that says where someone's direct messages go. It is read lazily so that a list
|
||||
// can name a relay whose url only exists once seeding has drained.
|
||||
const enableDms = (space: SeededSpace, user: TestUser, getUrls: () => string[]) =>
|
||||
space.event(user, () =>
|
||||
space.kind(MessagingRelayList).writer().setUrls(getUrls()).renderTemplate(),
|
||||
)
|
||||
|
||||
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
|
||||
|
||||
// The send button carries the shortcut it advertises, which differs by platform.
|
||||
const sendButton = (page: Page) => page.locator("button[data-tip$='enter to send']")
|
||||
|
||||
const timeline = (page: Page) => page.locator(".room__content")
|
||||
|
||||
const message = (page: Page, text: string) => page.locator(".room__item").filter({hasText: text})
|
||||
|
||||
const bubble = (page: Page, text: string) => page.locator(".chat-bubble").filter({hasText: text})
|
||||
|
||||
// One toast at a time — src/app/toast.ts holds a single writable — so this is the toast.
|
||||
const toast = (page: Page) => page.getByRole("alert")
|
||||
|
||||
// A tippy is appended to the layout's own target rather than beside its trigger, and it keeps its
|
||||
// content mounted after it hides, so the visible card there is the popover that was just opened.
|
||||
const detail = (page: Page) => page.locator(".tippy-target .card").filter({visible: true})
|
||||
|
||||
// EventActions renders zap, emoji and menu into one join, in that order, and every one of them is
|
||||
// an icon with no accessible name.
|
||||
const menuOf = (scope: Locator) => scope.locator(".join").getByRole("button").last()
|
||||
|
||||
// A comment, an article and a thread post are all the same feature card, named by their text.
|
||||
const noteCard = (page: Page, text: string) =>
|
||||
page.locator(".card.z-feature").filter({hasText: text})
|
||||
|
||||
// A modal is mounted alongside the page it covers, so a page's own "Write" and the composer's
|
||||
// submit are both in the dom at once.
|
||||
const modal = (page: Page, title: string) =>
|
||||
page.locator("form").filter({has: page.getByRole("heading", {name: title})})
|
||||
|
||||
const editorOf = (scope: Locator) => scope.locator(".note-editor [contenteditable=true]")
|
||||
|
||||
// The composer stays disabled until every recipient's messaging relays have been read, so waiting
|
||||
// on the send button is part of sending rather than a wait for a wait's sake.
|
||||
const send = async (page: Page, content: string) => {
|
||||
await expect(sendButton(page)).toBeEnabled()
|
||||
await composer(page).click()
|
||||
await composer(page).pressSequentially(content)
|
||||
await composer(page).press("Enter")
|
||||
}
|
||||
|
||||
// Every frame this page put on the wire carrying an event, with the relay it went to.
|
||||
const published = (page: Page): {url: string; event: TrustedEvent}[] =>
|
||||
getTranscript(page.context())
|
||||
.filter(
|
||||
({direction, message}) => direction === "toRelay" && message[0] === ClientMessageType.Event,
|
||||
)
|
||||
.map(({url, message}) => ({url, event: message[1]}))
|
||||
|
||||
const publishedEvents = (page: Page, kind: number) =>
|
||||
published(page)
|
||||
.filter(({event}) => event.kind === kind)
|
||||
.map(({event}) => event)
|
||||
|
||||
// Which relays a given event was sent to, oldest first — one entry per attempt.
|
||||
const publishedTo = (page: Page, id: string) =>
|
||||
published(page)
|
||||
.filter(({event}) => event.id === id)
|
||||
.map(({url}) => url)
|
||||
|
||||
const writeArticle = async (page: Page, title: string, body: string) => {
|
||||
await page.getByRole("button", {name: "Write"}).click()
|
||||
|
||||
const composer = modal(page, "Write an Article")
|
||||
|
||||
await composer.getByPlaceholder("What is this article about?").fill(title)
|
||||
await editorOf(composer).pressSequentially(body)
|
||||
await composer.getByRole("button", {name: "Publish Article"}).click()
|
||||
}
|
||||
|
||||
const writeComment = async (page: Page, body: string) => {
|
||||
await page.getByRole("button", {name: "Add a comment"}).click()
|
||||
|
||||
const composer = page.locator("form").filter({has: page.locator(".note-editor")})
|
||||
|
||||
await editorOf(composer).pressSequentially(body)
|
||||
await composer.getByRole("button", {name: "Comment"}).click()
|
||||
}
|
||||
|
||||
// The send delay is a user setting rather than a page's own state, so it is set the way a person
|
||||
// sets it. Reading it back off a freshly loaded page is what says the client has taken it up: the
|
||||
// slider is initialised from the settings store on mount.
|
||||
const setSendDelay = async (page: Page, seconds: number) => {
|
||||
const slider = page.locator("input[type=range]")
|
||||
|
||||
await page.goto("/settings/content")
|
||||
await slider.fill(String(seconds * 1000))
|
||||
|
||||
await expect(page.getByText(`Delay sending chat messages for ${seconds} seconds.`)).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(toast(page)).toContainText("Your settings have been saved!")
|
||||
|
||||
await page.goto("/settings/content")
|
||||
await expect(slider).toHaveValue(String(seconds * 1000))
|
||||
}
|
||||
|
||||
const chooseFile = async (page: Page, button: Locator, name: string) => {
|
||||
const chooser = page.waitForEvent("filechooser")
|
||||
|
||||
await button.click()
|
||||
await (await chooser).setFiles({name, mimeType: "image/gif", buffer: GIF})
|
||||
}
|
||||
|
||||
test("US-068 watch a delayed send, and cancel it", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
|
||||
for (const person of [user.alice, user.bob]) {
|
||||
seedPerson(space, person, person.name)
|
||||
enableDms(space, person, () => [space.url])
|
||||
}
|
||||
|
||||
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const path = roomPath(url, "general")
|
||||
|
||||
const alice = await as(users.alice, path)
|
||||
const bob = await as(users.bob, path)
|
||||
|
||||
await expect(timeline(alice).getByText("morning all")).toBeVisible()
|
||||
await expect(timeline(bob).getByText("morning all")).toBeVisible()
|
||||
|
||||
// The delay defaults to zero, so this one leaves as soon as it is written and says nothing.
|
||||
await send(alice, "no delay here")
|
||||
|
||||
await expect(message(alice, "no delay here")).toBeVisible()
|
||||
await expect(message(bob, "no delay here")).toBeVisible()
|
||||
await expect(toast(alice)).toHaveCount(0)
|
||||
|
||||
await setSendDelay(alice, 5)
|
||||
await alice.goto(path)
|
||||
|
||||
await expect(timeline(alice).getByText("morning all")).toBeVisible()
|
||||
|
||||
await send(alice, "second thoughts")
|
||||
|
||||
// The bubble is hers straight away; the delay is only about when it leaves.
|
||||
await expect(message(alice, "second thoughts")).toBeVisible()
|
||||
await expect(toast(alice)).toContainText("Sending...")
|
||||
await expect(toast(alice).getByRole("button", {name: "Cancel"})).toBeVisible()
|
||||
|
||||
await expect(toast(alice)).toContainText("Message sent!")
|
||||
await expect(message(bob, "second thoughts")).toBeVisible()
|
||||
await expect(toast(alice)).toHaveCount(0)
|
||||
|
||||
await send(alice, "wrong room, sorry")
|
||||
|
||||
await expect(message(alice, "wrong room, sorry")).toBeVisible()
|
||||
|
||||
await toast(alice).getByRole("button", {name: "Cancel"}).click()
|
||||
|
||||
await expect(message(alice, "wrong room, sorry")).toHaveCount(0)
|
||||
await expect(toast(alice)).toHaveCount(0)
|
||||
|
||||
// Sent after the cancelled one and delayed by as long, so bob having this means the window the
|
||||
// cancelled one would have left in has been and gone.
|
||||
await send(alice, "still here")
|
||||
|
||||
await expect(message(bob, "still here")).toBeVisible()
|
||||
await expect(message(bob, "wrong room, sorry")).toHaveCount(0)
|
||||
|
||||
// The same cancel in a conversation.
|
||||
await alice.goto(chatPath(users.bob.pubkey))
|
||||
await bob.goto(chatPath(users.alice.pubkey))
|
||||
|
||||
await send(alice, "ignore this one")
|
||||
|
||||
await expect(bubble(alice, "ignore this one")).toBeVisible()
|
||||
|
||||
await toast(alice).getByRole("button", {name: "Cancel"}).click()
|
||||
|
||||
await send(alice, "actually, hi")
|
||||
|
||||
await expect(bubble(bob, "actually, hi")).toBeVisible()
|
||||
await expect(bubble(bob, "ignore this one")).toHaveCount(0)
|
||||
await expect(bubble(alice, "ignore this one")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-069 see why a message failed to deliver", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
// Alice belongs to both relays; bob deliberately belongs only to the first.
|
||||
other.join(user.alice)
|
||||
|
||||
for (const person of [user.alice, user.bob]) {
|
||||
seedPerson(space, person, person.name)
|
||||
}
|
||||
|
||||
enableDms(space, user.alice, () => [space.url])
|
||||
// Bob's client says his messages go to both relays, but he is not a member of the second, so a
|
||||
// wrap addressed to him is stored by one and refused by the other.
|
||||
enableDms(space, user.bob, () => [space.url, other.url])
|
||||
|
||||
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
|
||||
// A room the relay never created, which is where a link to a room that has since been deleted
|
||||
// lands. Everything else about the space still works, so only this one publish is refused.
|
||||
const ghost = roomPath(url, "archive")
|
||||
|
||||
const alice = await as(users.alice, ghost)
|
||||
const bob = await as(users.bob, ghost)
|
||||
|
||||
await send(alice, "anyone here?")
|
||||
|
||||
const failure = message(alice, "anyone here?").getByText("Failed to send!")
|
||||
|
||||
await expect(failure).toBeVisible()
|
||||
|
||||
// The text is still hers to see, and the publish is finished and was refused, so there is
|
||||
// nothing left for bob to receive.
|
||||
await expect(message(alice, "anyone here?")).toContainText("anyone here?")
|
||||
await expect(message(bob, "anyone here?")).toHaveCount(0)
|
||||
|
||||
await failure.click()
|
||||
|
||||
await expect(detail(alice)).toContainText("Failed to send!")
|
||||
await expect(detail(alice)).toContainText("space.test")
|
||||
await expect(detail(alice)).toContainText("invalid: group not found.")
|
||||
await expect(detail(alice).getByRole("button", {name: "Retry"})).toBeVisible()
|
||||
|
||||
// A conversation with someone whose two messaging relays do not both take his mail.
|
||||
await alice.goto(chatPath(users.bob.pubkey))
|
||||
|
||||
await send(alice, "half a message")
|
||||
|
||||
const partial = alice.getByText("Failed to send!")
|
||||
|
||||
await expect(partial).toBeVisible()
|
||||
|
||||
await partial.click()
|
||||
|
||||
await expect(detail(alice)).toContainText("Partial delivery 1/2 relays")
|
||||
await expect(detail(alice)).toContainText("space.test")
|
||||
await expect(detail(alice)).toContainText("other.test")
|
||||
await expect(detail(alice)).toContainText("restricted:")
|
||||
await expect(detail(alice).locator(".text-success")).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("US-070 retry a failed relay", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
seedPerson(space, user.alice, "Alice Anderson")
|
||||
seedPerson(space, user.bob, "Bob Barker")
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const ghost = roomPath(url, "archive")
|
||||
const alice = await as(users.alice, ghost)
|
||||
|
||||
// Opened now rather than once the room exists: seeding again below replaces the scenario, and a
|
||||
// page opened from that one carries no room list — which is the only thing telling authPolicy it
|
||||
// may answer a members-only relay's challenge, so bob would never get to read anything.
|
||||
const bob = await as(users.bob, roomPath(url, "general"))
|
||||
|
||||
// A delay is what makes the retry's own "Sending..." a state rather than an instant, so this
|
||||
// watches a retry the way the person who asked for it does.
|
||||
await setSendDelay(alice, 5)
|
||||
await alice.goto(ghost)
|
||||
|
||||
await send(alice, "is anyone here?")
|
||||
|
||||
const failure = message(alice, "is anyone here?").getByText("Failed to send!")
|
||||
|
||||
await expect(failure).toBeVisible()
|
||||
|
||||
await failure.click()
|
||||
await detail(alice).getByRole("button", {name: "Retry"}).click()
|
||||
|
||||
await expect(toast(alice)).toContainText("Sending...")
|
||||
|
||||
// The room still does not exist, so this attempt is refused too: the toast goes without ever
|
||||
// having said the message was sent, and the message is still marked failed.
|
||||
await expect(toast(alice)).toHaveCount(0)
|
||||
await expect(failure).toBeVisible()
|
||||
|
||||
// The admin creates the room the message was addressed to. Seeding again is the only way the
|
||||
// relay changes its mind about something it has already refused.
|
||||
await seed(({relay}) => {
|
||||
relay("space").room("archive", {name: "Archive"})
|
||||
})
|
||||
|
||||
await failure.click()
|
||||
await detail(alice).getByRole("button", {name: "Retry"}).click()
|
||||
|
||||
await expect(toast(alice)).toContainText("Sending...")
|
||||
await expect(toast(alice)).toContainText("Message sent!")
|
||||
|
||||
const [sent] = publishedEvents(alice, MESSAGE)
|
||||
|
||||
// One attempt and two retries, every one of them to the relay that failed and to nothing else.
|
||||
expect(publishedTo(alice, sent.id)).toEqual([url, url, url])
|
||||
|
||||
await bob.goto(roomPath(url, "archive"))
|
||||
|
||||
await expect(message(bob, "is anyone here?")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-071 content posts show delivery status in place", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
seedPerson(space, user.alice, "Alice Anderson")
|
||||
seedPerson(space, user.bob, "Bob Barker")
|
||||
|
||||
// A second space in her list, so that opening it prompts her for nothing.
|
||||
other.join(user.alice)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const quiet = scenario.space("other").url
|
||||
|
||||
// A space whose relay this browser will not open a socket to, which is what a post that sits
|
||||
// unconfirmed looks like from the inside: it is sent, and nothing comes back.
|
||||
const alice = await as(users.alice, `${spacePath(url)}/articles`, {
|
||||
env: {VITE_BLOCKED_RELAYS: quiet},
|
||||
})
|
||||
|
||||
await writeArticle(alice, "Signals in the Noise", "Everything worth hearing is quiet.")
|
||||
|
||||
const article = alice.getByRole("link", {name: /Written by/}).filter({hasText: "Signals"})
|
||||
|
||||
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
|
||||
|
||||
// The row's own actions first: a status this row does not have says nothing until the row it
|
||||
// would sit in is on screen.
|
||||
await expect(menuOf(article)).toBeVisible()
|
||||
await expect(article.getByText("Sending...")).toHaveCount(0)
|
||||
await expect(article.getByText("Failed to send!")).toHaveCount(0)
|
||||
await expect(toast(alice)).toHaveCount(0)
|
||||
|
||||
await article.getByText("Signals in the Noise").click()
|
||||
await writeComment(alice, "Worth saying twice.")
|
||||
|
||||
const comment = noteCard(alice, "Worth saying twice.")
|
||||
|
||||
await expect(menuOf(comment)).toBeVisible()
|
||||
await expect(comment.getByText("Sending...")).toHaveCount(0)
|
||||
await expect(comment.getByText("Failed to send!")).toHaveCount(0)
|
||||
await expect(toast(alice)).toHaveCount(0)
|
||||
|
||||
// The same post into the space whose relay says nothing back.
|
||||
await alice.goto(`${spacePath(quiet)}/articles`)
|
||||
await writeArticle(alice, "Into the Void", "Nobody is listening.")
|
||||
|
||||
const stuck = alice.getByRole("link", {name: /Written by/}).filter({hasText: "Into the Void"})
|
||||
|
||||
await expect(stuck.getByText("Sending...")).toBeVisible()
|
||||
await expect(stuck.getByRole("button", {name: "Cancel"})).toBeVisible()
|
||||
|
||||
// The composer closes itself once publishing gives up, and the row it left behind says why.
|
||||
await expect(stuck.getByText("Failed to send!")).toBeVisible()
|
||||
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
|
||||
|
||||
await stuck.getByText("Failed to send!").click()
|
||||
|
||||
await expect(detail(alice)).toContainText("Failed to send!")
|
||||
await expect(detail(alice)).toContainText("other.test")
|
||||
await expect(detail(alice)).toContainText("request timed out.")
|
||||
|
||||
await stuck.getByText("Into the Void").click()
|
||||
await writeComment(alice, "A footnote nobody asked for.")
|
||||
|
||||
const footnote = noteCard(alice, "A footnote nobody asked for.")
|
||||
|
||||
await expect(footnote.getByText("Sending...")).toBeVisible()
|
||||
await expect(footnote.getByRole("button", {name: "Cancel"})).toBeVisible()
|
||||
|
||||
await footnote.getByRole("button", {name: "Cancel"}).click()
|
||||
|
||||
await expect(noteCard(alice, "A footnote nobody asked for.")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
seedPerson(space, user.alice, "Alice Anderson")
|
||||
seedPerson(space, user.bob, "Bob Barker")
|
||||
other.join(user.alice)
|
||||
|
||||
const article = space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Article)
|
||||
.writer()
|
||||
.setIdentifier("tending-the-garden")
|
||||
.setTitle("Tending the Garden")
|
||||
.setPublishedAt(at(4, HOUR))
|
||||
.setContent("Gardens are worth the trouble.")
|
||||
.renderTemplate(),
|
||||
at(4, HOUR),
|
||||
)
|
||||
|
||||
const comment = space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Comment)
|
||||
.writer()
|
||||
.setRootFromEvent(article.event)
|
||||
.setParentFromEvent(article.event)
|
||||
.setContent("A note about the soil chapter.")
|
||||
.renderTemplate(),
|
||||
at(3, HOUR),
|
||||
)
|
||||
|
||||
// Somebody else's reaction, so the summary the pill is meant to replace has something in it.
|
||||
space.event(
|
||||
user.bob,
|
||||
() => space.kind(Reaction).writer().setEvent(comment.event).setContent("🎉").renderTemplate(),
|
||||
at(2, HOUR),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const quiet = scenario.space("other").url
|
||||
|
||||
const alice = await as(users.alice, `${spacePath(quiet)}/articles`, {
|
||||
env: {VITE_BLOCKED_RELAYS: quiet},
|
||||
})
|
||||
|
||||
// A post whose relay never answered, deleted while it is still marked failed.
|
||||
await writeArticle(alice, "Into the Void", "Nobody is listening.")
|
||||
|
||||
const stuck = alice.getByRole("link", {name: /Written by/}).filter({hasText: "Into the Void"})
|
||||
|
||||
await expect(stuck.getByText("Failed to send!")).toBeVisible()
|
||||
|
||||
// The composer closes itself once publishing gives up, and the row's menu is underneath it.
|
||||
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
|
||||
|
||||
await menuOf(stuck).click()
|
||||
await alice.getByRole("button", {name: "Delete Article"}).click()
|
||||
await alice.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(stuck.getByText("Deleted", {exact: true})).toBeVisible()
|
||||
await expect(stuck.getByText("Failed to send!")).toHaveCount(0)
|
||||
|
||||
await alice.goto(articlePath(url, `${LONG_FORM}:${users.alice.pubkey}:tending-the-garden`))
|
||||
|
||||
const comment = noteCard(alice, "A note about the soil chapter.")
|
||||
const article = noteCard(alice, "Gardens are worth the trouble.")
|
||||
|
||||
await expect(comment).toBeVisible()
|
||||
await expect(comment.getByRole("button", {name: /🎉/})).toBeVisible()
|
||||
|
||||
await menuOf(comment).click()
|
||||
await alice.getByRole("button", {name: "Delete Comment"}).click()
|
||||
await alice.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(comment.getByText("Deleted", {exact: true})).toBeVisible()
|
||||
|
||||
await menuOf(article).click()
|
||||
await alice.getByRole("button", {name: "Delete Article"}).click()
|
||||
await alice.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(article.getByText("Deleted", {exact: true})).toBeVisible()
|
||||
|
||||
// The pill stands in place of what the row offered before it, rather than beside it.
|
||||
await expect(comment.getByRole("button", {name: /🎉/})).toHaveCount(0)
|
||||
await expect(comment.locator(".join")).toHaveCount(0)
|
||||
await expect(article.locator(".join")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-073 a multi-part message reports one status", async ({seed, as}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
|
||||
for (const person of [user.alice, user.bob, user.carol]) {
|
||||
space.join(person, "general")
|
||||
seedPerson(space, person, person.name)
|
||||
}
|
||||
|
||||
enableDms(space, user.alice, () => [space.url])
|
||||
enableDms(space, user.bob, () => [space.url])
|
||||
// Carol's client names a relay she does not belong to, so every part of a message to her is
|
||||
// stored by one of her two relays and refused by the other.
|
||||
enableDms(space, user.carol, () => [space.url, other.url])
|
||||
})
|
||||
|
||||
const alice = await as(users.alice, chatPath(users.bob.pubkey))
|
||||
const bob = await as(users.bob, chatPath(users.alice.pubkey))
|
||||
|
||||
await mockBlossom(alice.context(), {server: BLOSSOM_ORIGIN})
|
||||
await mockBlossom(bob.context(), {server: BLOSSOM_ORIGIN})
|
||||
|
||||
await expect(sendButton(alice)).toBeEnabled()
|
||||
|
||||
await composer(alice).click()
|
||||
await composer(alice).pressSequentially("here is the harbour")
|
||||
await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), "harbour.gif")
|
||||
|
||||
// The editor names the file the moment it is attached, so the name alone does not mean the
|
||||
// upload is done — and a submit while it is still running is dropped on the floor.
|
||||
await expect(composer(alice)).toContainText("harbour.gif")
|
||||
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
|
||||
await expect(sendButton(alice)).toBeEnabled()
|
||||
|
||||
await composer(alice).press("Enter")
|
||||
|
||||
// Two bubbles, one status for the pair of them.
|
||||
await expect(toast(alice)).toHaveCount(1)
|
||||
await expect(toast(alice)).toContainText("Sending...")
|
||||
await expect(alice.locator(".chat-bubble")).toHaveCount(2)
|
||||
await expect(bubble(alice, "here is the harbour")).toBeVisible()
|
||||
|
||||
// Which only becomes a success once both parts have landed everywhere they were sent.
|
||||
await expect(toast(alice)).toContainText("Message sent!")
|
||||
await expect(toast(alice)).toHaveCount(1)
|
||||
await expect(bob.locator(".chat-bubble")).toHaveCount(2)
|
||||
await expect(toast(alice)).toHaveCount(0)
|
||||
|
||||
// The same message to someone one of whose relays refuses it: each part says so for itself.
|
||||
await alice.goto(chatPath(users.carol.pubkey))
|
||||
|
||||
await expect(sendButton(alice)).toBeEnabled()
|
||||
|
||||
await composer(alice).click()
|
||||
await composer(alice).pressSequentially("and one for you")
|
||||
await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), "harbour.gif")
|
||||
|
||||
await expect(composer(alice)).toContainText("harbour.gif")
|
||||
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
|
||||
await expect(sendButton(alice)).toBeEnabled()
|
||||
|
||||
await composer(alice).press("Enter")
|
||||
|
||||
await expect(alice.locator(".chat-bubble")).toHaveCount(2)
|
||||
await expect(alice.getByText("Failed to send!")).toHaveCount(2)
|
||||
})
|
||||
613
e2e/specs/dms.spec.ts
Normal file
613
e2e/specs/dms.spec.ts
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
import {npubEncode} from "nostr-tools/nip19"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
import {DAY, HOUR, MINUTE} from "@welshman/lib"
|
||||
import {MessagingRelayList, RelayList} from "@welshman/domain"
|
||||
import {expect, makeTestUser, test, users} from "../harness"
|
||||
import type {SeededRumor, SeededSpace, TestUser} from "../harness"
|
||||
|
||||
// The path the app builds for a conversation: the other participants' pubkeys, sorted and joined
|
||||
// with commas — see makeChatId in src/app/chats.ts.
|
||||
const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
|
||||
|
||||
const profilePath = (pubkey: string) => `/people/${npubEncode(pubkey)}`
|
||||
|
||||
// A path as a pattern, for a url that carries a modal's hash alongside it.
|
||||
const pathPattern = (path: string) => new RegExp(path.replace(/[.?+*()[\]]/g, "\\$&"))
|
||||
|
||||
// Everything about a person is resolved through their relay list — their profile, their messaging
|
||||
// relays, the wraps addressed to them — and a scenario with no fallbacks resolves an author with
|
||||
// no kind-10002 to no relays at all. So a person here is a membership, a profile and a relay list.
|
||||
// Membership is also what lets a gift wrap addressed to them be stored: zooid authorizes a
|
||||
// kind-1059 by the member named in its p tag.
|
||||
const seedPerson = (space: SeededSpace, user: TestUser, name: string) => {
|
||||
space.join(user)
|
||||
space.profile(user, {name})
|
||||
space.event(user, () =>
|
||||
space
|
||||
.kind(RelayList)
|
||||
.writer()
|
||||
.setReadUrls([space.url])
|
||||
.setWriteUrls([space.url])
|
||||
.renderTemplate(),
|
||||
)
|
||||
}
|
||||
|
||||
// The kind-10050 ChatEnable publishes when someone accepts "Enable direct messaging?". Having one
|
||||
// is what makes a person reachable, so a story about messaging being off is a person without one.
|
||||
const enableDms = (space: SeededSpace, user: TestUser) =>
|
||||
space.event(user, () =>
|
||||
space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(),
|
||||
)
|
||||
|
||||
const chatList = (page: Page) => page.locator(".secondary-nav .overflow-auto")
|
||||
|
||||
// One ChatItem is one button; nothing inside it is one.
|
||||
const chatItems = (page: Page) => chatList(page).locator("button")
|
||||
|
||||
const chatFilter = (page: Page) => page.locator(".secondary-nav input[type='text']")
|
||||
|
||||
// ChatItem's unread mark is a bare dot with no text of its own.
|
||||
const unreadDots = (scope: Locator) => scope.locator(".rounded-full.bg-primary")
|
||||
|
||||
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
|
||||
|
||||
// A modal's body is the only scroll container carrying its title.
|
||||
const modalBody = (page: Page, title: string) =>
|
||||
page
|
||||
.locator(".scroll-container")
|
||||
.filter({has: page.getByRole("heading", {name: title, exact: true})})
|
||||
|
||||
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
|
||||
|
||||
// The send button is named by the shortcut it advertises, which differs by platform.
|
||||
const sendButton = (page: Page) => page.locator('.room__compose [data-tip$="enter to send"]')
|
||||
|
||||
// Both things the composer puts above itself — the message being replied to and the editing
|
||||
// indicator — are the same bordered strip.
|
||||
const composePreview = (page: Page) => page.locator(".room__compose .border-l-2")
|
||||
|
||||
const bubble = (page: Page, text: string) => page.locator(".chat-bubble").filter({hasText: text})
|
||||
|
||||
const message = (page: Page, id: string) => page.locator(`[data-event="${id}"]`)
|
||||
|
||||
const enablePrompt = (page: Page) => page.getByRole("heading", {name: "Enable direct messaging?"})
|
||||
|
||||
// The composer stays disabled until every recipient's messaging relays have been read, so waiting
|
||||
// on it is part of sending rather than a wait for a wait's sake.
|
||||
const send = async (page: Page, content: string) => {
|
||||
await expect(sendButton(page)).toBeEnabled()
|
||||
await composer(page).pressSequentially(content)
|
||||
await composer(page).press("Enter")
|
||||
}
|
||||
|
||||
// A suggestion carries the pubkey it selects as its label, so the name is what gets typed and the
|
||||
// pubkey is what identifies the row that comes back.
|
||||
const startChat = async (page: Page, people: {term: string; user: TestUser}[]) => {
|
||||
await page.getByRole("button", {name: "Start New Chat"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Start a Chat"})).toBeVisible()
|
||||
|
||||
for (const {term, user} of people) {
|
||||
const suggestion = page.locator(`.tiptap-suggestions__item[aria-label="${user.pubkey}"]`)
|
||||
|
||||
// Typed a keystroke at a time rather than filled: the suggestion list is rebuilt off the term
|
||||
// changing, so a term that arrives all at once is matched against whatever the profile search
|
||||
// held at that instant and never again.
|
||||
await page.getByPlaceholder("Search for profiles...").pressSequentially(term, {delay: 150})
|
||||
|
||||
await expect(suggestion).toContainText(term)
|
||||
|
||||
await suggestion.click()
|
||||
}
|
||||
|
||||
await page.getByRole("button", {name: "Create Chat"}).click()
|
||||
}
|
||||
|
||||
// A message's actions live behind a hover popover on a pointer device and behind a modal of named
|
||||
// buttons on a touch one, so a spec that wants to name them opens a touch context.
|
||||
const openMessageMenu = (page: Page, id: string) =>
|
||||
message(page, id).locator(".chat-bubble").click()
|
||||
|
||||
// A day divider and a chat item's stamp, formatted by the browser rather than by node, so the
|
||||
// locale and the timezone are the ones the app rendered with. The options mirror dateFormatter and
|
||||
// dateTimeFormatter in @welshman/lib.
|
||||
const dayLabel = (page: Page, seconds: number) =>
|
||||
page.evaluate(
|
||||
ts =>
|
||||
new Intl.DateTimeFormat(undefined, {year: "numeric", month: "long", day: "numeric"}).format(
|
||||
new Date(ts * 1000),
|
||||
),
|
||||
seconds,
|
||||
)
|
||||
|
||||
const stampLabel = (page: Page, seconds: number) =>
|
||||
page.evaluate(
|
||||
ts =>
|
||||
new Intl.DateTimeFormat(undefined, {dateStyle: "short", timeStyle: "short"}).format(
|
||||
new Date(ts * 1000),
|
||||
),
|
||||
seconds,
|
||||
)
|
||||
|
||||
const topOf = async (locator: Locator) => {
|
||||
const box = await locator.boundingBox()
|
||||
|
||||
expect(box).toBeTruthy()
|
||||
|
||||
return box!.y
|
||||
}
|
||||
|
||||
test("US-029 start a one-on-one chat", async ({seed, as}) => {
|
||||
await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
seedPerson(space, user.alice, "Alice Anchor")
|
||||
seedPerson(space, user.bob, "Bob Barnacle")
|
||||
seedPerson(space, user.carol, "Carol Cutter")
|
||||
enableDms(space, user.alice)
|
||||
enableDms(space, user.bob)
|
||||
|
||||
// A conversation the three of them are already in. It puts bob's profile in her client before
|
||||
// she goes looking for him, without being the one-on-one she is about to start.
|
||||
space.dm(user.carol, [user.alice, user.bob], "welcome aboard, both of you", at(3, HOUR))
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/chat")
|
||||
|
||||
// A group conversation's item in the list names only the first of its participants, so his name
|
||||
// rather than his npub in its header is the client saying it has his profile, which is what the
|
||||
// search she is about to type into is built from.
|
||||
await chatItems(page).filter({hasText: "welcome aboard"}).click()
|
||||
|
||||
await expect(pageBar(page)).toContainText("Bob Barnacle")
|
||||
|
||||
await startChat(page, [{term: "Barnacle", user: users.bob}])
|
||||
|
||||
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
|
||||
await expect(composer(page)).toBeVisible()
|
||||
await expect(sendButton(page)).toBeEnabled()
|
||||
|
||||
await send(page, "hi bob")
|
||||
|
||||
await expect(bubble(page, "hi bob")).toBeVisible()
|
||||
|
||||
// The conversation she just started is the one at the top of her list, under his name
|
||||
await expect(chatItems(page).first()).toContainText("Bob Barnacle")
|
||||
await expect(chatItems(page).first()).toContainText("hi bob")
|
||||
|
||||
// His profile leads back into the same conversation
|
||||
await pageBar(page).getByRole("button", {name: "Bob Barnacle"}).click()
|
||||
await page.getByRole("button", {name: "View Full Profile"}).click()
|
||||
|
||||
await expect(page).toHaveURL(pathPattern(profilePath(users.bob.pubkey)))
|
||||
|
||||
await page.getByRole("button", {name: "Message", exact: true}).click()
|
||||
|
||||
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
|
||||
await expect(bubble(page, "hi bob")).toBeVisible()
|
||||
|
||||
// Her own profile offers no way to message herself
|
||||
await page.goto(profilePath(users.alice.pubkey))
|
||||
|
||||
await expect(page.getByRole("button", {name: "Edit profile"})).toBeVisible()
|
||||
await expect(page.getByRole("button", {name: "Message", exact: true})).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-030 start a group chat", async ({seed, as}) => {
|
||||
await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
seedPerson(space, user.alice, "Alice Anchor")
|
||||
seedPerson(space, user.bob, "Bob Barnacle")
|
||||
seedPerson(space, user.carol, "Carol Cutter")
|
||||
enableDms(space, user.alice)
|
||||
enableDms(space, user.bob)
|
||||
enableDms(space, user.carol)
|
||||
|
||||
// One conversation with each of them, so both profiles are in her client before she searches.
|
||||
space.dm(user.bob, [user.alice], "just us two", at(4, HOUR))
|
||||
space.dm(user.carol, [user.alice], "hello from carol", at(3, HOUR))
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/chat")
|
||||
|
||||
// Their names rather than their npubs is the client saying it has both profiles, which is what
|
||||
// the search she is about to type into is built from.
|
||||
await expect(chatItems(page).filter({hasText: "just us two"})).toContainText("Bob Barnacle")
|
||||
await expect(chatItems(page).filter({hasText: "hello from carol"})).toContainText("Carol Cutter")
|
||||
|
||||
await startChat(page, [
|
||||
{term: "Barnacle", user: users.bob},
|
||||
{term: "Cutter", user: users.carol},
|
||||
])
|
||||
|
||||
// One conversation holding both of them, rather than one apiece
|
||||
await expect(page).toHaveURL(pathPattern(chatPath(users.bob.pubkey, users.carol.pubkey)))
|
||||
await expect(sendButton(page)).toBeEnabled()
|
||||
|
||||
// Labeled with both of them rather than with one
|
||||
await expect(pageBar(page)).toContainText("Bob Barnacle")
|
||||
await expect(pageBar(page)).toContainText("Carol Cutter")
|
||||
|
||||
await pageBar(page)
|
||||
.getByRole("button", {name: /Bob Barnacle/})
|
||||
.click()
|
||||
|
||||
const members = modalBody(page, "People in this conversation")
|
||||
|
||||
await expect(members.getByText("Bob Barnacle")).toBeVisible()
|
||||
await expect(members.getByText("Carol Cutter")).toBeVisible()
|
||||
|
||||
await members.getByRole("button", {name: "Carol Cutter"}).click()
|
||||
|
||||
await expect(members).toHaveCount(0)
|
||||
await expect(page.getByRole("button", {name: "View Full Profile"})).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "View Full Profile"}).click()
|
||||
|
||||
await expect(page).toHaveURL(pathPattern(profilePath(users.carol.pubkey)))
|
||||
})
|
||||
|
||||
test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
// Neither of them has ever published a kind-10050, which is what messaging being off is.
|
||||
seedPerson(space, user.alice, "Alice Anchor")
|
||||
seedPerson(space, user.bob, "Bob Barnacle")
|
||||
})
|
||||
|
||||
const alice = await as(users.alice, profilePath(users.bob.pubkey))
|
||||
|
||||
await alice.getByRole("button", {name: "Message", exact: true}).click()
|
||||
|
||||
// The prompt stands where the conversation she asked for would have been
|
||||
await expect(enablePrompt(alice)).toBeVisible()
|
||||
await expect(alice).not.toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
|
||||
await expect(composer(alice)).toHaveCount(0)
|
||||
|
||||
await alice.getByRole("button", {name: "Enable direct messaging"}).click()
|
||||
|
||||
// Turning it on carries her on into the conversation she started
|
||||
await expect(enablePrompt(alice)).toHaveCount(0)
|
||||
await expect(alice).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
|
||||
|
||||
// Bob has never enabled it, so his end of the conversation is a dead end
|
||||
const banner = alice.locator(".card").filter({hasText: "Direct messages are not enabled"})
|
||||
|
||||
await expect(banner).toContainText("Bob Barnacle")
|
||||
await expect(sendButton(alice)).toBeDisabled()
|
||||
|
||||
const bob = await as(users.bob, "/")
|
||||
|
||||
await bob.locator('.primary-nav [data-tip="Messages"]').click()
|
||||
|
||||
await expect(enablePrompt(bob)).toBeVisible()
|
||||
|
||||
await bob.getByRole("button", {name: "Enable direct messaging"}).click()
|
||||
|
||||
// His list is the app's evidence that his kind-10050 has been published
|
||||
await expect(bob).toHaveURL(/\/chat$/)
|
||||
await expect(bob.getByRole("button", {name: "Start New Chat"})).toBeVisible()
|
||||
|
||||
// She reopens the conversation the way she opened it the first time
|
||||
await pageBar(alice).getByRole("button", {name: "Bob Barnacle"}).click()
|
||||
await alice.getByRole("button", {name: "View Full Profile"}).click()
|
||||
|
||||
await expect(alice).toHaveURL(pathPattern(profilePath(users.bob.pubkey)))
|
||||
|
||||
await alice.getByRole("button", {name: "Message", exact: true}).click()
|
||||
|
||||
// The prompt is behind her for good, and his end of it works now
|
||||
await expect(alice).toHaveURL(pathPattern(chatPath(users.bob.pubkey)))
|
||||
await expect(enablePrompt(alice)).toHaveCount(0)
|
||||
await expect(sendButton(alice)).toBeEnabled()
|
||||
await expect(banner).toHaveCount(0)
|
||||
|
||||
await send(alice, "finally")
|
||||
|
||||
await expect(bubble(alice, "finally")).toBeVisible()
|
||||
await expect(chatItems(bob).filter({hasText: "finally"})).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-032 exchange messages in a conversation", async ({seed, as}) => {
|
||||
let yesterday!: SeededRumor
|
||||
let firstToday!: SeededRumor
|
||||
let secondToday!: SeededRumor
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
seedPerson(space, user.alice, "Alice Anchor")
|
||||
seedPerson(space, user.bob, "Bob Barnacle")
|
||||
enableDms(space, user.alice)
|
||||
enableDms(space, user.bob)
|
||||
|
||||
yesterday = space.dm(user.bob, [user.alice], "sent this yesterday", at(30, HOUR))
|
||||
// A minute apart, which is what puts these two in the same run of messages.
|
||||
firstToday = space.dm(user.bob, [user.alice], "morning alice", at(50, MINUTE))
|
||||
secondToday = space.dm(user.bob, [user.alice], "still around?", at(49, MINUTE))
|
||||
})
|
||||
|
||||
const alice = await as(users.alice, chatPath(users.bob.pubkey))
|
||||
const bob = await as(users.bob, chatPath(users.alice.pubkey))
|
||||
|
||||
await expect(message(alice, yesterday.id)).toBeVisible()
|
||||
await expect(message(alice, firstToday.id)).toBeVisible()
|
||||
await expect(message(alice, secondToday.id)).toBeVisible()
|
||||
|
||||
// Chronological: each message is rendered below the one before it
|
||||
expect(await topOf(message(alice, yesterday.id))).toBeLessThan(
|
||||
await topOf(message(alice, firstToday.id)),
|
||||
)
|
||||
expect(await topOf(message(alice, firstToday.id))).toBeLessThan(
|
||||
await topOf(message(alice, secondToday.id)),
|
||||
)
|
||||
|
||||
// A divider per day
|
||||
await expect(alice.getByText(await dayLabel(alice, scenario.at(30, HOUR)))).toBeVisible()
|
||||
await expect(alice.getByText(await dayLabel(alice, scenario.at(50, MINUTE)))).toBeVisible()
|
||||
|
||||
// His second message in a row carries neither his name nor his avatar
|
||||
await expect(bubble(alice, "morning alice")).toContainText("Bob Barnacle")
|
||||
await expect(bubble(alice, "morning alice").locator(".rounded-full")).toHaveCount(1)
|
||||
await expect(bubble(alice, "still around?")).not.toContainText("Bob Barnacle")
|
||||
await expect(bubble(alice, "still around?").locator(".rounded-full")).toHaveCount(0)
|
||||
|
||||
await send(alice, "just got here")
|
||||
|
||||
await expect(bubble(alice, "just got here")).toBeVisible()
|
||||
await expect(bubble(alice, "just got here")).toHaveClass(/chat-bubble--user/)
|
||||
await expect(composer(alice)).toHaveText("")
|
||||
|
||||
// ...and it reaches the conversation he already has open, attributed to her
|
||||
await expect(bubble(bob, "just got here")).toBeVisible()
|
||||
await expect(bubble(bob, "just got here")).toContainText("Alice Anchor")
|
||||
})
|
||||
|
||||
test("US-033 browse and search your conversations", async ({seed, as}) => {
|
||||
const dave = makeTestUser("dave")
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
seedPerson(space, user.alice, "Alice Anchor")
|
||||
seedPerson(space, user.bob, "Bob Barnacle")
|
||||
seedPerson(space, user.carol, "Carol Cutter")
|
||||
seedPerson(space, dave, "Dave Davits")
|
||||
enableDms(space, user.alice)
|
||||
|
||||
space.dm(dave, [user.alice], "see you at the meetup", at(2, DAY))
|
||||
space.dm(user.carol, [user.alice], "thanks for the link", at(3, HOUR))
|
||||
space.dm(user.bob, [user.alice], "lunch tomorrow?", at(30, MINUTE))
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/chat")
|
||||
|
||||
await expect(chatItems(page)).toHaveCount(3)
|
||||
|
||||
const bobStamp = await stampLabel(page, scenario.at(30, MINUTE))
|
||||
const carolStamp = await stampLabel(page, scenario.at(3, HOUR))
|
||||
const daveStamp = await stampLabel(page, scenario.at(2, DAY))
|
||||
|
||||
// Most recently active first, each with a name, a preview and a timestamp
|
||||
await expect(chatItems(page).nth(0)).toContainText("Bob Barnacle")
|
||||
await expect(chatItems(page).nth(0)).toContainText("lunch tomorrow?")
|
||||
await expect(chatItems(page).nth(0)).toContainText(bobStamp)
|
||||
await expect(chatItems(page).nth(1)).toContainText("Carol Cutter")
|
||||
await expect(chatItems(page).nth(1)).toContainText("thanks for the link")
|
||||
await expect(chatItems(page).nth(1)).toContainText(carolStamp)
|
||||
await expect(chatItems(page).nth(2)).toContainText("Dave Davits")
|
||||
await expect(chatItems(page).nth(2)).toContainText("see you at the meetup")
|
||||
await expect(chatItems(page).nth(2)).toContainText(daveStamp)
|
||||
|
||||
await chatFilter(page).fill("Bob Barnacle")
|
||||
|
||||
await expect(chatItems(page)).toHaveCount(1)
|
||||
await expect(chatItems(page).nth(0)).toContainText("Bob Barnacle")
|
||||
|
||||
await chatFilter(page).fill("")
|
||||
|
||||
await expect(chatItems(page)).toHaveCount(3)
|
||||
})
|
||||
|
||||
test("US-034 track and clear unread conversations", async ({seed, as}) => {
|
||||
const dave = makeTestUser("dave")
|
||||
|
||||
await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
seedPerson(space, user.alice, "Alice Anchor")
|
||||
seedPerson(space, user.bob, "Bob Barnacle")
|
||||
seedPerson(space, user.carol, "Carol Cutter")
|
||||
seedPerson(space, dave, "Dave Davits")
|
||||
enableDms(space, user.alice)
|
||||
enableDms(space, user.bob)
|
||||
|
||||
space.dm(user.carol, [user.alice], "did you see this?", at(3, HOUR))
|
||||
space.dm(dave, [user.alice], "ping from dave", at(2, HOUR))
|
||||
})
|
||||
|
||||
const alice = await as(users.alice, "/chat")
|
||||
const bobChat = chatItems(alice).filter({hasText: "Bob Barnacle"})
|
||||
const carolChat = chatItems(alice).filter({hasText: "Carol Cutter"})
|
||||
const daveChat = chatItems(alice).filter({hasText: "Dave Davits"})
|
||||
|
||||
await expect(chatItems(alice)).toHaveCount(2)
|
||||
await expect(unreadDots(carolChat)).toBeVisible()
|
||||
await expect(unreadDots(daveChat)).toBeVisible()
|
||||
|
||||
// Bob writes while she is looking at the list rather than at his conversation
|
||||
const bob = await as(users.bob, chatPath(users.alice.pubkey))
|
||||
|
||||
await send(bob, "are you free later?")
|
||||
|
||||
await expect(bobChat).toBeVisible()
|
||||
await expect(unreadDots(bobChat)).toBeVisible()
|
||||
|
||||
// Reading it and moving on clears it
|
||||
await bobChat.click()
|
||||
|
||||
await expect(bubble(alice, "are you free later?")).toBeVisible()
|
||||
|
||||
await carolChat.click()
|
||||
|
||||
await expect(bubble(alice, "did you see this?")).toBeVisible()
|
||||
await expect(unreadDots(bobChat)).toHaveCount(0)
|
||||
|
||||
// Mark all read clears what is left of them in one go
|
||||
await alice.locator(".secondary-nav__header").getByRole("button").click()
|
||||
await alice.getByRole("button", {name: "Mark all read"}).click()
|
||||
|
||||
await expect(unreadDots(chatList(alice))).toHaveCount(0)
|
||||
|
||||
// ...and they are still clear when she comes back
|
||||
await alice.locator('.primary-nav [data-tip="Settings"]').click()
|
||||
|
||||
await expect(alice).toHaveURL(pathPattern("/settings/profile"))
|
||||
|
||||
await alice.locator('.primary-nav [data-tip="Messages"]').click()
|
||||
|
||||
await expect(chatItems(alice)).toHaveCount(3)
|
||||
await expect(unreadDots(chatList(alice))).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-035 reply to, edit, and react to a direct message", async ({seed, as}) => {
|
||||
let his!: SeededRumor
|
||||
let hers!: SeededRumor
|
||||
|
||||
await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
seedPerson(space, user.alice, "Alice Anchor")
|
||||
seedPerson(space, user.bob, "Bob Barnacle")
|
||||
enableDms(space, user.alice)
|
||||
enableDms(space, user.bob)
|
||||
|
||||
his = space.dm(user.bob, [user.alice], "did you see the thing?", at(20, MINUTE))
|
||||
hers = space.dm(user.alice, [user.bob], "first attempt", at(10, MINUTE))
|
||||
})
|
||||
|
||||
// A touch context, which is what puts a message's actions behind named buttons rather than
|
||||
// behind a row of icons in a hover popover.
|
||||
const alice = await as(users.alice, chatPath(users.bob.pubkey), {context: {hasTouch: true}})
|
||||
const bob = await as(users.bob, chatPath(users.alice.pubkey))
|
||||
|
||||
await expect(message(alice, his.id)).toBeVisible()
|
||||
await expect(message(alice, hers.id)).toBeVisible()
|
||||
|
||||
// Reply: the preview appears above the composer, and closing it sends nothing
|
||||
await openMessageMenu(alice, his.id)
|
||||
await alice.getByRole("button", {name: "Send Reply"}).click()
|
||||
|
||||
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
|
||||
await expect(composePreview(alice)).toContainText("did you see the thing?")
|
||||
|
||||
await composePreview(alice).getByRole("button").last().click()
|
||||
|
||||
await expect(composePreview(alice)).toHaveCount(0)
|
||||
await expect(alice.locator(".chat-bubble")).toHaveCount(2)
|
||||
|
||||
await openMessageMenu(alice, his.id)
|
||||
await alice.getByRole("button", {name: "Send Reply"}).click()
|
||||
|
||||
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
|
||||
|
||||
await send(alice, "yes I did")
|
||||
|
||||
// The reply that gets sent quotes what it answers
|
||||
await expect(bubble(alice, "yes I did")).toContainText("did you see the thing?")
|
||||
await expect(bubble(bob, "yes I did")).toContainText("did you see the thing?")
|
||||
|
||||
// Edit: her own message is replaced in place for both of them rather than duplicated
|
||||
await openMessageMenu(alice, hers.id)
|
||||
|
||||
await expect(alice.getByRole("button", {name: "Edit Message"})).toBeVisible()
|
||||
|
||||
await alice.getByRole("button", {name: "Edit Message"}).click()
|
||||
|
||||
await expect(composePreview(alice)).toContainText("Editing message")
|
||||
await expect(composer(alice)).toContainText("first attempt")
|
||||
|
||||
await composer(alice).press("ControlOrMeta+a")
|
||||
await send(alice, "second attempt")
|
||||
|
||||
await expect(bubble(alice, "second attempt")).toBeVisible()
|
||||
await expect(alice.locator(".chat-bubble").filter({hasText: "first attempt"})).toHaveCount(0)
|
||||
await expect(bubble(bob, "second attempt")).toBeVisible()
|
||||
await expect(bob.locator(".chat-bubble").filter({hasText: "first attempt"})).toHaveCount(0)
|
||||
|
||||
// His, hers and her reply — the edit replaced one rather than adding a fourth
|
||||
await expect(alice.locator(".chat-bubble")).toHaveCount(3)
|
||||
await expect(bob.locator(".chat-bubble")).toHaveCount(3)
|
||||
|
||||
// React, then take it back, on both sides of the conversation
|
||||
await openMessageMenu(alice, his.id)
|
||||
await alice.getByRole("button", {name: "Send Reaction"}).click()
|
||||
|
||||
const picker = alice.locator("emoji-picker").filter({visible: true})
|
||||
|
||||
// A result's label is the emoji, its annotation and every shortcode joined together, so the
|
||||
// annotation is what gets matched rather than the whole of it.
|
||||
await picker.locator("input.search").fill("party popper")
|
||||
await picker.getByRole("option", {name: /party popper/}).click()
|
||||
|
||||
await expect(message(alice, his.id)).toContainText("🎉")
|
||||
await expect(message(bob, his.id)).toContainText("🎉")
|
||||
|
||||
await message(alice, his.id).getByRole("button", {name: "🎉"}).click()
|
||||
|
||||
await expect(message(alice, his.id)).not.toContainText("🎉")
|
||||
await expect(message(bob, his.id)).not.toContainText("🎉")
|
||||
|
||||
// Up in an empty composer picks up the last thing she said, which is the reply
|
||||
await expect(composer(alice)).toHaveText("")
|
||||
|
||||
await composer(alice).press("ArrowUp")
|
||||
|
||||
await expect(composePreview(alice)).toContainText("Editing message")
|
||||
await expect(composer(alice)).toContainText("yes I did")
|
||||
})
|
||||
|
||||
test("US-036 receive a new conversation live", async ({seed, as}) => {
|
||||
await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
seedPerson(space, user.alice, "Alice Anchor")
|
||||
seedPerson(space, user.bob, "Bob Barnacle")
|
||||
seedPerson(space, user.carol, "Carol Cutter")
|
||||
enableDms(space, user.alice)
|
||||
enableDms(space, user.bob)
|
||||
|
||||
// A conversation each of them already has. A list with something in it is how each page says
|
||||
// its own end of the sync is up, before the one that has to arrive live is sent.
|
||||
space.dm(user.carol, [user.alice], "see you monday", at(4, HOUR))
|
||||
space.dm(user.carol, [user.bob], "you too", at(4, HOUR))
|
||||
})
|
||||
|
||||
const alice = await as(users.alice, "/chat")
|
||||
|
||||
await expect(chatItems(alice).filter({hasText: "see you monday"})).toBeVisible()
|
||||
|
||||
const bob = await as(users.bob, "/chat")
|
||||
|
||||
await expect(chatItems(bob).filter({hasText: "you too"})).toBeVisible()
|
||||
|
||||
// He has never talked to her, so he identifies her by the code she gave him
|
||||
await bob.getByRole("button", {name: "Start New Chat"}).click()
|
||||
await bob.getByPlaceholder("Search for profiles...").fill(npubEncode(users.alice.pubkey))
|
||||
await bob.getByRole("button", {name: "Create Chat"}).click()
|
||||
|
||||
await expect(bob).toHaveURL(pathPattern(chatPath(users.alice.pubkey)))
|
||||
|
||||
await send(bob, "starting a chat with you")
|
||||
|
||||
// Her list picks the conversation up on its own
|
||||
const fromBob = chatItems(alice).filter({hasText: "Bob Barnacle"})
|
||||
|
||||
await expect(fromBob).toBeVisible()
|
||||
|
||||
await fromBob.click()
|
||||
|
||||
await expect(bubble(alice, "starting a chat with you")).toBeVisible()
|
||||
})
|
||||
420
e2e/specs/notifications.spec.ts
Normal file
420
e2e/specs/notifications.spec.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import {neventEncode, npubEncode} from "nostr-tools/nip19"
|
||||
import {HOUR, MINUTE} from "@welshman/lib"
|
||||
import {displayRelayUrl} from "@welshman/util"
|
||||
import {MessagingRelayList, RelayList} from "@welshman/domain"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
import {expect, roomPath, spacePath, test, users} from "../harness"
|
||||
import type {SeededSpace, TestUser} from "../harness"
|
||||
|
||||
// A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id is ever
|
||||
// read back here.
|
||||
type Seeded = {readonly id: string}
|
||||
|
||||
// A literal as a pattern, for a url that carries a query string alongside the path being matched,
|
||||
// or a host whose dots would otherwise be wildcards.
|
||||
const pattern = (literal: string) => new RegExp(literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
|
||||
// The unread indicator, which every surface renders the same way: a small primary-colored dot in
|
||||
// the corner of the thing it belongs to. RelaySummary's "you're a member" check is the same shape
|
||||
// at h-5 w-5, so the size is part of what says which one this is.
|
||||
const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary")
|
||||
|
||||
// The bell SpaceMenuRoomItem hangs off a room whose notification setting differs from its space's.
|
||||
// An icon is a css mask built from a data url, so which bell it is can't be read out of the class
|
||||
// list — but it only renders on a difference, and while the space itself is still notifying the
|
||||
// only difference a room can have is a mute.
|
||||
const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50")
|
||||
|
||||
// The bell SpaceMenuHeader puts beside the space's name once the space itself is muted. The only
|
||||
// other absolutely-positioned dot in that button is the admin action-items one, which is opacity-0.
|
||||
const mutedSpaceBell = (header: Locator) => header.locator("div.opacity-50")
|
||||
|
||||
// FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row, with the
|
||||
// label at one end and the control at the other.
|
||||
const settingRow = (page: Page, label: string) =>
|
||||
page.locator("div.items-center.justify-between").filter({hasText: label})
|
||||
|
||||
// PrimaryNavItemSpace carries the relay's name as a tooltip rather than as an accessible name — its
|
||||
// icon is a masked svg with no alt text — and it has an onclick, so PrimaryNavItem renders it as a
|
||||
// button rather than a link. The name comes from nip-11; until that document lands the tooltip is
|
||||
// the relay's host instead, and each tenant's toml names it after itself, so both start the same.
|
||||
const spaceNavItem = (page: Page, name: string) =>
|
||||
page.locator(`.primary-nav [data-tip^="${name}"]`)
|
||||
|
||||
// The space menu's header, the one button in the secondary nav carrying the relay's address.
|
||||
const spaceMenu = (page: Page, url: string) =>
|
||||
page.locator(".secondary-nav").getByRole("button", {name: pattern(displayRelayUrl(url))})
|
||||
|
||||
const roomLink = (page: Page, name: string) =>
|
||||
page.locator(".space-menu__scroll").getByRole("link", {name})
|
||||
|
||||
// The room's page bar carries a back arrow (display:none at this viewport), a search button and the
|
||||
// detail button, in that order.
|
||||
const openRoomDetail = (page: Page) =>
|
||||
page.locator('[data-component="PageBar"]').getByRole("button").last().click()
|
||||
|
||||
// `.dialog` is on both the backdrop wrapper and the panel inside it, so the last match is the panel.
|
||||
const dialog = (page: Page, title: string) =>
|
||||
page
|
||||
.locator(".dialog")
|
||||
.filter({has: page.getByRole("heading", {name: title, exact: true})})
|
||||
.last()
|
||||
|
||||
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
|
||||
|
||||
const message = (page: Page, text: string) => page.locator(".room__item").filter({hasText: text})
|
||||
|
||||
const send = async (page: Page, content: string) => {
|
||||
await composer(page).pressSequentially(content)
|
||||
await composer(page).press("Enter")
|
||||
await expect(message(page, content)).toBeVisible()
|
||||
}
|
||||
|
||||
// The page bar names the room, so waiting for it is what says the composer below now belongs to
|
||||
// the room that was just opened rather than to the one being torn down.
|
||||
const postTo = async (page: Page, room: string, content: string) => {
|
||||
await roomLink(page, room).click()
|
||||
await expect(page.locator('[data-component="PageBar"]')).toContainText(room)
|
||||
await send(page, content)
|
||||
}
|
||||
|
||||
// Outbox routing resolves everything about a person through their relay list: settings are
|
||||
// published to the relays it names, and a gift wrap only reaches somebody who has said where their
|
||||
// messages go.
|
||||
const seedRelays = (space: SeededSpace, user: TestUser) => {
|
||||
space.event(user, () =>
|
||||
space
|
||||
.kind(RelayList)
|
||||
.writer()
|
||||
.setReadUrls([space.url])
|
||||
.setWriteUrls([space.url])
|
||||
.renderTemplate(),
|
||||
)
|
||||
space.event(user, () =>
|
||||
space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(),
|
||||
)
|
||||
}
|
||||
|
||||
test("US-103 see and clear unread indicators", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.room("random", {name: "Random"})
|
||||
space.join(user.alice, "general", "random")
|
||||
space.join(user.bob, "general", "random")
|
||||
|
||||
// History bob wrote himself, so the space has something in it while nothing in it is unread
|
||||
// for him — his own messages never raise an indicator.
|
||||
space.message(user.bob, "general", "anyone around?", at(2, HOUR))
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
const bob = await as(users.bob, "/spaces")
|
||||
const alice = await as(users.alice, roomPath(space.url, "general"))
|
||||
|
||||
const spaceRow = bob.getByRole("listitem").filter({hasText: space.url})
|
||||
const navItem = spaceNavItem(bob, space.name)
|
||||
|
||||
await expect(spaceRow).toBeVisible()
|
||||
await expect(navItem).toBeVisible()
|
||||
await expect(unreadDot(spaceRow)).toHaveCount(0)
|
||||
await expect(unreadDot(navItem)).toHaveCount(0)
|
||||
|
||||
await send(alice, "the server is on fire")
|
||||
|
||||
// Bob is sitting on the space list the whole time, so both dots arrive without a navigation
|
||||
await expect(unreadDot(spaceRow)).toBeVisible()
|
||||
await expect(unreadDot(navItem)).toBeVisible()
|
||||
|
||||
// Inside the space, the dot points at the room the message landed in. A space's room list only
|
||||
// exists in its own menu, so this is the one indicator the list above can't show.
|
||||
await spaceRow.click()
|
||||
|
||||
const general = roomLink(bob, "General")
|
||||
const random = roomLink(bob, "Random")
|
||||
|
||||
// Both rooms are on screen first, so a room with no dot is a room raising none rather than a
|
||||
// row that never rendered
|
||||
await expect(random).toBeVisible()
|
||||
await expect(unreadDot(general)).toBeVisible()
|
||||
await expect(unreadDot(random)).toHaveCount(0)
|
||||
|
||||
await general.click()
|
||||
|
||||
await expect(message(bob, "the server is on fire")).toBeVisible()
|
||||
|
||||
// Reading it clears the room's dot...
|
||||
await bob.getByRole("link", {name: "Space Details"}).click()
|
||||
|
||||
await expect(general).toBeVisible()
|
||||
await expect(unreadDot(general)).toHaveCount(0)
|
||||
|
||||
// ...and it stays cleared back on the space list he started from
|
||||
await bob.locator('.primary-nav a[href="/spaces"]').click()
|
||||
|
||||
await expect(spaceRow).toBeVisible()
|
||||
await expect(unreadDot(spaceRow)).toHaveCount(0)
|
||||
await expect(unreadDot(navItem)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-104 mute a room or a whole space", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.room("random", {name: "Random"})
|
||||
space.join(user.alice, "general", "random")
|
||||
space.join(user.bob, "general", "random")
|
||||
|
||||
seedRelays(space, user.alice)
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
|
||||
// Silencing a whole space is only offered once push notifications are on, so that is where alice
|
||||
// has to start.
|
||||
const alice = await as(users.alice, "/settings/alerts", {
|
||||
context: {permissions: ["notifications"]},
|
||||
})
|
||||
|
||||
await settingRow(alice, "Enable push notifications").getByRole("checkbox").check()
|
||||
await alice.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(alice.getByRole("alert")).toContainText("Your settings have been saved!")
|
||||
|
||||
await spaceNavItem(alice, space.name).click()
|
||||
|
||||
const general = roomLink(alice, "General")
|
||||
const random = roomLink(alice, "Random")
|
||||
|
||||
await general.click()
|
||||
|
||||
await expect(alice).toHaveURL(pattern(roomPath(space.url, "general")))
|
||||
|
||||
// Silence this one room from its detail panel
|
||||
await openRoomDetail(alice)
|
||||
|
||||
const roomNotifications = settingRow(alice, "Notifications").getByRole("checkbox")
|
||||
|
||||
await expect(roomNotifications).toBeChecked()
|
||||
|
||||
await roomNotifications.uncheck()
|
||||
await alice.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
await expect(random).toBeVisible()
|
||||
await expect(mutedRoomBell(general)).toBeVisible()
|
||||
await expect(mutedRoomBell(random)).toHaveCount(0)
|
||||
|
||||
// Step out of both rooms, so either one is free to raise a dot
|
||||
await alice.getByRole("link", {name: "Space Details"}).click()
|
||||
|
||||
const bob = await as(users.bob, roomPath(space.url, "general"))
|
||||
|
||||
await send(bob, "deploy is broken")
|
||||
await postTo(bob, "Random", "lunch?")
|
||||
|
||||
// The sibling room's message raises a dot, the muted room's does not
|
||||
await expect(unreadDot(random)).toBeVisible()
|
||||
await expect(unreadDot(general)).toHaveCount(0)
|
||||
|
||||
// The muted room's message did arrive, it just raised nothing
|
||||
await general.click()
|
||||
|
||||
await expect(message(alice, "deploy is broken")).toBeVisible()
|
||||
|
||||
// Turning the room back on restores its indicator for what comes next
|
||||
await openRoomDetail(alice)
|
||||
await roomNotifications.check()
|
||||
await alice.getByRole("button", {name: "Go back"}).click()
|
||||
await alice.getByRole("link", {name: "Space Details"}).click()
|
||||
|
||||
await expect(general).toBeVisible()
|
||||
await expect(mutedRoomBell(general)).toHaveCount(0)
|
||||
|
||||
await postTo(bob, "General", "and now the build too")
|
||||
|
||||
// Both rooms are now showing a dot, so silencing the space is what takes them down
|
||||
await expect(unreadDot(general)).toBeVisible()
|
||||
await expect(unreadDot(random)).toBeVisible()
|
||||
|
||||
const menu = spaceMenu(alice, space.url)
|
||||
|
||||
await menu.click()
|
||||
await alice.getByRole("button", {name: "Turn off notifications"}).click()
|
||||
|
||||
await expect(mutedSpaceBell(menu)).toBeVisible()
|
||||
await expect(unreadDot(general)).toHaveCount(0)
|
||||
await expect(unreadDot(random)).toHaveCount(0)
|
||||
|
||||
await postTo(bob, "General", "still broken")
|
||||
await postTo(bob, "Random", "still hungry")
|
||||
|
||||
await expect(unreadDot(general)).toHaveCount(0)
|
||||
await expect(unreadDot(random)).toHaveCount(0)
|
||||
|
||||
// Reopening the menu shows the label the mute flipped, and turning it back on brings the
|
||||
// indicators back — which is also what proves those messages reached alice at all
|
||||
await menu.click()
|
||||
|
||||
const turnOn = alice.getByRole("button", {name: "Turn on notifications"})
|
||||
|
||||
await expect(turnOn).toBeVisible()
|
||||
await turnOn.click()
|
||||
|
||||
await expect(mutedSpaceBell(menu)).toHaveCount(0)
|
||||
await expect(unreadDot(general)).toBeVisible()
|
||||
await expect(unreadDot(random)).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-105 land on the home page", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
|
||||
// A build that names a platform space sends /home straight into it
|
||||
const platform = await as(users.alice, "/home", {env: {VITE_PLATFORM_RELAYS: space.url}})
|
||||
|
||||
await expect(platform).toHaveURL(pattern(spacePath(space.url)))
|
||||
|
||||
// With none configured, /home is a welcome screen with two ways out
|
||||
const page = await as(users.alice, "/home")
|
||||
|
||||
const addSpace = page.getByRole("link", {name: "Add a space"})
|
||||
const startConversation = page.getByRole("button", {name: "Start a conversation"})
|
||||
|
||||
await expect(addSpace).toBeVisible()
|
||||
await expect(startConversation).toBeVisible()
|
||||
|
||||
await addSpace.click()
|
||||
|
||||
await expect(page).toHaveURL(/\/spaces$/)
|
||||
|
||||
await page.goBack()
|
||||
|
||||
await expect(page).toHaveURL(/\/home$/)
|
||||
|
||||
await startConversation.click()
|
||||
|
||||
await expect(page).toHaveURL(/\/chat$/)
|
||||
})
|
||||
|
||||
test("US-106 share text into the app", async ({seed, as}) => {
|
||||
const shared = "the offsite is moving to the 14th"
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.room("watercooler", {name: "Watercooler"})
|
||||
space.join(user.alice, "general", "watercooler")
|
||||
space.join(user.bob, "general", "watercooler")
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
|
||||
seedRelays(space, user.alice)
|
||||
seedRelays(space, user.bob)
|
||||
|
||||
space.dm(user.bob, [user.alice], "are you around?", at(20, MINUTE))
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
|
||||
// A client only unwraps direct messages once its owner has opened chat, so a conversation the
|
||||
// share dialog can offer is one alice has already seen.
|
||||
const page = await as(users.alice, "/chat")
|
||||
|
||||
await expect(page.getByText("are you around?").first()).toBeVisible()
|
||||
|
||||
// The text arrives in the query string rather than through a native share intent, which is the
|
||||
// same thing src/routes/share reads either way.
|
||||
await page.goto(`/share?text=${encodeURIComponent(shared)}`)
|
||||
|
||||
const share = dialog(page, "Share")
|
||||
|
||||
await expect(page.getByText("Where would you like to share this?")).toBeVisible()
|
||||
await expect(share.getByRole("button", {name: "General"})).toBeVisible()
|
||||
await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible()
|
||||
await expect(share.getByRole("button", {name: "Bob Barker"})).toBeVisible()
|
||||
|
||||
const search = page.getByPlaceholder("Search rooms and conversations...")
|
||||
|
||||
await search.fill("Watercooler")
|
||||
|
||||
await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible()
|
||||
await expect(share.getByRole("button", {name: "General"})).toHaveCount(0)
|
||||
await expect(share.getByRole("button", {name: "Bob Barker"})).toHaveCount(0)
|
||||
|
||||
await search.fill("")
|
||||
await share.getByRole("button", {name: "General"}).click()
|
||||
await share.getByRole("button", {name: "Share", exact: true}).click()
|
||||
|
||||
await expect(page).toHaveURL(pattern(roomPath(space.url, "general")))
|
||||
await expect(composer(page)).toHaveText(shared)
|
||||
})
|
||||
|
||||
test("US-107 open a nostr link", async ({seed, as}) => {
|
||||
let posted!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.bob, {name: "Bob Barker"})
|
||||
|
||||
seedRelays(space, user.bob)
|
||||
|
||||
posted = space.message(user.bob, "general", "the meeting moved to friday", at(20, MINUTE))
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
const page = await as(users.alice, roomPath(space.url, "general"), {
|
||||
context: {permissions: ["clipboard-read", "clipboard-write"]},
|
||||
})
|
||||
|
||||
const item = page.locator(`[data-event="${posted.id}"]`)
|
||||
|
||||
await expect(item).toBeVisible()
|
||||
await item.hover()
|
||||
await item.locator(".room__item-actions button").last().click()
|
||||
await page.getByRole("button", {name: "Message Details"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Event Details"})).toBeVisible()
|
||||
|
||||
// EventInfo renders the link on mount rather than during setup, so wait for it rather than
|
||||
// copying an empty field
|
||||
const eventLink = settingRow(page, "Event Link")
|
||||
|
||||
await expect(eventLink.getByRole("textbox")).toHaveValue(/^nostr:nevent1/)
|
||||
await eventLink.getByRole("button").click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
|
||||
|
||||
const link = await page.evaluate(() => navigator.clipboard.readText())
|
||||
|
||||
expect(link).toMatch(/^nostr:nevent1/)
|
||||
|
||||
await page.goto("/" + link.replace(/^nostr:/, ""))
|
||||
|
||||
await expect(page).toHaveURL(pattern(roomPath(space.url, "general") + "?at="))
|
||||
await expect(message(page, "the meeting moved to friday")).toBeVisible()
|
||||
|
||||
// A person's npub resolves the same way
|
||||
const npub = npubEncode(users.bob.pubkey)
|
||||
|
||||
await page.goto(`/${npub}`)
|
||||
|
||||
await expect(page).toHaveURL(pattern(`/people/${npub}`))
|
||||
await expect(page.getByRole("heading", {name: "Bob Barker"})).toBeVisible()
|
||||
|
||||
// Nothing holds this event, so the link falls back to the app's home rather than a dead page
|
||||
await page.goto(`/${neventEncode({id: "f".repeat(64), relays: [space.url]})}`)
|
||||
|
||||
await expect(page).toHaveURL(/\/home$/)
|
||||
})
|
||||
413
e2e/specs/onboarding.spec.ts
Normal file
413
e2e/specs/onboarding.spec.ts
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
import {readFile} from "node:fs/promises"
|
||||
import type {Page} from "@playwright/test"
|
||||
import {npubEncode, nsecEncode} from "nostr-tools/nip19"
|
||||
import {encrypt} from "nostr-tools/nip49"
|
||||
import {hexToBytes} from "@welshman/lib"
|
||||
import {expect, roomPath, spacePath, test, users} from "../harness"
|
||||
import type {TestUser} from "../harness"
|
||||
|
||||
// The sign-in gate AppContainer renders in place of the app for anyone without a session.
|
||||
const gate = (page: Page) => page.getByRole("heading", {name: "Welcome to Flotilla!"})
|
||||
|
||||
const nsecFor = (user: TestUser) => nsecEncode(hexToBytes(user.secret))
|
||||
|
||||
// Landing → "Log in" → "Log in with Key" → paste → submit, which is the only way to watch a
|
||||
// session come into existence: a session injected by `as()` is re-applied on every navigation.
|
||||
const logInWithKey = async (page: Page, key: string) => {
|
||||
await page.getByRole("button", {name: "Log in"}).click()
|
||||
await page.getByRole("button", {name: "Log in with Key"}).click()
|
||||
await page.getByPlaceholder("nsec1...").fill(key)
|
||||
await page.getByRole("button", {name: "Log in", exact: true}).click()
|
||||
}
|
||||
|
||||
// The nav's settings link carries its label as a tooltip rather than as an accessible name — its
|
||||
// icon is a masked svg with no alt text — so it is addressed by where it goes.
|
||||
const openSettings = (page: Page) =>
|
||||
page.locator('.primary-nav a[href="/settings/profile"]').click()
|
||||
|
||||
test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => {
|
||||
const scenario = await seed(({relay}) => {
|
||||
relay("space").room("general", {name: "General"})
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
|
||||
// A room url rather than the root: the gate stands in front of the whole app, so where the
|
||||
// visitor landed makes no difference.
|
||||
const page = await visit(roomPath(url, "general"))
|
||||
|
||||
await expect(gate(page)).toBeVisible()
|
||||
await expect(page.getByRole("button", {name: "Log in"})).toBeVisible()
|
||||
await expect(page.getByRole("button", {name: "Create an account"})).toBeVisible()
|
||||
await expect(page.getByRole("link", {name: "Terms of Service"})).toHaveAttribute(
|
||||
"href",
|
||||
"https://flotilla.social/terms",
|
||||
)
|
||||
await expect(page.getByRole("link", {name: "Privacy Policy"})).toHaveAttribute(
|
||||
"href",
|
||||
"https://flotilla.social/privacy",
|
||||
)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
await expect(gate(page)).toBeVisible()
|
||||
|
||||
// The backdrop, clicked in its top left corner so the dialog itself isn't what gets hit.
|
||||
await page.getByRole("button", {name: "Close dialog"}).click({position: {x: 4, y: 4}})
|
||||
|
||||
await expect(gate(page)).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Log in"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Log in with Nostr"})).toBeVisible()
|
||||
|
||||
await page.goBack()
|
||||
|
||||
await expect(gate(page)).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Create an account"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Join Flotilla"})).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-002 sign up by generating a new key", async ({seed, visit}) => {
|
||||
const scenario = await seed(({relay}) => {
|
||||
relay("space").room("general", {name: "General"})
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await visit()
|
||||
|
||||
await page.getByRole("button", {name: "Create an account"}).click()
|
||||
await page.getByRole("button", {name: "Generate a key"}).click()
|
||||
|
||||
await page.locator('input[type="text"]').fill("Nova Tester")
|
||||
await page.getByRole("button", {name: "Create Account"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Your Keys are Ready!"})).toBeVisible()
|
||||
|
||||
const continueButton = page.getByRole("button", {name: "Continue"})
|
||||
const password = page.locator('input[type="password"]')
|
||||
|
||||
await expect(continueButton).toBeDisabled()
|
||||
|
||||
await page.getByRole("button", {name: "I want to download an encrypted version"}).click()
|
||||
await password.fill("hunter2")
|
||||
await page.getByRole("button", {name: "Download my key"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"Your password must be at least 12 characters long.",
|
||||
)
|
||||
await expect(continueButton).toBeDisabled()
|
||||
|
||||
await password.fill("correct horse battery staple")
|
||||
|
||||
const download = page.waitForEvent("download")
|
||||
|
||||
await page.getByRole("button", {name: "Download my key"}).click()
|
||||
|
||||
const contents = await readFile(await (await download).path(), "utf8")
|
||||
|
||||
expect(contents).toContain("ncryptsec1")
|
||||
expect(contents).not.toContain("nsec1")
|
||||
|
||||
await expect(continueButton).toBeEnabled()
|
||||
await continueButton.click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "You're all set!"})).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Go to Dashboard"}).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/home$/)
|
||||
await expect(page.locator(".primary-nav")).toBeVisible()
|
||||
await expect(page.getByRole("heading", {name: "You're all set!"})).toHaveCount(0)
|
||||
await expect(gate(page)).toHaveCount(0)
|
||||
|
||||
// A space's nav item is a button rather than a link — PrimaryNavItemSpace passes an onclick, and
|
||||
// PrimaryNavItem renders a Button whenever it has one — so it is addressed by the tooltip it
|
||||
// carries, which is the relay's nip-11 name, and clicking it is what says which space it is.
|
||||
const spaceItem = page.locator('.primary-nav [data-tip="space"]')
|
||||
|
||||
await expect(spaceItem).toBeVisible()
|
||||
await spaceItem.click()
|
||||
await expect(page).toHaveURL(new RegExp(spacePath(url)))
|
||||
|
||||
await openSettings(page)
|
||||
|
||||
await expect(page.getByText("Nova Tester")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-003 log in with an existing private key", async ({seed, visit}) => {
|
||||
await seed(({relay}) => {
|
||||
relay("space").room("general", {name: "General"})
|
||||
})
|
||||
|
||||
const aliceNpub = npubEncode(users.alice.pubkey)
|
||||
const password = "a very good password"
|
||||
const ncryptsec = encrypt(hexToBytes(users.alice.secret), password)
|
||||
|
||||
const withNsec = await visit()
|
||||
|
||||
await logInWithKey(withNsec, nsecFor(users.alice))
|
||||
await expect(withNsec.locator(".primary-nav")).toBeVisible()
|
||||
await openSettings(withNsec)
|
||||
await expect(withNsec.getByRole("textbox")).toHaveValue(aliceNpub)
|
||||
|
||||
const withHex = await visit()
|
||||
|
||||
await logInWithKey(withHex, users.alice.secret)
|
||||
await expect(withHex.locator(".primary-nav")).toBeVisible()
|
||||
await openSettings(withHex)
|
||||
await expect(withHex.getByRole("textbox")).toHaveValue(aliceNpub)
|
||||
|
||||
const withNcryptsec = await visit()
|
||||
|
||||
await withNcryptsec.getByRole("button", {name: "Log in"}).click()
|
||||
await withNcryptsec.getByRole("button", {name: "Log in with Key"}).click()
|
||||
|
||||
const key = withNcryptsec.getByPlaceholder("nsec1...")
|
||||
const submit = withNcryptsec.getByRole("button", {name: "Log in", exact: true})
|
||||
|
||||
await key.fill("this is not a key")
|
||||
await expect(submit).toBeDisabled()
|
||||
|
||||
await key.fill(ncryptsec)
|
||||
await withNcryptsec.getByPlaceholder("Your password").fill("not the password")
|
||||
await submit.click()
|
||||
|
||||
await expect(withNcryptsec.getByRole("alert")).toContainText(
|
||||
"Failed to decrypt key. Please check your password.",
|
||||
)
|
||||
await expect(withNcryptsec.getByRole("heading", {name: "Log In with Key"})).toBeVisible()
|
||||
await expect(withNcryptsec.locator(".primary-nav")).toHaveCount(0)
|
||||
|
||||
await withNcryptsec.getByPlaceholder("Your password").fill(password)
|
||||
await submit.click()
|
||||
|
||||
await expect(withNcryptsec.locator(".primary-nav")).toBeVisible()
|
||||
await openSettings(withNcryptsec)
|
||||
await expect(withNcryptsec.getByRole("textbox")).toHaveValue(aliceNpub)
|
||||
|
||||
// A second browser context with its own storage: bob's session is his own, not a second view
|
||||
// of alice's.
|
||||
const asBob = await visit()
|
||||
|
||||
await logInWithKey(asBob, nsecFor(users.bob))
|
||||
await openSettings(asBob)
|
||||
await expect(asBob.getByRole("textbox")).toHaveValue(npubEncode(users.bob.pubkey))
|
||||
})
|
||||
|
||||
test("US-004 log in with a browser extension", async ({seed, visit}) => {
|
||||
await seed(({relay}) => {
|
||||
relay("space").room("general", {name: "General"})
|
||||
})
|
||||
|
||||
const page = await visit("/", {nip07: users.alice})
|
||||
|
||||
await page.getByRole("button", {name: "Log in"}).click()
|
||||
await page.getByRole("button", {name: "Log in with Extension"}).click()
|
||||
|
||||
await expect(page.locator(".primary-nav")).toBeVisible()
|
||||
|
||||
await openSettings(page)
|
||||
|
||||
await expect(page.getByRole("textbox")).toHaveValue(npubEncode(users.alice.pubkey))
|
||||
await expect(page.locator('input[type="password"]')).toHaveCount(0)
|
||||
|
||||
const withoutExtension = await visit()
|
||||
|
||||
await withoutExtension.getByRole("button", {name: "Log in"}).click()
|
||||
|
||||
await expect(
|
||||
withoutExtension.getByRole("button", {name: "Log in with Remote Signer"}),
|
||||
).toBeVisible()
|
||||
await expect(withoutExtension.getByRole("button", {name: "Log in with Extension"})).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-005 log in with a remote signer", async ({seed, visit}) => {
|
||||
await seed(({relay}) => {
|
||||
relay("space").room("general", {name: "General"})
|
||||
})
|
||||
|
||||
const page = await visit()
|
||||
|
||||
await page.getByRole("button", {name: "Log in"}).click()
|
||||
await page.getByRole("button", {name: "Log in with Remote Signer"}).click()
|
||||
|
||||
const bunker = page.getByPlaceholder("bunker://")
|
||||
const next = page.getByRole("button", {name: "Next"})
|
||||
|
||||
// The relay named here belongs to no scenario, so a connection attempt would be recorded as a
|
||||
// leak and fail this test rather than passing unnoticed.
|
||||
await bunker.fill("bunker://not-a-signer-pubkey?relay=wss://nowhere.test/")
|
||||
await next.click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"Sorry, it looks like that's an invalid bunker link.",
|
||||
)
|
||||
|
||||
await bunker.fill(`bunker://${users.carol.pubkey}`)
|
||||
await next.click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"That bunker link does not include any relays.",
|
||||
)
|
||||
|
||||
await page.getByRole("button", {name: "Log in with a QR code instead"}).click()
|
||||
|
||||
await expect(page.getByText("Scan with your signer to log in, or click to copy.")).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
await expect(bunker).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-006 stay logged in, and log out deliberately", async ({seed, visit}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
})
|
||||
|
||||
const npub = npubEncode(users.alice.pubkey)
|
||||
const page = await visit()
|
||||
|
||||
await logInWithKey(page, nsecFor(users.alice))
|
||||
|
||||
await expect(page.locator(".primary-nav")).toBeVisible()
|
||||
|
||||
await openSettings(page)
|
||||
|
||||
await expect(page.getByRole("textbox")).toHaveValue(npub)
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect(page.locator(".primary-nav")).toBeVisible()
|
||||
await expect(gate(page)).toHaveCount(0)
|
||||
await expect(page.getByRole("textbox")).toHaveValue(npub)
|
||||
|
||||
await page.locator(".secondary-nav").getByRole("button", {name: "Log Out"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: /Are you sure you want/})).toBeVisible()
|
||||
await expect(page.getByText("Your local database will be cleared.")).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
await expect(page.locator(".primary-nav")).toBeVisible()
|
||||
await expect(page.getByRole("textbox")).toHaveValue(npub)
|
||||
|
||||
await page.locator(".secondary-nav").getByRole("button", {name: "Log Out"}).click()
|
||||
await page.locator("form").getByRole("button", {name: "Log Out"}).click()
|
||||
|
||||
await expect(gate(page)).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-007 inspect your keys and signer status", async ({seed, as, visit}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
})
|
||||
|
||||
const npub = npubEncode(users.alice.pubkey)
|
||||
const page = await as(users.alice, "/settings/profile", {
|
||||
context: {permissions: ["clipboard-read", "clipboard-write"]},
|
||||
})
|
||||
|
||||
const readClipboard = () => page.evaluate(() => navigator.clipboard.readText())
|
||||
|
||||
const npubInput = page.getByRole("textbox")
|
||||
|
||||
await expect(npubInput).toHaveValue(npub)
|
||||
await expect(npubInput).toHaveJSProperty("readOnly", true)
|
||||
|
||||
await page.locator("label.input").filter({has: npubInput}).getByRole("button").click()
|
||||
|
||||
await expect.poll(readClipboard).toBe(npub)
|
||||
|
||||
const secretInput = page.locator('input[type="password"]')
|
||||
|
||||
await expect(secretInput).toHaveValue(users.alice.secret)
|
||||
|
||||
await page.locator("label.input").filter({has: secretInput}).getByRole("button").click()
|
||||
|
||||
await expect.poll(readClipboard).toBe(nsecFor(users.alice))
|
||||
|
||||
const signerStatus = page.locator(".card").filter({hasText: "Signer Status"}).last()
|
||||
|
||||
await expect(signerStatus).toContainText("Logged in with private key")
|
||||
await expect(signerStatus).toContainText("Ok")
|
||||
|
||||
const succeeded = async () =>
|
||||
Number(/(\d+) requests succeeded/.exec(await signerStatus.innerText())?.[1])
|
||||
|
||||
const before = await succeeded()
|
||||
|
||||
await page.locator(".card").first().getByRole("button").click()
|
||||
await page.locator('input[type="text"]').first().fill("Alice Anderson")
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
|
||||
await expect.poll(succeeded).toBeGreaterThan(before)
|
||||
|
||||
// An extension session holds no key for the app to show.
|
||||
const extension = await visit("/", {nip07: users.bob})
|
||||
|
||||
await extension.getByRole("button", {name: "Log in"}).click()
|
||||
await extension.getByRole("button", {name: "Log in with Extension"}).click()
|
||||
|
||||
await expect(extension.locator(".primary-nav")).toBeVisible()
|
||||
|
||||
await openSettings(extension)
|
||||
|
||||
await expect(extension.getByRole("textbox")).toHaveValue(npubEncode(users.bob.pubkey))
|
||||
await expect(extension.locator('input[type="password"]')).toHaveCount(0)
|
||||
await expect(extension.locator(".card").filter({hasText: "Signer Status"}).last()).toContainText(
|
||||
"Logged in with browser extension",
|
||||
)
|
||||
})
|
||||
|
||||
test("US-008 delete your nostr account", async ({seed, as, visit}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.alice, {name: "Alice Anderson"})
|
||||
})
|
||||
|
||||
const alice = await visit()
|
||||
|
||||
await logInWithKey(alice, nsecFor(users.alice))
|
||||
await openSettings(alice)
|
||||
|
||||
await alice.locator(".card").filter({hasText: "Advanced"}).getByRole("button").click()
|
||||
await alice.getByRole("button", {name: "Delete your profile"}).click()
|
||||
|
||||
await expect(alice.getByRole("heading", {name: "Delete your account"})).toBeVisible()
|
||||
|
||||
const confirm = alice.getByRole("button", {name: "Confirm"})
|
||||
const phrase = alice.locator('input[type="text"]')
|
||||
|
||||
await expect(confirm).toBeDisabled()
|
||||
|
||||
await phrase.fill("permanently delete my")
|
||||
|
||||
await expect(confirm).toBeDisabled()
|
||||
|
||||
await phrase.fill("permanently delete my nostr account")
|
||||
|
||||
await expect(confirm).toBeEnabled()
|
||||
|
||||
await confirm.click()
|
||||
|
||||
await expect(alice.getByRole("progressbar")).toBeVisible()
|
||||
await expect(gate(alice)).toBeVisible()
|
||||
|
||||
const bob = await as(users.bob, `/people/${npubEncode(users.alice.pubkey)}`)
|
||||
|
||||
await expect(bob.getByRole("heading", {name: "[deleted]"})).toBeVisible()
|
||||
})
|
||||
771
e2e/specs/people.spec.ts
Normal file
771
e2e/specs/people.spec.ts
Normal file
|
|
@ -0,0 +1,771 @@
|
|||
import {createHash} from "node:crypto"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
import {nprofileEncode, npubEncode} from "nostr-tools/nip19"
|
||||
import {HOUR, MINUTE, MONTH} from "@welshman/lib"
|
||||
import {NOTE, makeEvent} from "@welshman/util"
|
||||
import type {SignedEvent} from "@welshman/util"
|
||||
import {FollowList, Note, PinList, Profile, RelayList} from "@welshman/domain"
|
||||
import {expect, makeTestUser, mockBlossom, spacePath, test, users} from "../harness"
|
||||
import type {SeededSpace, TestUser} from "../harness"
|
||||
|
||||
// A handle to a seeded event, which only reads once seed() has drained its queue.
|
||||
type Seeded = {readonly id: string; readonly event: SignedEvent}
|
||||
|
||||
// The profile page keeps its Reputation and Spaces panels in a sidebar that only exists above
|
||||
// tailwind's xl breakpoint, and the default 1280 viewport sits exactly on it.
|
||||
const DESKTOP = {viewport: {width: 1440, height: 900}}
|
||||
|
||||
const CLIPBOARD = {...DESKTOP, permissions: ["clipboard-read", "clipboard-write"]}
|
||||
|
||||
// Where an upload lands. Both the avatar picker and the banner picker call into uploadFile with no
|
||||
// relay of their own, so the blossom probe is skipped and VITE_DEFAULT_BLOSSOM_SERVERS is used.
|
||||
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
|
||||
|
||||
// A 1x1 gif and a 1x1 webp. compressFileForUpload passes both formats through untouched rather
|
||||
// than re-encoding them through a canvas, so the bytes the server hashes are the bytes chosen
|
||||
// here and the url an upload resolves to is predictable from node.
|
||||
const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64")
|
||||
const WEBP = Buffer.from("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", "base64")
|
||||
|
||||
// uploadFile appends the extension when the descriptor's url carries none, which the mock's never
|
||||
// does.
|
||||
const uploadedUrl = (body: Buffer, extension: string) =>
|
||||
`${BLOSSOM_ORIGIN}/${createHash("sha256").update(body).digest("hex")}.${extension}`
|
||||
|
||||
const profilePath = (user: TestUser) => `/people/${npubEncode(user.pubkey)}`
|
||||
|
||||
// displayPubkey in @welshman/domain: the npub with its middle taken out.
|
||||
const shortNpub = (user: TestUser) => {
|
||||
const npub = npubEncode(user.pubkey)
|
||||
|
||||
return npub.slice(0, 8) + "…" + npub.slice(-5)
|
||||
}
|
||||
|
||||
// The page's own region, so an assertion about an avatar isn't satisfied by the copy of it the
|
||||
// nav renders.
|
||||
const pageContent = (page: Page) => page.locator(".page__content")
|
||||
|
||||
// ProfileTrust and ProfileSharedSpaces are rendered twice — stacked for narrow viewports, and in
|
||||
// the sidebar — so anything said about them is scoped to the copy that is actually on screen.
|
||||
const sidebar = (page: Page) => page.locator("aside")
|
||||
|
||||
// A modal is a `.dialog` overlay wrapping a `.dialog` card, and the card is the one with the
|
||||
// content in it.
|
||||
const dialog = (page: Page) => page.locator(".dialog").last()
|
||||
|
||||
const notes = (page: Page) => pageContent(page).locator(".cv.card")
|
||||
|
||||
const readClipboard = (page: Page) => page.evaluate(() => navigator.clipboard.readText())
|
||||
|
||||
// The "..." menu on a profile header, which is the only ghost circle button the page renders.
|
||||
const profileMenu = (page: Page) => page.locator("button.button-circle.button-ghost")
|
||||
|
||||
// PeopleItem renders its link twice, once for each breakpoint; the wide one comes first.
|
||||
const viewProfile = (card: Locator) => card.getByRole("link", {name: "View Profile"}).first()
|
||||
|
||||
// Outbox routing resolves everything about a person through their relay list, so a seeded fixture
|
||||
// is only loadable by somebody else once its author has one.
|
||||
const seedRelayList = (space: SeededSpace, user: TestUser) =>
|
||||
space.event(user, () =>
|
||||
space
|
||||
.kind(RelayList)
|
||||
.writer()
|
||||
.setReadUrls([space.url])
|
||||
.setWriteUrls([space.url])
|
||||
.renderTemplate(),
|
||||
)
|
||||
|
||||
test("US-074 find a person", async ({seed, as}) => {
|
||||
// Sixty of them: the people list starts at ten and adds another ten every second until the page
|
||||
// is taller than the scroller's threshold — and it keeps counting while the profiles are still
|
||||
// on their way, so by the time the first ones render the limit is already well past ten.
|
||||
// "Scrolling loads more" is only a fact when there are far more matches than that first fill
|
||||
// can reach.
|
||||
const searchers = Array.from({length: 60}, (_, i) => makeTestUser(`searcher-${i}`))
|
||||
const searcherName = (i: number) => `Searcher ${String(i).padStart(2, "0")}`
|
||||
const searcherAvatar = (i: number) => `https://images.test/searcher-${i}.png`
|
||||
const about = [
|
||||
"Deck crew on the northern run.",
|
||||
"Splices rope, keeps the log, argues about knots.",
|
||||
"Ask about the time the compass froze solid.",
|
||||
"Answers to a whistle and to nothing else.",
|
||||
].join("\n")
|
||||
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
|
||||
// Bob is the one person whose name shares no token with the others, so narrowing the term is
|
||||
// visibly a filter rather than a reordering.
|
||||
space.profile(user.bob, {
|
||||
name: "Bob Barnacle",
|
||||
about: "Dockside cook and keeper of the ship's cat.",
|
||||
})
|
||||
|
||||
seedRelayList(space, user.bob)
|
||||
|
||||
for (const [i, searcher] of searchers.entries()) {
|
||||
space.member(searcher)
|
||||
space.profile(searcher, {name: searcherName(i), about, picture: searcherAvatar(i)})
|
||||
}
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/people", {context: DESKTOP})
|
||||
const term = page.getByPlaceholder("Search for people...")
|
||||
const cards = page.locator(".card.card-interactive")
|
||||
|
||||
await term.fill("Searcher")
|
||||
|
||||
// Which of them ranks first is fuse's business, so a result is described by what every
|
||||
// result carries rather than by which one it turned out to be.
|
||||
const first = cards.first()
|
||||
|
||||
await expect(first).toBeVisible()
|
||||
await expect(first.locator('img[src^="https://images.test/searcher-"]')).toBeVisible()
|
||||
await expect(first).toContainText(/Searcher \d\d/)
|
||||
await expect(first).toContainText("Deck crew on the northern run.")
|
||||
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(10)
|
||||
|
||||
const shown = await cards.count()
|
||||
|
||||
expect(shown).toBeLessThan(searchers.length)
|
||||
|
||||
await cards.last().scrollIntoViewIfNeeded()
|
||||
|
||||
await expect.poll(() => cards.count()).toBeGreaterThan(shown)
|
||||
|
||||
// Narrowing the term filters the list rather than reordering it.
|
||||
await term.fill("Barnacle")
|
||||
|
||||
const bobCard = cards.filter({hasText: "Bob Barnacle"})
|
||||
|
||||
await expect(bobCard).toHaveCount(1)
|
||||
await expect(cards.filter({hasText: /Searcher \d\d/})).toHaveCount(0)
|
||||
|
||||
await viewProfile(bobCard).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
|
||||
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-075 view someone's profile", async ({seed, as}) => {
|
||||
const avatar = "https://images.test/bob-avatar.png"
|
||||
const banner = "https://images.test/bob-banner.png"
|
||||
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
|
||||
// A second space alice does not belong to, so "Member" is a claim about the overlap rather
|
||||
// than about every space bob is in.
|
||||
other.room("lounge", {name: "Lounge"})
|
||||
other.join(user.bob, "lounge")
|
||||
|
||||
space.event(user.bob, () =>
|
||||
space
|
||||
.kind(Profile)
|
||||
.writer()
|
||||
.setName("Bob Barnacle")
|
||||
.setAbout("Deckhand, dockside cook, and keeper of the ship's cat.")
|
||||
.setPicture(avatar)
|
||||
.setBanner(banner)
|
||||
.setWebsite("bobbarnacle.example")
|
||||
.renderTemplate(),
|
||||
)
|
||||
|
||||
seedRelayList(space, user.bob)
|
||||
|
||||
// Carol belongs to no space at all, which is what the panel's empty state is about.
|
||||
space.member(user.carol)
|
||||
space.profile(user.carol, {name: "Carol Cutter"})
|
||||
seedRelayList(space, user.carol)
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
const other = scenario.space("other")
|
||||
const page = await as(users.alice, profilePath(users.bob), {context: CLIPBOARD})
|
||||
const region = pageContent(page)
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
|
||||
// The header renders the avatar twice, one size per breakpoint; the wide one is second.
|
||||
await expect(region.locator(`img[src="${avatar}"]`).last()).toBeVisible()
|
||||
await expect(region.locator(`img[src="${banner}"]`)).toBeVisible()
|
||||
await expect(region.getByText("Deckhand, dockside cook")).toBeVisible()
|
||||
|
||||
const npub = region.getByText(shortNpub(users.bob))
|
||||
|
||||
await expect(npub).toBeVisible()
|
||||
|
||||
await npub.locator("xpath=following-sibling::button").click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
|
||||
await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey))
|
||||
|
||||
await expect(region.getByRole("link", {name: "bobbarnacle.example"})).toHaveAttribute(
|
||||
"href",
|
||||
"https://bobbarnacle.example",
|
||||
)
|
||||
|
||||
const spaces = sidebar(page).locator(".card.card-sm").filter({hasText: "Spaces"})
|
||||
const spaceLink = spaces.locator(`a[href="${spacePath(space.url)}"]`)
|
||||
const otherLink = spaces.locator(`a[href="${spacePath(other.url)}"]`)
|
||||
|
||||
await expect(spaces.locator(".badge").first()).toHaveText("2")
|
||||
await expect(spaceLink).toContainText("Member")
|
||||
await expect(otherLink).toBeVisible()
|
||||
await expect(otherLink.locator(".badge").filter({hasText: "Member"})).toHaveCount(0)
|
||||
|
||||
await spaceLink.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(spacePath(space.url)))
|
||||
|
||||
await page.goto(profilePath(users.carol))
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Carol Cutter"})).toBeVisible()
|
||||
await expect(sidebar(page).getByText("No spaces found.")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-076 follow and unfollow", async ({seed, as}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.bob, {name: "Bob Barnacle"})
|
||||
seedRelayList(space, user.alice)
|
||||
seedRelayList(space, user.bob)
|
||||
})
|
||||
|
||||
const page = await as(users.alice, profilePath(users.bob), {context: DESKTOP})
|
||||
const follow = page.getByRole("button", {name: "Follow", exact: true})
|
||||
const unfollow = page.getByRole("button", {name: "Unfollow", exact: true})
|
||||
|
||||
await expect(follow).toBeVisible()
|
||||
|
||||
await follow.click()
|
||||
|
||||
// The same page, still on the same url: the label flips where it stands.
|
||||
await expect(unfollow).toBeVisible()
|
||||
await expect(follow).toHaveCount(0)
|
||||
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
|
||||
|
||||
await unfollow.click()
|
||||
|
||||
await expect(follow).toBeVisible()
|
||||
await expect(unfollow).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
|
||||
const carolAvatar = "https://images.test/carol-avatar.png"
|
||||
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.join(user.carol, "general")
|
||||
space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."})
|
||||
space.profile(user.carol, {name: "Carol Cutter", about: "Sailmaker.", picture: carolAvatar})
|
||||
seedRelayList(space, user.alice)
|
||||
seedRelayList(space, user.bob)
|
||||
seedRelayList(space, user.carol)
|
||||
|
||||
// Carol already follows bob, so alice following carol is the one thing that has to happen
|
||||
// through the ui for his standing to move.
|
||||
space.event(user.carol, () =>
|
||||
space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(),
|
||||
)
|
||||
})
|
||||
|
||||
// Every hop here is a link click rather than a navigation, so the follow alice publishes stays
|
||||
// in the client that published it.
|
||||
const page = await as(users.alice, "/people", {context: DESKTOP})
|
||||
const term = page.getByPlaceholder("Search for people...")
|
||||
const cards = page.locator(".card.card-interactive")
|
||||
const bobCard = cards.filter({hasText: "Bob Barnacle"})
|
||||
const carolCard = cards.filter({hasText: "Carol Cutter"})
|
||||
const reputation = () => sidebar(page).locator(".card.card-sm").filter({hasText: "Reputation"})
|
||||
|
||||
// The trust ring beside a name: a circle whose dash offset shrinks as the ring fills in.
|
||||
const ring = async () =>
|
||||
Number(await bobCard.locator("circle.wot-highlight").getAttribute("stroke-dashoffset"))
|
||||
|
||||
await term.fill("Barnacle")
|
||||
|
||||
await expect(bobCard).toHaveCount(1)
|
||||
await expect(bobCard.locator("circle.wot-highlight")).toBeAttached()
|
||||
|
||||
const emptyRing = await ring()
|
||||
|
||||
await viewProfile(bobCard).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
|
||||
// Word-bounded: a plain "0 / 100" is also a substring of "10 / 100" and "20 / 100", which are
|
||||
// exactly the readings this is supposed to rule out.
|
||||
await expect(reputation()).toContainText(/\b0 \/ 100\b/)
|
||||
await expect(reputation()).toContainText("This user is not well known in your network.")
|
||||
|
||||
await page.locator('.primary-nav a[href="/people"]').click()
|
||||
await term.fill("Cutter")
|
||||
|
||||
await expect(carolCard).toHaveCount(1)
|
||||
|
||||
await viewProfile(carolCard).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${profilePath(users.carol)}$`))
|
||||
|
||||
await page.getByRole("button", {name: "Follow", exact: true}).click()
|
||||
|
||||
await expect(page.getByRole("button", {name: "Unfollow", exact: true})).toBeVisible()
|
||||
|
||||
await page.locator('.primary-nav a[href="/people"]').click()
|
||||
await term.fill("Barnacle")
|
||||
|
||||
await expect(bobCard).toHaveCount(1)
|
||||
await expect.poll(ring).toBeLessThan(emptyRing)
|
||||
|
||||
await viewProfile(bobCard).click()
|
||||
|
||||
await expect(reputation()).toContainText("Followed by 1+ people in your network.")
|
||||
await expect(reputation()).toContainText("1 person you follow also follow Bob Barnacle.")
|
||||
await expect(reputation().locator(`img[src="${carolAvatar}"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-078 edit your own profile", async ({seed, as}) => {
|
||||
const oldAvatar = "https://images.test/alice-avatar.png"
|
||||
const newAvatar = uploadedUrl(WEBP, "webp")
|
||||
const newBanner = uploadedUrl(GIF, "gif")
|
||||
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.event(user.alice, () =>
|
||||
space
|
||||
.kind(Profile)
|
||||
.writer()
|
||||
.setName("Alice Anderson")
|
||||
.setAbout("Ship's navigator.")
|
||||
.setPicture(oldAvatar)
|
||||
.setNip05("alice@flotilla.test")
|
||||
.renderTemplate(),
|
||||
)
|
||||
seedRelayList(space, user.alice)
|
||||
})
|
||||
|
||||
const page = await as(users.alice, profilePath(users.alice), {context: DESKTOP})
|
||||
|
||||
await mockBlossom(page.context(), {server: BLOSSOM_ORIGIN})
|
||||
|
||||
const region = pageContent(page)
|
||||
const edit = page.getByRole("button", {name: "Edit profile"})
|
||||
|
||||
await expect(edit).toBeVisible()
|
||||
await expect(page.getByRole("button", {name: "Follow", exact: true})).toHaveCount(0)
|
||||
// Exact, or this would match the nav's "Messages".
|
||||
await expect(page.getByRole("button", {name: "Message", exact: true})).toHaveCount(0)
|
||||
|
||||
await edit.click()
|
||||
|
||||
// ProfileEditForm's text inputs, in the order it renders them: nickname, website, nostr address.
|
||||
const form = page.locator("form").first()
|
||||
const nickname = form.locator('input[type="text"]').first()
|
||||
const about = form.locator("textarea")
|
||||
const nostrAddress = form.locator('input[type="text"]').nth(2)
|
||||
const picker = page.locator('label[aria-label="Drag and drop files here."]')
|
||||
|
||||
await expect(nickname).toHaveValue("Alice Anderson")
|
||||
await expect(about).toHaveValue("Ship's navigator.")
|
||||
await expect(nostrAddress).toHaveValue("alice@flotilla.test")
|
||||
await expect.poll(() => picker.getAttribute("style")).toContain(oldAvatar)
|
||||
|
||||
await nickname.fill("Alice Anchor")
|
||||
await about.fill("Ship's navigator and part-time cartographer.")
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
|
||||
await expect(page.getByRole("heading", {name: "Alice Anchor"})).toBeVisible()
|
||||
await expect(region.getByText("part-time cartographer")).toBeVisible()
|
||||
|
||||
await edit.click()
|
||||
|
||||
const avatarChooser = page.waitForEvent("filechooser")
|
||||
|
||||
await picker.click()
|
||||
await (
|
||||
await avatarChooser
|
||||
).setFiles({
|
||||
name: "avatar.webp",
|
||||
mimeType: "image/webp",
|
||||
buffer: WEBP,
|
||||
})
|
||||
|
||||
await expect.poll(() => picker.getAttribute("style")).toContain(newAvatar)
|
||||
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(region.locator(`img[src="${newAvatar}"]`).last()).toBeVisible()
|
||||
|
||||
const bannerChooser = page.waitForEvent("filechooser")
|
||||
|
||||
await page.getByRole("button", {name: "Change banner"}).click()
|
||||
await (await bannerChooser).setFiles({name: "banner.gif", mimeType: "image/gif", buffer: GIF})
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Banner updated.")
|
||||
await expect(region.locator(`img[src="${newBanner}"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-079 read a person's notes", async ({seed, as}) => {
|
||||
const avatar = "https://images.test/alice-avatar.png"
|
||||
|
||||
let carolNote!: Seeded
|
||||
let pinned!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.join(user.carol, "general")
|
||||
space.profile(user.alice, {name: "Alice Anderson", picture: avatar})
|
||||
space.profile(user.carol, {name: "Carol Cutter"})
|
||||
seedRelayList(space, user.alice)
|
||||
seedRelayList(space, user.carol)
|
||||
|
||||
carolNote = space.event(
|
||||
user.carol,
|
||||
makeEvent(NOTE, {content: "Anyone seen the tide charts?", created_at: at(4, HOUR)}),
|
||||
)
|
||||
|
||||
pinned = space.event(
|
||||
user.alice,
|
||||
makeEvent(NOTE, {content: "PINNED how to read a tide chart", created_at: at(3, HOUR)}),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.alice,
|
||||
makeEvent(NOTE, {content: "MIDDLE rigging notes from this morning", created_at: at(2, HOUR)}),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.alice,
|
||||
makeEvent(NOTE, {content: "NEWEST the wind has finally turned", created_at: at(1, HOUR)}),
|
||||
)
|
||||
|
||||
// Older than the window the feed opens with, so reaching it is the feed paging backwards.
|
||||
space.event(
|
||||
user.alice,
|
||||
makeEvent(NOTE, {content: "OLDEST from the spring refit", created_at: at(8, MONTH)}),
|
||||
)
|
||||
|
||||
// A reply to carol, which a profile's note list leaves out.
|
||||
space.event(
|
||||
user.alice,
|
||||
() =>
|
||||
space
|
||||
.kind(Note)
|
||||
.writer()
|
||||
.setParent(carolNote.event)
|
||||
.setContent("REPLY they are in the chart drawer")
|
||||
.renderTemplate(),
|
||||
at(90, MINUTE),
|
||||
)
|
||||
|
||||
space.event(user.alice, () =>
|
||||
space.kind(PinList).writer().pinPublicly(["e", pinned.id]).renderTemplate(),
|
||||
)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.bob, profilePath(users.alice), {context: DESKTOP})
|
||||
const list = notes(page)
|
||||
const newest = list.filter({hasText: "NEWEST"})
|
||||
|
||||
await expect(newest).toBeVisible()
|
||||
await expect(newest.getByText("Alice Anderson")).toBeVisible()
|
||||
await expect(newest.locator(`img[src="${avatar}"]`)).toBeVisible()
|
||||
// The story asks for a relative timestamp. NoteCard renders formatTimestamp, which is a short
|
||||
// date plus a clock time, so this is the story's reading rather than the app's.
|
||||
await expect(newest).toContainText(/\d+ (second|minute|hour|day)s? ago/)
|
||||
|
||||
await expect(list.filter({hasText: "REPLY"})).toHaveCount(0)
|
||||
|
||||
// The pin outranks the newer notes; the rest are newest first.
|
||||
await expect(list.first()).toContainText("PINNED")
|
||||
await expect(list.nth(1)).toContainText("NEWEST")
|
||||
await expect(list.nth(2)).toContainText("MIDDLE")
|
||||
|
||||
// Nothing is scrolled here: the feed keeps widening its window until the page is full, which is
|
||||
// what "loads older notes automatically" means.
|
||||
await expect(list.filter({hasText: "OLDEST"})).toBeVisible()
|
||||
|
||||
// A note alice publishes while bob is looking. Flotilla has no composer for a kind-1 note, so
|
||||
// it goes out through the app's own primitives in her signed-in session, over her socket to the
|
||||
// space — which is the half of this the story is about.
|
||||
const alice = await as(users.alice, spacePath(url), {context: DESKTOP})
|
||||
|
||||
await alice.evaluate(
|
||||
async ([relayUrl, content]) => {
|
||||
const {
|
||||
app,
|
||||
network,
|
||||
makeEvent: make,
|
||||
} = window as unknown as {
|
||||
app: {get(): {user: {sign(template: object): Promise<object>}}}
|
||||
network: {get(): {publish(options: {event: object; relays: string[]}): Promise<unknown>}}
|
||||
makeEvent(kind: number, values: {content: string}): object
|
||||
}
|
||||
|
||||
await network.get().publish({
|
||||
event: await app.get().user.sign(make(1, {content})),
|
||||
relays: [relayUrl],
|
||||
})
|
||||
},
|
||||
[url, "LIVE straight off the deck"] as const,
|
||||
)
|
||||
|
||||
// Above every unpinned note, without bob having reloaded anything.
|
||||
await expect(list.filter({hasText: "LIVE"})).toBeVisible()
|
||||
await expect(list.nth(1)).toContainText("LIVE")
|
||||
|
||||
// Carol has posted nothing at all.
|
||||
await page.goto(profilePath(users.carol))
|
||||
|
||||
await expect(page.getByText("Loading notes...")).toBeVisible()
|
||||
await expect(page.getByText("No notes found for this profile.")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-080 preview a profile from anywhere", async ({seed, as}) => {
|
||||
const avatar = "https://images.test/bob-avatar.png"
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.bob, {
|
||||
name: "Bob Barnacle",
|
||||
about: "Deckhand, dockside cook, and keeper of the ship's cat.",
|
||||
picture: avatar,
|
||||
})
|
||||
space.message(user.bob, "general", "the cat has the helm", at(1, HOUR))
|
||||
seedRelayList(space, user.bob)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, `${spacePath(url)}/directory`, {context: DESKTOP})
|
||||
// SpaceMember covers its whole card with one button, whose aria-label is interpolated from
|
||||
// `$profiles.display(pubkey).get()` — a plain call, so the label keeps whatever the name was at
|
||||
// first render, which is the npub the card falls back to before the profile has loaded. The
|
||||
// name it *displays* comes from a store and does update, so the card is found by that.
|
||||
const preview = page
|
||||
.locator(".card.card-interactive")
|
||||
.filter({hasText: "Bob Barnacle"})
|
||||
.locator("button[aria-label]")
|
||||
.first()
|
||||
|
||||
await expect(preview).toBeVisible()
|
||||
|
||||
await preview.click()
|
||||
|
||||
await expect(dialog(page).getByText("Bob Barnacle")).toBeVisible()
|
||||
await expect(dialog(page).locator(`img[src="${avatar}"]`)).toBeVisible()
|
||||
await expect(dialog(page).getByText("Deckhand, dockside cook")).toBeVisible()
|
||||
await expect(dialog(page).getByText(/Last active/)).toBeVisible()
|
||||
|
||||
await dialog(page).getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
// Closed, and alice is exactly where she opened it from.
|
||||
await expect(page.locator(".dialog")).toHaveCount(0)
|
||||
await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`))
|
||||
await expect(preview).toBeVisible()
|
||||
|
||||
await preview.click()
|
||||
await page.getByRole("button", {name: "View Full Profile"}).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
|
||||
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-081 inspect and share a profile", async ({seed, as}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."})
|
||||
seedRelayList(space, user.bob)
|
||||
})
|
||||
|
||||
const page = await as(users.alice, profilePath(users.bob), {context: CLIPBOARD})
|
||||
|
||||
await profileMenu(page).click()
|
||||
await page.getByRole("button", {name: "Profile Info"}).click()
|
||||
|
||||
await expect(page.getByRole("heading", {name: "Profile Details"})).toBeVisible()
|
||||
|
||||
const info = dialog(page)
|
||||
const link = info.locator('input[type="text"]').first()
|
||||
const pubkey = info.locator('input[type="text"]').nth(1)
|
||||
|
||||
// The profile was signed during this test, so the creation date is today's. A FieldInline puts
|
||||
// its value in the div right after its label, which keeps this a claim about the date rather
|
||||
// than about anything else in the dialog that happens to carry four digits.
|
||||
const createdAt = info
|
||||
.locator("label")
|
||||
.filter({hasText: "Created At"})
|
||||
.locator("xpath=following-sibling::div")
|
||||
|
||||
await expect(createdAt).toContainText(String(new Date().getFullYear()))
|
||||
|
||||
await expect(link).toHaveValue(/^nostr:nprofile1/)
|
||||
await expect(pubkey).toHaveValue(npubEncode(users.bob.pubkey))
|
||||
await expect(info.locator("pre code")).toContainText("Bob Barnacle")
|
||||
|
||||
await info.locator("label.input").filter({has: link}).getByRole("button").click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
|
||||
await expect.poll(() => readClipboard(page)).toBe(await link.inputValue())
|
||||
|
||||
await info.locator("label.input").filter({has: pubkey}).getByRole("button").click()
|
||||
|
||||
await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey))
|
||||
|
||||
await info.getByRole("button", {name: "Copy"}).click()
|
||||
|
||||
await expect.poll(() => readClipboard(page)).toContain("Bob Barnacle")
|
||||
|
||||
await page.getByRole("button", {name: "Got it"}).click()
|
||||
|
||||
await profileMenu(page).click()
|
||||
await page.getByRole("button", {name: "Share"}).click()
|
||||
|
||||
await expect(page.getByText("Share Profile")).toBeVisible()
|
||||
|
||||
const share = dialog(page)
|
||||
const qr = share.locator("canvas")
|
||||
const shareLink = share.locator("input")
|
||||
|
||||
await expect(qr).toBeVisible()
|
||||
expect((await qr.boundingBox())?.width ?? 0).toBeGreaterThan(0)
|
||||
|
||||
// pubkeyLink in src/app/env.ts, which shares a profile without relay hints.
|
||||
await expect(shareLink).toHaveValue(
|
||||
`https://coracle.social/${nprofileEncode({pubkey: users.bob.pubkey, relays: []})}`,
|
||||
)
|
||||
|
||||
await share.locator("label.input").getByRole("button").click()
|
||||
|
||||
await expect.poll(() => readClipboard(page)).toBe(await shareLink.inputValue())
|
||||
})
|
||||
|
||||
test("US-082 mute an account", async ({seed, as}) => {
|
||||
await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."})
|
||||
seedRelayList(space, user.alice)
|
||||
seedRelayList(space, user.bob)
|
||||
|
||||
space.event(
|
||||
user.bob,
|
||||
makeEvent(NOTE, {content: "Salt on the rigging this morning", created_at: at(2, HOUR)}),
|
||||
)
|
||||
|
||||
space.event(
|
||||
user.bob,
|
||||
makeEvent(NOTE, {content: "The gulls have taken the mainsail", created_at: at(1, HOUR)}),
|
||||
)
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/settings/content", {context: DESKTOP})
|
||||
const badge = page.locator(".badge").filter({hasText: "Bob Barnacle"})
|
||||
const save = page.getByRole("button", {name: "Save Changes"})
|
||||
|
||||
// Typed rather than filled, and slower than the search's own debounce: the suggestion list is
|
||||
// rebuilt per keystroke, and the profile it is searching for only arrives from the relay once a
|
||||
// pause in the typing has let the query go out.
|
||||
await page
|
||||
.getByPlaceholder("Search for profiles...")
|
||||
.pressSequentially("Bob Barnacle", {delay: 700})
|
||||
|
||||
const suggestion = page.locator(".tiptap-suggestions__item").filter({hasText: "Bob Barnacle"})
|
||||
|
||||
await expect(suggestion).toBeVisible()
|
||||
|
||||
await suggestion.click()
|
||||
|
||||
await expect(badge).toBeVisible()
|
||||
|
||||
await save.click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
|
||||
|
||||
// Through the nav rather than a fresh navigation, so what is on screen is what the client that
|
||||
// just published the mute believes.
|
||||
const openBobsProfile = async () => {
|
||||
await page.locator('.primary-nav a[href="/people"]').click()
|
||||
await page.getByPlaceholder("Search for people...").fill("Barnacle")
|
||||
|
||||
const card = page.locator(".card.card-interactive").filter({hasText: "Bob Barnacle"})
|
||||
|
||||
await expect(card).toHaveCount(1)
|
||||
await viewProfile(card).click()
|
||||
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
|
||||
}
|
||||
|
||||
await openBobsProfile()
|
||||
|
||||
const list = notes(page)
|
||||
|
||||
await expect(list).toHaveCount(2)
|
||||
await expect(list.first()).toContainText("You have muted this person.")
|
||||
await expect(list.last()).toContainText("You have muted this person.")
|
||||
await expect(pageContent(page).getByText("Salt on the rigging")).toHaveCount(0)
|
||||
|
||||
await list.first().getByRole("button", {name: "Show anyway"}).click()
|
||||
|
||||
await expect(list.first()).toContainText("The gulls have taken the mainsail")
|
||||
await expect(list.last()).toContainText("You have muted this person.")
|
||||
|
||||
await page.locator('.primary-nav a[href="/settings/profile"]').click()
|
||||
await page.getByRole("link", {name: "Content"}).click()
|
||||
|
||||
await expect(badge).toBeVisible()
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect(badge).toBeVisible()
|
||||
|
||||
await badge.getByRole("button").first().click()
|
||||
|
||||
await expect(badge).toHaveCount(0)
|
||||
|
||||
await save.click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
|
||||
|
||||
await openBobsProfile()
|
||||
|
||||
await expect(list).toHaveCount(2)
|
||||
await expect(pageContent(page).getByText("Salt on the rigging this morning")).toBeVisible()
|
||||
await expect(pageContent(page).getByText("The gulls have taken the mainsail")).toBeVisible()
|
||||
await expect(pageContent(page).getByText("You have muted this person.")).toHaveCount(0)
|
||||
})
|
||||
771
e2e/specs/rooms.spec.ts
Normal file
771
e2e/specs/rooms.spec.ts
Normal file
|
|
@ -0,0 +1,771 @@
|
|||
import {DAY, HOUR, MINUTE, WEEK} from "@welshman/lib"
|
||||
import {MessagingRelayList, RelayList, displayPubkey} from "@welshman/domain"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
import {expect, makeTestUser, roomPath, spacePath, test, users} from "../harness"
|
||||
import type {SeededRumor, SeededSpace, TestUser} from "../harness"
|
||||
|
||||
// A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id and
|
||||
// timestamp are ever read back here.
|
||||
type Seeded = {readonly id: string; readonly event: {readonly created_at: number}}
|
||||
|
||||
// A path as a pattern, for a url that carries a query string or a modal's hash alongside it.
|
||||
const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
|
||||
// The panel of the modal carrying a given title. `.dialog` is on both the backdrop wrapper and the
|
||||
// panel inside it, so the last match is the panel.
|
||||
const dialog = (page: Page, title: string) =>
|
||||
page
|
||||
.locator(".dialog")
|
||||
.filter({has: page.getByRole("heading", {name: title, exact: true})})
|
||||
.last()
|
||||
|
||||
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
|
||||
|
||||
// .room__content is column-reverse, so the message at the bottom of the room is the first one in
|
||||
// the dom.
|
||||
const messages = (page: Page) => page.locator(".room__item")
|
||||
|
||||
const message = (page: Page, text: string) => messages(page).filter({hasText: text})
|
||||
|
||||
// RoomItem gives its hover actions no accessible names — every one is an icon. Their order is
|
||||
// fixed by the component: zap, emoji, reply, edit (only on your own recent message), menu.
|
||||
const messageActions = (page: Page, text: string) =>
|
||||
message(page, text).locator(".room__item-actions button")
|
||||
|
||||
const openMessageMenu = (page: Page, text: string) => messageActions(page, text).last().click()
|
||||
|
||||
// Each of these menus hides itself once the pointer leaves it, which is how one is dismissed
|
||||
// without clicking an item. The popper sits against the right hand end of the message, so the top
|
||||
// left corner of the viewport is outside it.
|
||||
const dismissMenu = (page: Page) => page.mouse.move(0, 0)
|
||||
|
||||
// The room's page bar carries a back arrow, a search button and the detail button, in that order.
|
||||
// The back arrow is display:none at this viewport, so a role query sees the other two.
|
||||
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
|
||||
|
||||
const openRoomDetail = (page: Page) => pageBar(page).getByRole("button").last().click()
|
||||
|
||||
// RoomDetail has no title of its own — the room's name stands in for one — so it is named by the
|
||||
// permissions card only it renders. Scoped to the modal's body rather than the dialog, so that the
|
||||
// dialog's own close button isn't the first button in it.
|
||||
const roomDetail = (page: Page) =>
|
||||
page.locator(".scroll-container").filter({hasText: "Room Permissions"})
|
||||
|
||||
const openRoomDetailMenu = (page: Page) => roomDetail(page).getByRole("button").first().click()
|
||||
|
||||
// The space menu's sections are flat siblings — a header, then the rooms under it — so which
|
||||
// section a room is in is a question about document order rather than nesting.
|
||||
const roomSection = (page: Page, name: string) =>
|
||||
page.locator(".space-menu__scroll").evaluate((menu, roomName) => {
|
||||
let section: string | undefined
|
||||
|
||||
for (const node of menu.querySelectorAll(".secondary-nav__header, .secondary-nav__nav-item")) {
|
||||
if (node.classList.contains("secondary-nav__header")) {
|
||||
section = node.textContent?.trim()
|
||||
} else if (node.textContent?.trim() === roomName) {
|
||||
return section
|
||||
}
|
||||
}
|
||||
}, name)
|
||||
|
||||
const roomLink = (page: Page, name: string) =>
|
||||
page.locator(".space-menu__scroll").getByRole("link", {name})
|
||||
|
||||
// A FieldInline puts its control in the div immediately after its label.
|
||||
const field = (form: Locator, label: string) =>
|
||||
form
|
||||
.locator("label")
|
||||
.filter({hasText: label})
|
||||
.locator("xpath=following-sibling::div")
|
||||
.locator("input[type=text]")
|
||||
|
||||
// RoomForm's permission toggles are a bare checkbox beside their own text rather than a labelled
|
||||
// control.
|
||||
const permission = (form: Locator, label: string) =>
|
||||
form.getByText(label).locator("xpath=preceding-sibling::input")
|
||||
|
||||
const reactionPill = (page: Page, text: string) =>
|
||||
message(page, text).getByRole("button", {name: /🎉/})
|
||||
|
||||
// The picker is a web component with an open shadow root, so its search field and its results are
|
||||
// reachable through it. Searching rather than browsing avoids depending on which category tab an
|
||||
// emoji happens to live under. A tippy keeps its content mounted after it hides, so the visible
|
||||
// one is the picker that was just opened.
|
||||
const react = async (page: Page, opener: Locator) => {
|
||||
await opener.click()
|
||||
|
||||
const picker = page.locator("emoji-picker").filter({visible: true})
|
||||
|
||||
await picker.locator("input.search").fill("party popper")
|
||||
await picker.locator('[role="option"][aria-label*="party popper"]').first().click()
|
||||
}
|
||||
|
||||
const send = async (page: Page, content: string) => {
|
||||
await composer(page).pressSequentially(content)
|
||||
await composer(page).press("Enter")
|
||||
}
|
||||
|
||||
// Outbox routing resolves everything about a person through their relay list, and a gift wrap only
|
||||
// reaches somebody who has said where their messages go.
|
||||
const seedChatter = (space: SeededSpace, user: TestUser) => {
|
||||
space.event(user, () =>
|
||||
space
|
||||
.kind(RelayList)
|
||||
.writer()
|
||||
.setReadUrls([space.url])
|
||||
.setWriteUrls([space.url])
|
||||
.renderTemplate(),
|
||||
)
|
||||
space.event(user, () =>
|
||||
space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(),
|
||||
)
|
||||
}
|
||||
|
||||
test("US-018 send and receive a room message in real time", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.alice, {name: "Alice Anchor", picture: "https://images.test/alice.png"})
|
||||
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const path = roomPath(url, "general")
|
||||
|
||||
// Two browser contexts, two identities, one relay: bob's page is already listening when alice
|
||||
// sends, so the message reaches him over the wire.
|
||||
const alice = await as(users.alice, path)
|
||||
const bob = await as(users.bob, path)
|
||||
|
||||
await expect(message(bob, "morning all")).toBeVisible()
|
||||
|
||||
await send(alice, "anyone there?")
|
||||
|
||||
await expect(composer(alice)).toHaveText("")
|
||||
|
||||
const sent = messages(alice).first()
|
||||
|
||||
await expect(sent).toContainText("anyone there?")
|
||||
await expect(sent).toContainText("Alice Anchor")
|
||||
await expect(sent).toContainText(/Today at \d/)
|
||||
await expect(sent.locator('img[src="https://images.test/alice.png"]')).toBeVisible()
|
||||
|
||||
await expect(message(bob, "anyone there?")).toBeVisible()
|
||||
|
||||
// Shift+Enter breaks the line rather than sending it
|
||||
await composer(alice).pressSequentially("first line")
|
||||
await composer(alice).press("Shift+Enter")
|
||||
await composer(alice).pressSequentially("second line")
|
||||
|
||||
await expect(composer(alice).locator("br")).toHaveCount(1)
|
||||
await expect(messages(alice)).toHaveCount(2)
|
||||
|
||||
// ...and cmd/ctrl+enter sends from the middle of it
|
||||
await composer(alice).press("ArrowUp")
|
||||
await composer(alice).press("ControlOrMeta+Enter")
|
||||
|
||||
await expect(composer(alice)).toHaveText("")
|
||||
await expect(message(alice, "second line")).toContainText("first line")
|
||||
await expect(message(bob, "second line")).toContainText("first line")
|
||||
})
|
||||
|
||||
test("US-019 join and leave a room", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
// Bob belongs to the space but to none of its rooms, so joining one is his to do.
|
||||
space.join(user.bob)
|
||||
space.profile(user.bob, {name: "Bob Barnacle"})
|
||||
space.message(user.alice, "general", "morning all", at(2, HOUR))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const path = roomPath(url, "general")
|
||||
|
||||
const bob = await as(users.bob, path)
|
||||
const alice = await as(users.alice, path)
|
||||
|
||||
await expect(message(bob, "morning all")).toBeVisible()
|
||||
await expect.poll(() => roomSection(bob, "General")).toBe("Rooms")
|
||||
|
||||
await openRoomDetail(bob)
|
||||
await openRoomDetailMenu(bob)
|
||||
await bob.getByRole("button", {name: "Join member list"}).click()
|
||||
|
||||
await expect.poll(() => roomSection(bob, "General")).toBe("Your Rooms")
|
||||
|
||||
// Everyone else in the room watches him arrive
|
||||
const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"})
|
||||
|
||||
await expect(joined).toBeVisible()
|
||||
|
||||
await joined.getByRole("button", {name: "@Bob Barnacle"}).click()
|
||||
|
||||
await expect(alice.getByRole("button", {name: "View Full Profile"})).toBeVisible()
|
||||
|
||||
await openRoomDetailMenu(bob)
|
||||
await bob.getByRole("button", {name: "Leave member list"}).click()
|
||||
|
||||
await expect(bob.getByRole("alert")).toHaveClass(/text-content/)
|
||||
|
||||
await openRoomDetailMenu(bob)
|
||||
|
||||
await expect(bob.getByRole("button", {name: "Join member list"})).toBeVisible()
|
||||
await expect.poll(() => roomSection(bob, "General")).toBe("Rooms")
|
||||
})
|
||||
|
||||
test("US-020 create, edit, and delete a room", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.admin, "general")
|
||||
space.join(user.bob, "general")
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const admin = await as(users.admin, roomPath(url, "general"))
|
||||
|
||||
await admin.getByRole("button", {name: "Create room"}).click()
|
||||
|
||||
const createForm = dialog(admin, "Create a Room")
|
||||
|
||||
await field(createForm, "Name").fill("Ship Log")
|
||||
await field(createForm, "Description").fill("Where the watch writes things down")
|
||||
await createForm.getByRole("button", {name: "Create Room"}).click()
|
||||
|
||||
await expect(pageBar(admin)).toContainText("Ship Log")
|
||||
await expect.poll(() => roomSection(admin, "Ship Log")).toBe("Your Rooms")
|
||||
|
||||
// The room id is generated, so it comes back off the url the form navigated to
|
||||
const h = new URL(admin.url()).pathname.split("/").pop()!
|
||||
|
||||
await openRoomDetail(admin)
|
||||
await openRoomDetailMenu(admin)
|
||||
await admin.getByRole("button", {name: "Edit Room"}).click()
|
||||
|
||||
const editForm = dialog(admin, "Edit a Room")
|
||||
|
||||
await field(editForm, "Name").fill("Captain's Log")
|
||||
await permission(editForm, "Only allow members to read messages").check()
|
||||
await editForm.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(pageBar(admin)).toContainText("Captain's Log")
|
||||
await expect(roomLink(admin, "Captain's Log")).toBeVisible()
|
||||
|
||||
await openRoomDetail(admin)
|
||||
|
||||
await expect(roomDetail(admin).getByRole("button", {name: "Private"})).toBeVisible()
|
||||
|
||||
await openRoomDetailMenu(admin)
|
||||
await admin.getByRole("button", {name: "Edit Room"}).click()
|
||||
|
||||
await expect(field(editForm, "Name")).toHaveValue("Captain's Log")
|
||||
await expect(permission(editForm, "Only allow members to read messages")).toBeChecked()
|
||||
|
||||
await editForm.getByRole("button", {name: "Go back"}).click()
|
||||
await openRoomDetailMenu(admin)
|
||||
await admin.getByRole("button", {name: "Delete Room"}).click()
|
||||
|
||||
await expect(
|
||||
admin.getByRole("heading", {name: "Are you sure you want to delete this room?"}),
|
||||
).toBeVisible()
|
||||
|
||||
await admin.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(admin).not.toHaveURL(pathPattern(h))
|
||||
await expect(admin).toHaveURL(pathPattern(spacePath(url)))
|
||||
await expect(roomLink(admin, "General")).toBeVisible()
|
||||
await expect(roomLink(admin, "Captain's Log")).toHaveCount(0)
|
||||
|
||||
// Room management is the relay's business as much as the ui's, and bob has neither
|
||||
const bob = await as(users.bob, roomPath(url, "general"))
|
||||
|
||||
await openRoomDetail(bob)
|
||||
await openRoomDetailMenu(bob)
|
||||
|
||||
await expect(bob.getByRole("button", {name: "Leave member list"})).toBeVisible()
|
||||
await expect(bob.getByRole("button", {name: "Edit Room"})).toHaveCount(0)
|
||||
await expect(bob.getByRole("button", {name: "Delete Room"})).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-021 request access to a private room and get approved", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
// `private` is what hides the room's history from non-members; `closed` is what keeps a join
|
||||
// request from admitting her on its own, so an admin has to act on it.
|
||||
space.room("wardroom", {name: "Wardroom", private: true, closed: true})
|
||||
space.join(user.admin, "general", "wardroom")
|
||||
space.join(user.alice, "general", "wardroom")
|
||||
// Carol belongs to the space, which is what lets her see that the room exists at all.
|
||||
space.join(user.carol)
|
||||
space.profile(user.carol, {name: "Carol Cutter"})
|
||||
space.message(user.alice, "wardroom", "the charts are in the locker", at(2, HOUR))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const path = roomPath(url, "wardroom")
|
||||
|
||||
const carol = await as(users.carol, path)
|
||||
const admin = await as(users.admin, path)
|
||||
|
||||
await expect(message(admin, "the charts are in the locker")).toBeVisible()
|
||||
|
||||
await expect(carol.getByText("You aren't currently a member of this room.")).toBeVisible()
|
||||
await expect(carol.getByText("the charts are in the locker")).toHaveCount(0)
|
||||
|
||||
await carol.getByRole("button", {name: "Ask to Join"}).click()
|
||||
|
||||
await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible()
|
||||
|
||||
await admin.getByRole("button", {name: /space\.test/}).click()
|
||||
await admin.getByRole("button", {name: /Action Items/}).click()
|
||||
|
||||
const request = admin.locator(".card").filter({hasText: "requested membership in"})
|
||||
|
||||
await expect(request).toContainText("Carol Cutter")
|
||||
await expect(request).toContainText("Wardroom")
|
||||
|
||||
await request.getByRole("button", {name: "Accept"}).click()
|
||||
|
||||
await expect(admin.getByText("Member has been added to the room!")).toBeVisible()
|
||||
|
||||
await expect(carol.getByText("the charts are in the locker")).toBeVisible()
|
||||
|
||||
await send(carol, "found them, thanks")
|
||||
|
||||
await expect(message(carol, "found them, thanks")).toBeVisible()
|
||||
await expect(message(admin, "found them, thanks")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-022 bring people into a room", async ({seed, as}) => {
|
||||
const dora = makeTestUser("dora")
|
||||
const erik = makeTestUser("erik")
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.admin, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.bob, {name: "Bob Barnacle"})
|
||||
// A space member who isn't in the room yet. Her message is what loads her profile into the
|
||||
// client that goes looking for her.
|
||||
space.join(dora)
|
||||
space.profile(dora, {name: "Dora Deckhand"})
|
||||
space.message(dora, "general", "passing through", at(3, HOUR))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const admin = await as(users.admin, roomPath(url, "general"))
|
||||
|
||||
await expect(message(admin, "passing through")).toContainText("Dora Deckhand")
|
||||
|
||||
await openRoomDetail(admin)
|
||||
await admin.getByRole("button", {name: "Create invite"}).click()
|
||||
|
||||
const inviteModal = dialog(admin, "Create a Room Invite")
|
||||
const invite = inviteModal.locator("input[readonly]")
|
||||
|
||||
await expect(inviteModal.locator("canvas")).toBeVisible()
|
||||
await expect(invite).toHaveValue(/\/join\?r=space\.test&c=[^&]*&h=general&code=.+/)
|
||||
|
||||
await inviteModal.locator("label").filter({has: invite}).getByRole("button").click()
|
||||
|
||||
await expect(admin.getByText("Copied to clipboard!")).toBeVisible()
|
||||
|
||||
// The link is opened as a path rather than as an absolute url, which would leave the test's own
|
||||
// dev server for the platform's.
|
||||
const link = new URL(await invite.inputValue())
|
||||
const carol = await as(users.carol, `/join${link.search}`)
|
||||
|
||||
await expect(carol.getByText("You're about to join:")).toBeVisible()
|
||||
|
||||
await carol.getByRole("button", {name: "Join Room"}).click()
|
||||
|
||||
await expect(carol.getByText("Welcome to the room!")).toBeVisible()
|
||||
await expect(carol).toHaveURL(pathPattern(roomPath(url, "general")))
|
||||
await expect(message(carol, "passing through")).toBeVisible()
|
||||
|
||||
await admin.getByRole("button", {name: "Done"}).click()
|
||||
await admin.getByRole("button", {name: "View All"}).click()
|
||||
|
||||
const members = dialog(admin, "Members")
|
||||
|
||||
await expect(members.getByText("Bob Barnacle")).toBeVisible()
|
||||
await expect(members.getByText("Dora Deckhand")).toHaveCount(0)
|
||||
|
||||
await admin.getByRole("button", {name: "Add members"}).click()
|
||||
await admin.getByPlaceholder("Search for profiles...").fill("Dora")
|
||||
await admin.locator(".tiptap-suggestions").getByRole("button", {name: dora.pubkey}).click()
|
||||
await admin.getByRole("button", {name: "Save changes"}).click()
|
||||
|
||||
await expect(admin.getByText("Members have successfully been added!")).toBeVisible()
|
||||
await expect(members.getByText("Dora Deckhand")).toBeVisible()
|
||||
|
||||
// Somebody who isn't in the space yet has to be let into it first. A pubkey pasted into the
|
||||
// search field selects that person outright, which is how erik is reachable without a profile.
|
||||
await admin.getByRole("button", {name: "Add members"}).click()
|
||||
await admin.getByPlaceholder("Search for profiles...").fill(erik.pubkey)
|
||||
await admin.getByRole("button", {name: "Save changes"}).click()
|
||||
|
||||
await expect(admin.getByText("is not a member of this space. Add them?")).toBeVisible()
|
||||
|
||||
await admin.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(admin.getByText("Members have successfully been added!")).toBeVisible()
|
||||
await expect(members.getByText(displayPubkey(erik.pubkey))).toBeVisible()
|
||||
|
||||
const doraCard = members.locator(".card").filter({hasText: "Dora Deckhand"})
|
||||
|
||||
await doraCard.getByRole("button").last().click()
|
||||
await admin.getByRole("button", {name: "Remove Member"}).click()
|
||||
await admin.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(admin.getByText("Member has successfully been removed!")).toBeVisible()
|
||||
await expect(members.getByText("Dora Deckhand")).toHaveCount(0)
|
||||
|
||||
const bob = await as(users.bob, roomPath(url, "general"))
|
||||
|
||||
await openRoomDetail(bob)
|
||||
await bob.getByRole("button", {name: "View All"}).click()
|
||||
|
||||
await expect(dialog(bob, "Members").getByText("Bob Barnacle")).toBeVisible()
|
||||
await expect(bob.getByRole("button", {name: "Add members"})).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-023 reply to a message", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.alice, {name: "Alice Anchor"})
|
||||
space.message(user.alice, "general", "the tide turns at six", at(2, HOUR))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const path = roomPath(url, "general")
|
||||
|
||||
const bob = await as(users.bob, path)
|
||||
const alice = await as(users.alice, path)
|
||||
|
||||
await expect(message(bob, "the tide turns at six")).toBeVisible()
|
||||
|
||||
await messageActions(bob, "the tide turns at six").nth(2).click()
|
||||
|
||||
const banner = bob.getByText("Replying to @Alice Anchor")
|
||||
|
||||
await expect(banner).toBeVisible()
|
||||
await expect(bob.locator(".room__compose")).toContainText("the tide turns at six")
|
||||
|
||||
// Escape clears the reply without sending it
|
||||
await composer(bob).press("Escape")
|
||||
|
||||
await expect(banner).toHaveCount(0)
|
||||
|
||||
// ...and so does the banner's own close button
|
||||
await messageActions(bob, "the tide turns at six").nth(2).click()
|
||||
await banner.locator("..").getByRole("button").last().click()
|
||||
|
||||
await expect(banner).toHaveCount(0)
|
||||
await expect(messages(bob)).toHaveCount(1)
|
||||
|
||||
await messageActions(bob, "the tide turns at six").nth(2).click()
|
||||
await send(bob, "aye, I'll be there")
|
||||
|
||||
await expect(message(bob, "aye, I'll be there")).toContainText("the tide turns at six")
|
||||
await expect(message(alice, "aye, I'll be there")).toContainText("the tide turns at six")
|
||||
})
|
||||
|
||||
test("US-024 edit or delete a message you sent", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.alice, {name: "Alice Anchor"})
|
||||
space.profile(user.bob, {name: "Bob Barnacle"})
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const path = roomPath(url, "general")
|
||||
|
||||
const alice = await as(users.alice, path)
|
||||
const bob = await as(users.bob, path)
|
||||
|
||||
// Only a message she sent in the last five minutes is editable, so she sends one
|
||||
await send(alice, "we sail at dwan")
|
||||
|
||||
await expect(message(bob, "we sail at dwan")).toBeVisible()
|
||||
|
||||
await send(bob, "spelling?")
|
||||
|
||||
await expect(message(alice, "spelling?")).toBeVisible()
|
||||
|
||||
// Her own message offers zap, emoji, reply, edit and a menu; his offers no edit...
|
||||
await expect(messageActions(alice, "we sail at dwan")).toHaveCount(5)
|
||||
await expect(messageActions(alice, "spelling?")).toHaveCount(4)
|
||||
|
||||
// ...and no delete either
|
||||
await openMessageMenu(alice, "spelling?")
|
||||
|
||||
await expect(alice.getByRole("button", {name: "Report Content"})).toBeVisible()
|
||||
await expect(alice.getByRole("button", {name: "Delete Message"})).toHaveCount(0)
|
||||
|
||||
await dismissMenu(alice)
|
||||
|
||||
await messageActions(alice, "we sail at dwan").nth(3).click()
|
||||
|
||||
await expect(alice.getByText("Editing message")).toBeVisible()
|
||||
await expect(composer(alice)).toHaveText("we sail at dwan")
|
||||
|
||||
await composer(alice).press("ControlOrMeta+a")
|
||||
await send(alice, "we sail at dawn")
|
||||
|
||||
await expect(message(alice, "we sail at dawn")).toBeVisible()
|
||||
await expect(alice.getByText("we sail at dwan")).toHaveCount(0)
|
||||
await expect(message(bob, "we sail at dawn")).toBeVisible()
|
||||
await expect(bob.getByText("we sail at dwan")).toHaveCount(0)
|
||||
|
||||
// Republished with its original timestamp, so it stays above the message it drew
|
||||
await expect(messages(alice).nth(1)).toContainText("we sail at dawn")
|
||||
await expect(messages(bob).nth(1)).toContainText("we sail at dawn")
|
||||
|
||||
// Up in an empty composer picks up her most recent editable message
|
||||
await composer(alice).press("ArrowUp")
|
||||
|
||||
await expect(alice.getByText("Editing message")).toBeVisible()
|
||||
await expect(composer(alice)).toHaveText("we sail at dawn")
|
||||
|
||||
await alice.getByText("Editing message").locator("..").getByRole("button").click()
|
||||
|
||||
await expect(alice.getByText("Editing message")).toHaveCount(0)
|
||||
await expect(message(alice, "we sail at dawn")).toBeVisible()
|
||||
|
||||
await openMessageMenu(alice, "we sail at dawn")
|
||||
await alice.getByRole("button", {name: "Delete Message"}).click()
|
||||
await alice.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(alice.getByText("we sail at dawn")).toHaveCount(0)
|
||||
await expect(bob.getByText("we sail at dawn")).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-025 react to a message", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.join(user.carol, "general")
|
||||
space.profile(user.bob, {name: "Bob Barnacle"})
|
||||
space.message(user.alice, "general", "we made port", at(2, HOUR))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const path = roomPath(url, "general")
|
||||
|
||||
const carol = await as(users.carol, path)
|
||||
const bob = await as(users.bob, path)
|
||||
|
||||
await expect(message(carol, "we made port")).toBeVisible()
|
||||
await expect(message(bob, "we made port")).toBeVisible()
|
||||
|
||||
// The quick reaction button is the second of RoomItem's hover actions
|
||||
await react(carol, messageActions(carol, "we made port").nth(1))
|
||||
|
||||
await expect(reactionPill(carol, "we made port")).toBeVisible()
|
||||
await expect(reactionPill(bob, "we made port")).toBeVisible()
|
||||
|
||||
await react(bob, messageActions(bob, "we made port").nth(1))
|
||||
|
||||
await expect(reactionPill(carol, "we made port")).toContainText("2")
|
||||
await expect(reactionPill(bob, "we made port")).toContainText("2")
|
||||
|
||||
await reactionPill(carol, "we made port").click()
|
||||
|
||||
await expect(reactionPill(carol, "we made port")).not.toContainText("2")
|
||||
await expect(reactionPill(carol, "we made port")).not.toHaveClass(/button-primary/)
|
||||
await expect(reactionPill(bob, "we made port")).not.toContainText("2")
|
||||
|
||||
// On a phone, a pill she hasn't joined shows who reacted rather than adding her own
|
||||
const phone = await as(users.carol, path, {
|
||||
context: {viewport: {width: 390, height: 844}, hasTouch: true},
|
||||
})
|
||||
|
||||
await expect(reactionPill(phone, "we made port")).toBeVisible()
|
||||
|
||||
await reactionPill(phone, "we made port").click()
|
||||
|
||||
await expect(phone.getByText("Reacted to this message")).toBeVisible()
|
||||
await expect(phone.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-026 pin a message and browse pins", async ({seed, as}) => {
|
||||
let pinned!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.admin, "general")
|
||||
space.join(user.alice, "general")
|
||||
|
||||
pinned = space.message(user.alice, "general", "muster at eight bells", at(2, HOUR))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const admin = await as(users.admin, roomPath(url, "general"))
|
||||
|
||||
await expect(message(admin, "muster at eight bells")).toBeVisible()
|
||||
|
||||
await openMessageMenu(admin, "muster at eight bells")
|
||||
await admin.getByRole("button", {name: "Pin Message"}).click()
|
||||
|
||||
await expect(admin.getByText("Message pinned")).toBeVisible()
|
||||
|
||||
const banner = admin.locator(".room-pins")
|
||||
|
||||
await expect(banner).toContainText("muster at eight bells")
|
||||
|
||||
await banner.getByRole("button", {name: "All pinned messages"}).click()
|
||||
|
||||
const pins = dialog(admin, "Pinned Messages")
|
||||
|
||||
await expect(pins).toContainText("muster at eight bells")
|
||||
|
||||
await pins.getByRole("button", {name: "Jump to message"}).click()
|
||||
|
||||
await expect(pins).toHaveCount(0)
|
||||
await expect(admin.locator(`[data-event="${pinned.id}"]`)).toBeInViewport()
|
||||
|
||||
await banner.getByRole("button", {name: "All pinned messages"}).click()
|
||||
await pins.getByRole("button", {name: "Unpin"}).click()
|
||||
|
||||
await expect(admin.getByText("Message unpinned")).toBeVisible()
|
||||
await expect(pins).toContainText("No pinned messages.")
|
||||
await expect(banner).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-027 find a past message and jump to it", async ({seed, as}) => {
|
||||
let lastWeek!: Seeded
|
||||
let older!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.message(user.bob, "general", "harbor lights are on tonight", at(2, HOUR))
|
||||
|
||||
lastWeek = space.message(user.bob, "general", "harbor pilot is booked", at(3, DAY))
|
||||
older = space.message(user.bob, "general", "harbor dredging starts monday", at(3, WEEK))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const path = roomPath(url, "general")
|
||||
const page = await as(users.alice, path)
|
||||
|
||||
await expect(message(page, "harbor lights are on tonight")).toBeVisible()
|
||||
|
||||
await pageBar(page).getByRole("button", {name: "Search"}).click()
|
||||
|
||||
const term = page.getByPlaceholder("Search this room...")
|
||||
const search = dialog(page, "Search Content")
|
||||
|
||||
await term.fill("harbor")
|
||||
|
||||
await expect(search.getByText("Last 24 Hours")).toBeVisible()
|
||||
await expect(search.getByText("Last 7 Days")).toBeVisible()
|
||||
await expect(search.getByText("Older")).toBeVisible()
|
||||
await expect(search).toContainText("harbor dredging starts monday")
|
||||
|
||||
await term.fill("mizzenmast")
|
||||
|
||||
await expect(page.getByText("No results found.")).toBeVisible()
|
||||
|
||||
await term.fill("harbor pilot")
|
||||
await search.getByText("harbor pilot is booked").click()
|
||||
|
||||
await expect(search).toHaveCount(0)
|
||||
await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport()
|
||||
|
||||
// A permalink to one message lands on it, with a way back down to the newest
|
||||
await page.goto(`${path}?at=${older.event.created_at}`)
|
||||
|
||||
await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport()
|
||||
await expect(page.locator(".chat__scroll-down")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-028 share a message somewhere else", async ({seed, as}) => {
|
||||
let dm!: SeededRumor
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.room("random", {name: "Random"})
|
||||
space.join(user.alice, "general", "random")
|
||||
space.join(user.bob, "general", "random")
|
||||
space.profile(user.alice, {name: "Alice Anchor"})
|
||||
space.profile(user.bob, {name: "Bob Barnacle"})
|
||||
space.message(user.bob, "general", "the dock is closed on sunday", at(2, HOUR))
|
||||
|
||||
seedChatter(space, user.alice)
|
||||
seedChatter(space, user.bob)
|
||||
|
||||
dm = space.dm(user.bob, [user.alice], "are you around?", at(20, MINUTE))
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
|
||||
const alice = await as(users.alice, roomPath(url, "general"))
|
||||
const bob = await as(users.bob, roomPath(url, "random"))
|
||||
|
||||
await expect(message(alice, "the dock is closed on sunday")).toBeVisible()
|
||||
|
||||
await openMessageMenu(alice, "the dock is closed on sunday")
|
||||
await alice.getByRole("button", {name: "Share"}).click()
|
||||
|
||||
// ShareEvent is titled after the noun it was opened with, so its subtitle names it instead.
|
||||
const picker = alice
|
||||
.locator(".dialog")
|
||||
.filter({hasText: "Which room would you like to share this event to?"})
|
||||
.last()
|
||||
|
||||
await picker.getByRole("button", {name: "Random"}).click()
|
||||
await picker.getByRole("button", {name: /^Share/}).click()
|
||||
|
||||
await expect(alice).toHaveURL(pathPattern(roomPath(url, "random")))
|
||||
await expect(alice.locator(".room__compose")).toContainText("the dock is closed on sunday")
|
||||
|
||||
await send(alice, "heads up")
|
||||
|
||||
await expect(message(alice, "heads up")).toContainText("the dock is closed on sunday")
|
||||
await expect(message(bob, "heads up")).toContainText("the dock is closed on sunday")
|
||||
|
||||
// A conversation has no space of its own, so sharing from one searches everywhere she can post
|
||||
await alice.goto(`/chat/${users.bob.pubkey}`)
|
||||
|
||||
const received = alice.locator(`[data-event="${dm.id}"]`)
|
||||
|
||||
await expect(received).toContainText("are you around?")
|
||||
|
||||
await received.hover()
|
||||
await received.getByRole("button").first().click()
|
||||
await alice.getByRole("button", {name: "Share"}).click()
|
||||
|
||||
await expect(alice.getByText("Where would you like to share this?")).toBeVisible()
|
||||
await expect(alice.getByPlaceholder("Search rooms and conversations...")).toBeVisible()
|
||||
})
|
||||
589
e2e/specs/settings.spec.ts
Normal file
589
e2e/specs/settings.spec.ts
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
import {MINUTE} from "@welshman/lib"
|
||||
import {RelayList} from "@welshman/domain"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
import {expect, roomPath, test, users} from "../harness"
|
||||
import type {SeededSpace, TestUser} from "../harness"
|
||||
|
||||
// A handle to a seeded event, which only reads once seed() has drained its queue.
|
||||
type Seeded = {readonly id: string}
|
||||
|
||||
// The user's own relay list. Everything on these pages is published through their outbox, and the
|
||||
// sync that loads their settings back after a reload hangs off this list, so a spec that saves a
|
||||
// setting has to seed one.
|
||||
const relayList = (space: SeededSpace, user: TestUser) =>
|
||||
space.event(user, () =>
|
||||
space
|
||||
.kind(RelayList)
|
||||
.writer()
|
||||
.setReadUrls([space.url])
|
||||
.setWriteUrls([space.url])
|
||||
.renderTemplate(),
|
||||
)
|
||||
|
||||
// FieldInline lays a labelled control out as a single row, with the label at one end and the
|
||||
// control at the other.
|
||||
const row = (page: Page, label: string) =>
|
||||
page.locator("div.items-center.justify-between").filter({hasText: label})
|
||||
|
||||
const toggle = (page: Page, label: string) => row(page, label).getByRole("checkbox")
|
||||
|
||||
// RelaySettingsItem renders one relay list as a button: its name and description at one end, a
|
||||
// check or a danger triangle and the list's size at the other.
|
||||
const relayRow = (page: Page, title: string) => page.getByRole("button").filter({hasText: title})
|
||||
|
||||
const relayCount = (page: Page, title: string) => relayRow(page, title).locator("div.justify-end")
|
||||
|
||||
// The check/warning icon beside that count. Icons are mask-image data urls, so which one is showing
|
||||
// can only be read by comparing it against a row whose state is already known.
|
||||
const relayIcon = (page: Page, title: string) =>
|
||||
relayRow(page, title).locator("div.inline-block").last().getAttribute("style")
|
||||
|
||||
// One relay list's modal, which carries the list's name as its heading. Dialog nests two elements
|
||||
// with the class; the inner one holds the content.
|
||||
const relayDialog = (page: Page, title: string) =>
|
||||
page
|
||||
.locator(".dialog")
|
||||
.filter({has: page.getByRole("heading", {name: title, exact: true})})
|
||||
.last()
|
||||
|
||||
// The relay picker, which has no heading of its own — and is pushed over the list modal rather
|
||||
// than alongside it, so it is the only dialog in the dom while it is open.
|
||||
const relayPicker = (page: Page) => page.locator(".dialog").last()
|
||||
|
||||
const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name})
|
||||
|
||||
// RelaySettingsHealthCheck renders one issue as a small card with its recommendation beside it.
|
||||
const issue = (page: Page, title: string) => page.locator(".card-sm").filter({hasText: title})
|
||||
|
||||
// A saved setting reaches indexeddb in batches, and a settings page reads its values once when it
|
||||
// mounts — so a reload only sees the new value after the batch has been flushed. A toast clears
|
||||
// itself after five seconds, which is longer than the batch window, so waiting it out is what
|
||||
// makes the assertion that follows about persistence rather than about timing.
|
||||
const waitForToastToClear = (page: Page) => expect(page.getByRole("alert")).toHaveCount(0)
|
||||
|
||||
test("US-083 manage inbox and outbox relays", async ({seed, as}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
|
||||
// Kind 10002 publishes to every relay it names, and to the indexers, so alice has to be a
|
||||
// member of both of them for her own edits to be accepted.
|
||||
other.room("lounge", {name: "Lounge"})
|
||||
other.join(user.alice, "lounge")
|
||||
|
||||
space.event(user.alice, () =>
|
||||
space
|
||||
.kind(RelayList)
|
||||
.writer()
|
||||
.setReadUrls([space.url])
|
||||
.setWriteUrls([space.url, other.url])
|
||||
.renderTemplate(),
|
||||
)
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/settings/relays")
|
||||
|
||||
await expect(relayRow(page, "Inbox Relays")).toBeVisible()
|
||||
await expect(relayRow(page, "Outbox Relays")).toBeVisible()
|
||||
await expect(relayCount(page, "Inbox Relays")).toHaveText("1")
|
||||
await expect(relayCount(page, "Outbox Relays")).toHaveText("2")
|
||||
|
||||
// alice has no dm relays at all, so this row is the reference for what a warning looks like.
|
||||
const warningIcon = await relayIcon(page, "DM Relays")
|
||||
|
||||
expect(await relayIcon(page, "Outbox Relays")).not.toBe(warningIcon)
|
||||
|
||||
await relayRow(page, "Inbox Relays").click()
|
||||
|
||||
const inbox = relayDialog(page, "Inbox Relays")
|
||||
|
||||
await expect(inbox.getByText("space.test")).toBeVisible()
|
||||
await expect(inbox.getByText("other.test")).toHaveCount(0)
|
||||
|
||||
await inbox.getByRole("button", {name: "Add Relays"}).click()
|
||||
await page.getByPlaceholder("Search for relays...").fill("other.test")
|
||||
|
||||
// The typed url is offered as a custom entry alongside whatever the picker already knows about,
|
||||
// and both add the same relay.
|
||||
await relayPicker(page).getByRole("button", {name: "Add Relay"}).first().click()
|
||||
|
||||
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(0)
|
||||
|
||||
await page.getByRole("button", {name: "Done"}).click()
|
||||
|
||||
await expect(relayDialog(page, "Inbox Relays").getByText("other.test")).toBeVisible()
|
||||
|
||||
await relayDialog(page, "Inbox Relays").getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
await expect(relayCount(page, "Inbox Relays")).toHaveText("2")
|
||||
|
||||
await relayRow(page, "Outbox Relays").click()
|
||||
|
||||
const outbox = relayDialog(page, "Outbox Relays")
|
||||
|
||||
await relayCard(outbox, "other.test").getByRole("button", {name: "Remove"}).click()
|
||||
|
||||
await expect(outbox.getByText("space.test")).toBeVisible()
|
||||
await expect(outbox.getByText("other.test")).toHaveCount(0)
|
||||
|
||||
await outbox.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
await expect(relayCount(page, "Outbox Relays")).toHaveText("1")
|
||||
|
||||
expect(await relayIcon(page, "Outbox Relays")).toBe(warningIcon)
|
||||
})
|
||||
|
||||
test("US-084 manage DM, search, and blocked relays", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
// A second relay for the pickers to offer. Nobody is a member of it — what puts a relay in
|
||||
// the suggestion pool is its nip-11 document having been fetched, and every scenario relay is
|
||||
// an indexer, so bob's client reads this one at startup.
|
||||
relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.bob, "general")
|
||||
|
||||
relayList(space, user.bob)
|
||||
})
|
||||
|
||||
const other = scenario.space("other")
|
||||
const page = await as(users.bob, "/settings/relays", {
|
||||
// zooid advertises no nip-50, and the search-relay picker offers nothing that doesn't.
|
||||
relayInfo: {[other.url]: {supported_nips: ["1", "29", "43", "50"]}},
|
||||
})
|
||||
|
||||
await expect(relayCount(page, "DM Relays")).toHaveText("0")
|
||||
await expect(relayCount(page, "Search Relays")).toHaveText("0")
|
||||
|
||||
await relayRow(page, "DM Relays").click()
|
||||
await relayDialog(page, "DM Relays").getByRole("button", {name: "Add Relays"}).click()
|
||||
await relayCard(relayPicker(page), "space.test").getByRole("button", {name: "Add Relay"}).click()
|
||||
await page.getByRole("button", {name: "Done"}).click()
|
||||
|
||||
const dms = relayDialog(page, "DM Relays")
|
||||
|
||||
await expect(dms.getByText("space.test")).toBeVisible()
|
||||
|
||||
await dms.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
await expect(relayCount(page, "DM Relays")).toHaveText("1")
|
||||
|
||||
// The same relay is not now a search relay: the two lists are separate.
|
||||
await relayRow(page, "Search Relays").click()
|
||||
|
||||
const search = relayDialog(page, "Search Relays")
|
||||
|
||||
await expect(search.getByText("No relay selections found.")).toBeVisible()
|
||||
await expect(search.getByText("space.test")).toHaveCount(0)
|
||||
|
||||
await search.getByRole("button", {name: "Add Relays"}).click()
|
||||
|
||||
// Only the relay advertising nip-50 is on offer.
|
||||
await expect(relayPicker(page).getByText("other.test")).toBeVisible()
|
||||
await expect(relayPicker(page).getByText("space.test")).toHaveCount(0)
|
||||
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(1)
|
||||
|
||||
await relayPicker(page).getByRole("button", {name: "Add Relay"}).click()
|
||||
await page.getByRole("button", {name: "Done"}).click()
|
||||
await relayDialog(page, "Search Relays").getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
await expect(relayCount(page, "Search Relays")).toHaveText("1")
|
||||
|
||||
await relayRow(page, "Search Relays").click()
|
||||
await relayCard(relayDialog(page, "Search Relays"), "other.test")
|
||||
.getByRole("button", {name: "Remove"})
|
||||
.click()
|
||||
await relayDialog(page, "Search Relays").getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
await expect(relayCount(page, "Search Relays")).toHaveText("0")
|
||||
|
||||
await relayRow(page, "Blocked Relays").click()
|
||||
await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
|
||||
await relayCard(relayPicker(page), "other.test").getByRole("button", {name: "Add Relay"}).click()
|
||||
await page.getByRole("button", {name: "Done"}).click()
|
||||
|
||||
const blocked = relayDialog(page, "Blocked Relays")
|
||||
|
||||
await expect(blocked.getByText("other.test")).toBeVisible()
|
||||
|
||||
await blocked.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
await expect(relayCount(page, "Blocked Relays")).toHaveText("1")
|
||||
|
||||
// A blocked relay is one bob never wants used, so the other lists stop suggesting it. The search
|
||||
// picker offered other.test and nothing else a moment ago, which is what makes its absence here
|
||||
// a statement about blocking rather than about an empty picker.
|
||||
await relayRow(page, "Search Relays").click()
|
||||
await relayDialog(page, "Search Relays").getByRole("button", {name: "Add Relays"}).click()
|
||||
|
||||
await expect(relayPicker(page).getByPlaceholder("Search for relays...")).toBeVisible()
|
||||
await expect(relayPicker(page).getByText("other.test")).toHaveCount(0)
|
||||
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-085 fix relay misconfiguration from the health check", async ({seed, as}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
|
||||
// A recommendation writes the platform's default relays into the list, which is both of the
|
||||
// scenario's, so alice has to be a member of both for the new list to be accepted.
|
||||
other.room("lounge", {name: "Lounge"})
|
||||
other.join(user.alice, "lounge")
|
||||
|
||||
relayList(space, user.alice)
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/settings/relays")
|
||||
|
||||
// Exact, because "3 Issues Detected" is also a substring of "13 Issues Detected".
|
||||
await expect(page.getByText("3 Issues Detected", {exact: true})).toBeVisible()
|
||||
await expect(issue(page, "Missing Outbox Relays")).toBeVisible()
|
||||
await expect(relayCount(page, "Outbox Relays")).toHaveText("1")
|
||||
|
||||
await issue(page, "Missing Outbox Relays").getByRole("button", {name: "Update"}).click()
|
||||
|
||||
await expect(relayCount(page, "Outbox Relays")).toHaveText("2")
|
||||
await expect(issue(page, "Missing Outbox Relays")).toHaveCount(0)
|
||||
await expect(page.getByText("2 Issues Detected", {exact: true})).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Apply All Recommendations"}).click()
|
||||
|
||||
await expect(page.getByText("0 Issues Detected", {exact: true})).toBeVisible()
|
||||
await expect(page.getByRole("button", {name: "Apply All Recommendations"})).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("US-086 configure alerts", async ({seed, as}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
|
||||
relayList(space, user.alice)
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/settings/alerts")
|
||||
|
||||
const sound = toggle(page, "Play sound for new activity")
|
||||
const push = toggle(page, "Enable push notifications")
|
||||
const alertTypes = page.locator("div.card").filter({has: page.getByText("Alert Types")})
|
||||
|
||||
await expect(sound).toBeChecked()
|
||||
await expect(push).not.toBeChecked()
|
||||
await expect(alertTypes).not.toHaveClass(/opacity-50/)
|
||||
|
||||
// With nothing left to be alerted through, there is nothing to be alerted about.
|
||||
await sound.uncheck()
|
||||
|
||||
await expect(alertTypes).toHaveClass(/opacity-50/)
|
||||
|
||||
await page.getByRole("button", {name: "Discard Changes"}).click()
|
||||
|
||||
await expect(sound).toBeChecked()
|
||||
await expect(alertTypes).not.toHaveClass(/opacity-50/)
|
||||
|
||||
// Push asks the browser for permission, and this context was never granted it.
|
||||
await push.check()
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Failed to request notification permissions")
|
||||
await expect(push).not.toBeChecked()
|
||||
await expect(sound).toBeChecked()
|
||||
|
||||
await waitForToastToClear(page)
|
||||
|
||||
await sound.uncheck()
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
|
||||
|
||||
await waitForToastToClear(page)
|
||||
await page.reload()
|
||||
|
||||
await expect(toggle(page, "Play sound for new activity")).not.toBeChecked()
|
||||
})
|
||||
|
||||
test("US-087 configure content display", async ({seed, as}) => {
|
||||
let picture!: Seeded
|
||||
let link!: Seeded
|
||||
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
|
||||
picture = space.message(user.bob, "general", "https://images.test/sunset.png", at(40, MINUTE))
|
||||
link = space.message(user.bob, "general", "https://example.test/announcement", at(35, MINUTE))
|
||||
|
||||
relayList(space, user.alice)
|
||||
})
|
||||
|
||||
const {url} = scenario.space("space")
|
||||
const page = await as(users.alice, "/settings/content")
|
||||
|
||||
const hideSensitive = toggle(page, "Hide sensitive content?")
|
||||
const showMedia = toggle(page, "Show media?")
|
||||
|
||||
await expect(hideSensitive).toBeChecked()
|
||||
await expect(showMedia).toBeChecked()
|
||||
|
||||
await hideSensitive.uncheck()
|
||||
await showMedia.uncheck()
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
|
||||
|
||||
await waitForToastToClear(page)
|
||||
await page.goto(roomPath(url, "general"))
|
||||
|
||||
const pictureMessage = page.locator(`[data-event="${picture.id}"]`)
|
||||
const linkMessage = page.locator(`[data-event="${link.id}"]`)
|
||||
|
||||
await expect(pictureMessage.getByRole("link", {name: "images.test/sunset.png"})).toBeVisible()
|
||||
await expect(pictureMessage.locator('img[src="https://images.test/sunset.png"]')).toHaveCount(0)
|
||||
|
||||
await expect(linkMessage.getByRole("link", {name: "example.test/announcement"})).toBeVisible()
|
||||
await expect(linkMessage.locator(".spinner")).toHaveCount(0)
|
||||
await expect(linkMessage.getByText("Unable to load a preview")).toHaveCount(0)
|
||||
|
||||
await page.goto("/settings/content")
|
||||
|
||||
await expect(toggle(page, "Hide sensitive content?")).not.toBeChecked()
|
||||
})
|
||||
|
||||
test("US-088 adjust send delay and media servers", async ({seed, as}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
|
||||
relayList(space, user.alice)
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/settings/content")
|
||||
|
||||
// The send delay is the only slider on this page.
|
||||
const sendDelay = page.locator('input[type="range"]')
|
||||
const servers = page.getByRole("listitem")
|
||||
|
||||
await expect(page.getByText("Delay sending chat messages for 0 seconds.")).toBeVisible()
|
||||
|
||||
await sendDelay.fill("3000")
|
||||
|
||||
await expect(page.getByText("Delay sending chat messages for 3 seconds.")).toBeVisible()
|
||||
|
||||
await expect(servers).toHaveCount(0)
|
||||
|
||||
await page.getByRole("button", {name: "Add Server"}).click()
|
||||
|
||||
await expect(servers).toHaveCount(1)
|
||||
|
||||
await servers.first().locator("input").fill("https://media.test/")
|
||||
|
||||
await page.getByRole("button", {name: "Add Server"}).click()
|
||||
|
||||
await expect(servers).toHaveCount(2)
|
||||
|
||||
// Each row leads with its own remove button; the drag handle is the one with a label.
|
||||
await servers.nth(1).getByRole("button").first().click()
|
||||
|
||||
await expect(servers).toHaveCount(1)
|
||||
await expect(servers.first().locator("input")).toHaveValue("https://media.test/")
|
||||
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
|
||||
|
||||
await waitForToastToClear(page)
|
||||
await page.reload()
|
||||
|
||||
await expect(page.getByText("Delay sending chat messages for 3 seconds.")).toBeVisible()
|
||||
await expect(page.locator('input[type="range"]')).toHaveValue("3000")
|
||||
})
|
||||
|
||||
test("US-089 configure privacy preferences", async ({seed, as}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
|
||||
relayList(space, user.alice)
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/settings/privacy")
|
||||
|
||||
const auth = toggle(page, "Authenticate with unknown relays?")
|
||||
const usage = toggle(page, "Report usage?")
|
||||
|
||||
await expect(auth).not.toBeChecked()
|
||||
await expect(usage).toBeChecked()
|
||||
|
||||
await auth.check()
|
||||
await usage.uncheck()
|
||||
await page.getByRole("button", {name: "Discard Changes"}).click()
|
||||
|
||||
await expect(auth).not.toBeChecked()
|
||||
await expect(usage).toBeChecked()
|
||||
|
||||
await auth.check()
|
||||
await usage.uncheck()
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
|
||||
|
||||
await waitForToastToClear(page)
|
||||
await page.reload()
|
||||
|
||||
await expect(toggle(page, "Authenticate with unknown relays?")).toBeChecked()
|
||||
await expect(toggle(page, "Report usage?")).not.toBeChecked()
|
||||
})
|
||||
|
||||
test("US-090 change the app's appearance", async ({seed, as}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
|
||||
relayList(space, user.alice)
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/settings/theme", {context: {colorScheme: "light"}})
|
||||
const body = page.locator("body")
|
||||
|
||||
await expect(body).toHaveAttribute("data-theme", "light")
|
||||
|
||||
await page.getByRole("button", {name: "Dark", exact: true}).click()
|
||||
|
||||
await expect(body).toHaveAttribute("data-theme", "dark")
|
||||
|
||||
await page.getByRole("button", {name: "System", exact: true}).click()
|
||||
|
||||
await expect(body).toHaveAttribute("data-theme", "light")
|
||||
|
||||
// System means the device's, so changing the device's changes the app's.
|
||||
await page.emulateMedia({colorScheme: "dark"})
|
||||
|
||||
await expect(body).toHaveAttribute("data-theme", "dark")
|
||||
|
||||
await page.getByLabel("Style").selectOption("navy")
|
||||
|
||||
await expect(body).toHaveAttribute("data-fl-theme", "navy")
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect(body).toHaveAttribute("data-fl-theme", "navy")
|
||||
await expect(page.getByLabel("Style")).toHaveValue("navy")
|
||||
|
||||
// Font size is the only slider on this page, and the only setting here that is published.
|
||||
const fontSize = page.locator('input[type="range"]')
|
||||
|
||||
await expect(page.getByText("110%")).toBeVisible()
|
||||
await expect(page.getByRole("button", {name: "Save Changes"})).toHaveCount(0)
|
||||
|
||||
await fontSize.fill("1.25")
|
||||
|
||||
await expect(page.getByText("125%")).toBeVisible()
|
||||
await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/)
|
||||
|
||||
// Moving the slider is the save, so the size the document is rendered at survives a reload
|
||||
// without anything else having been pressed.
|
||||
await page.reload()
|
||||
|
||||
await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/)
|
||||
})
|
||||
|
||||
test("US-091 set up how people zap you", async ({seed, as}) => {
|
||||
await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.profile(user.alice, {name: "Alice Anders"})
|
||||
|
||||
relayList(space, user.alice)
|
||||
})
|
||||
|
||||
const page = await as(users.alice, "/settings/wallet")
|
||||
|
||||
await expect(page.getByText("Not set")).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Update"}).click()
|
||||
|
||||
const address = page
|
||||
.locator(".dialog")
|
||||
.filter({has: page.getByRole("heading", {name: "Update Lightning Address"})})
|
||||
.last()
|
||||
|
||||
await address.getByPlaceholder("user@domain.com").fill("alice@example.test")
|
||||
await address.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
// The dialog closes itself once the profile has gone out, and the page behind it carries its own
|
||||
// "Save Changes" — so wait it out rather than leaving that name ambiguous.
|
||||
await expect(address).toHaveCount(0)
|
||||
await expect(page.getByText("alice@example.test")).toBeVisible()
|
||||
await expect(page.getByText("Not set")).toHaveCount(0)
|
||||
|
||||
await page.getByRole("button", {name: "Update"}).click()
|
||||
await address.getByPlaceholder("user@domain.com").fill("")
|
||||
await address.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(address).toHaveCount(0)
|
||||
await expect(page.getByText("Not set")).toBeVisible()
|
||||
|
||||
// Each preset is a row: its own remove button and the amount side by side.
|
||||
const presets = page.locator("form div.items-center.gap-2")
|
||||
|
||||
await expect(presets).toHaveCount(4)
|
||||
|
||||
await page.getByRole("button", {name: "Add amount"}).click()
|
||||
|
||||
await expect(presets).toHaveCount(5)
|
||||
|
||||
// A preset has to be worth something, so saving a zero is refused with an error.
|
||||
await presets.nth(4).locator("input").fill("0")
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Zap amounts must be greater than zero.")
|
||||
|
||||
await waitForToastToClear(page)
|
||||
|
||||
// Discarding puts back the last saved values, which are still alice's original four — the zero
|
||||
// never reached them.
|
||||
await page.getByRole("button", {name: "Discard Changes"}).click()
|
||||
|
||||
await expect(presets).toHaveCount(4)
|
||||
|
||||
await presets.nth(3).getByRole("button").click()
|
||||
await presets.nth(2).getByRole("button").click()
|
||||
await presets.nth(1).getByRole("button").click()
|
||||
|
||||
await expect(presets).toHaveCount(1)
|
||||
await expect(presets.first().getByRole("button")).toBeDisabled()
|
||||
|
||||
await presets.first().locator("input").fill("500")
|
||||
await page.getByRole("button", {name: "Save Changes"}).click()
|
||||
|
||||
await expect(page.getByRole("alert")).toContainText("Your zap amounts have been saved!")
|
||||
|
||||
await waitForToastToClear(page)
|
||||
await page.reload()
|
||||
|
||||
const saved = page.locator("form div.items-center.gap-2")
|
||||
|
||||
await expect(saved).toHaveCount(1)
|
||||
await expect(saved.first().locator("input")).toHaveValue("500")
|
||||
})
|
||||
545
e2e/specs/spaces.spec.ts
Normal file
545
e2e/specs/spaces.spec.ts
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
import {DAY, HOUR, MINUTE, WEEK} from "@welshman/lib"
|
||||
import {THREAD, makeEvent} from "@welshman/util"
|
||||
import {expect, roomPath, spacePath, test, users} from "../harness"
|
||||
|
||||
test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
const unsigned = relay("unsigned")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
other.room("lounge", {name: "Lounge"})
|
||||
unsigned.room("hall", {name: "Hall"})
|
||||
|
||||
// Alice's own two spaces, declared in the order her room list should start out in
|
||||
space.join(user.alice, "general")
|
||||
other.join(user.alice, "lounge")
|
||||
|
||||
// A space she hasn't joined only becomes browsable through somebody else's room list
|
||||
space.join(user.bob, "general")
|
||||
unsigned.join(user.bob, "hall")
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
const other = scenario.space("other")
|
||||
const unsigned = scenario.space("unsigned")
|
||||
|
||||
// The spaces page discovers unjoined spaces by pulling the room lists of the pubkeys it
|
||||
// bootstraps from, so pointing that at bob is what puts his other space in front of alice.
|
||||
const page = await as(users.alice, "/spaces", {env: {VITE_DEFAULT_PUBKEYS: users.bob.pubkey}})
|
||||
|
||||
await expect(page.getByText("Your spaces")).toBeVisible()
|
||||
await expect(page.getByText(space.url)).toBeVisible()
|
||||
await expect(page.getByText(other.url)).toBeVisible()
|
||||
|
||||
// ...and the rest of them in a section of their own
|
||||
await expect(page.getByText("Browse Spaces")).toBeVisible()
|
||||
await expect(page.getByText(unsigned.url)).toBeVisible()
|
||||
|
||||
const term = page.getByPlaceholder("Search for spaces...")
|
||||
|
||||
await term.fill("unsigned")
|
||||
|
||||
await expect(page.getByText(unsigned.url)).toBeVisible()
|
||||
await expect(page.getByText(space.url)).toHaveCount(0)
|
||||
await expect(page.getByText(other.url)).toHaveCount(0)
|
||||
|
||||
await term.fill("")
|
||||
|
||||
await expect(page.getByText(space.url)).toBeVisible()
|
||||
|
||||
// A space she's joined opens
|
||||
await page.getByRole("listitem").filter({hasText: space.url}).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/spaces\/space\.test\//)
|
||||
|
||||
// A space she hasn't asks her to join first
|
||||
await page.goto("/spaces")
|
||||
await page.getByRole("button").filter({hasText: unsigned.url}).click()
|
||||
|
||||
await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible()
|
||||
await expect(page.getByRole("button", {name: "Go back"})).toBeEnabled()
|
||||
|
||||
// Reordering by dragging, which lives in her room list and so outlives the page
|
||||
await page.goto("/spaces")
|
||||
|
||||
const joined = page.getByRole("listitem")
|
||||
|
||||
await expect(joined.first()).toContainText(space.url)
|
||||
|
||||
await joined.filter({hasText: other.url}).dragTo(joined.filter({hasText: space.url}))
|
||||
|
||||
await expect(joined.first()).toContainText(other.url)
|
||||
|
||||
await page.reload()
|
||||
|
||||
await expect(page.getByRole("listitem").first()).toContainText(other.url)
|
||||
})
|
||||
|
||||
test("US-010 join a space from an invite link", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
other.room("lounge", {name: "Lounge"})
|
||||
space.join(user.alice, "general")
|
||||
})
|
||||
|
||||
const other = scenario.space("other")
|
||||
const page = await as(users.alice, spacePath(other.url))
|
||||
|
||||
// Arriving at a space she hasn't joined raises the join prompt on its own
|
||||
await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled()
|
||||
await expect(page.getByText(other.url)).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
// Going back left her un-joined, so arriving again asks again
|
||||
await page.goto(spacePath(other.url))
|
||||
|
||||
await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled()
|
||||
|
||||
await page.goto("/spaces")
|
||||
await page.getByRole("button", {name: "Add Space"}).click()
|
||||
await page.getByRole("button", {name: /Join a space/}).click()
|
||||
|
||||
const invite = page.locator("form").filter({hasText: "Invite Link*"}).getByRole("textbox")
|
||||
|
||||
await invite.fill("not an invite")
|
||||
|
||||
await expect(page.getByRole("button", {name: "Join Space"})).toBeDisabled()
|
||||
await expect(page.getByText("You're about to join:")).toHaveCount(0)
|
||||
|
||||
// The link makeInviteLink builds, typed rather than followed — an absolute platform url is an
|
||||
// off-origin navigation, and parseInviteLink only ever reads its query params.
|
||||
await invite.fill("https://app.flotilla.social/join?r=other.test&c=")
|
||||
|
||||
await expect(page.getByText("You're about to join:")).toBeVisible()
|
||||
await expect(page.getByText(other.url)).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Join Space"}).click()
|
||||
|
||||
await expect(page.getByText("Welcome to the space!")).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/spaces\/other\.test/)
|
||||
})
|
||||
|
||||
test("US-011 request access when a space turns you away", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const closed = relay("closed")
|
||||
const space = relay("space")
|
||||
|
||||
// This relay refuses a join that carries no claim. A claim is not an event a scenario can
|
||||
// publish — the relay only registers one through the nip-86 method the invite dialog calls —
|
||||
// so the code alice needs comes out of that dialog below.
|
||||
closed.room("lobby", {name: "Lobby"})
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.admin, "general")
|
||||
space.join(user.bob, "general")
|
||||
space.profile(user.bob, {name: "Bob Barnacle"})
|
||||
})
|
||||
|
||||
const closed = scenario.space("closed")
|
||||
const space = scenario.space("space")
|
||||
|
||||
// Administering a relay is not the same as belonging to it, so the space asks admin to join
|
||||
// like anyone else — and issues him an invite code all the same.
|
||||
const admin = await as(users.admin, spacePath(closed.url) + "/about")
|
||||
|
||||
await admin.locator("form").getByRole("button", {name: "Go back"}).click()
|
||||
await admin.getByRole("button", {name: /closed\.test/}).click()
|
||||
await admin.getByRole("button", {name: "Create Invite"}).click()
|
||||
|
||||
const inviteLink = admin.locator("input[readonly]")
|
||||
|
||||
await expect(inviteLink).toHaveValue(/[?&]c=\w/)
|
||||
|
||||
const [, claim = ""] = (await inviteLink.inputValue()).match(/[?&]c=([^&]+)/) ?? []
|
||||
|
||||
const alice = await as(users.alice, spacePath(closed.url))
|
||||
|
||||
// Turned away rather than joined, so the prompt becomes a request
|
||||
await expect(alice.getByRole("button", {name: "Request Access"})).toBeEnabled()
|
||||
|
||||
await alice.getByRole("button", {name: "Request Access"}).click()
|
||||
|
||||
await expect(alice.getByRole("heading", {name: "Request Access"})).toBeVisible()
|
||||
|
||||
const code = alice.locator("form").filter({hasText: "Invite code*"}).getByRole("textbox")
|
||||
|
||||
await code.fill("NOTTHECODE")
|
||||
await alice.getByRole("button", {name: "Join Space"}).click()
|
||||
|
||||
// The relay refused the code, so she is told and left asking
|
||||
await expect(alice.getByRole("alert")).toContainText("join request rejected")
|
||||
await expect(alice.getByRole("heading", {name: "Request Access"})).toBeVisible()
|
||||
|
||||
await code.fill(claim)
|
||||
await alice.getByRole("button", {name: "Join Space"}).click()
|
||||
|
||||
// The correct code granted her access, so the space stops turning her away
|
||||
await expect(alice.getByRole("heading", {name: "Request Access"})).toHaveCount(0)
|
||||
await expect(alice.getByRole("button", {name: "Join Space"})).toBeEnabled()
|
||||
|
||||
await alice.getByRole("button", {name: "Join Space"}).click()
|
||||
|
||||
await expect(alice.getByText("Welcome to the space!")).toBeVisible()
|
||||
await expect(alice).toHaveURL(/\/spaces\/closed\.test/)
|
||||
|
||||
// A member the admin bans is told why by the relay itself
|
||||
await admin.goto(spacePath(space.url) + "/directory")
|
||||
|
||||
const bobCard = admin
|
||||
.getByRole("button", {name: "View Bob Barnacle's profile"})
|
||||
.locator("xpath=..")
|
||||
|
||||
await bobCard.getByRole("button").last().click()
|
||||
await admin.getByRole("button", {name: "Ban member"}).click()
|
||||
|
||||
await expect(admin.getByText("Ban @Bob Barnacle from the space?")).toBeVisible()
|
||||
|
||||
await admin.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(admin.getByText("Member has successfully been banned!")).toBeVisible()
|
||||
|
||||
const bob = await as(users.bob, spacePath(space.url))
|
||||
|
||||
await expect(bob.getByRole("heading", {name: "Access Error"})).toBeVisible()
|
||||
await expect(bob.getByText(/not a member of this relay/i)).toBeVisible()
|
||||
|
||||
// From here he can ask for a code back in
|
||||
await bob.getByRole("button", {name: "Request Access"}).click()
|
||||
|
||||
await expect(bob.getByRole("heading", {name: "Request Access"})).toBeVisible()
|
||||
|
||||
await bob.getByRole("button", {name: "Go back"}).click()
|
||||
|
||||
// ...or give up on the space, which takes it off his list
|
||||
await bob.getByRole("button", {name: "Leave Space"}).click()
|
||||
|
||||
await expect(bob).toHaveURL(/\/home/)
|
||||
|
||||
await bob.goto("/spaces")
|
||||
|
||||
await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const unsigned = relay("unsigned")
|
||||
|
||||
unsigned.room("general", {name: "General"})
|
||||
unsigned.join(user.alice, "general")
|
||||
unsigned.join(user.bob, "general")
|
||||
unsigned.message(user.alice, "general", "the hull is patched")
|
||||
})
|
||||
|
||||
const unsigned = scenario.space("unsigned")
|
||||
|
||||
// This relay serves events with their signatures stripped, so it can forge messages
|
||||
const bob = await as(users.bob, roomPath(unsigned.url, "general"))
|
||||
|
||||
await expect(bob.getByRole("heading", {name: "Do you trust this space?"})).toBeVisible()
|
||||
|
||||
await bob.getByRole("button", {name: "I don't trust this space"}).click()
|
||||
|
||||
await expect(bob).toHaveURL(/\/home/)
|
||||
|
||||
await bob.goto("/spaces")
|
||||
|
||||
await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible()
|
||||
|
||||
const alice = await as(users.alice, roomPath(unsigned.url, "general"))
|
||||
|
||||
await expect(alice.getByRole("heading", {name: "Do you trust this space?"})).toBeVisible()
|
||||
|
||||
await alice.getByRole("button", {name: "I trust this space, continue"}).click()
|
||||
|
||||
await expect(alice.getByRole("heading", {name: "Do you trust this space?"})).toHaveCount(0)
|
||||
await expect(alice.getByText("the hull is patched")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-013 follow a space that has moved", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
other.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
space.join(user.bob, "general")
|
||||
|
||||
// The new address has to let her in once her list points at it
|
||||
other.member(user.alice)
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
const other = scenario.space("other")
|
||||
|
||||
// The relay's own nip-11 document is what reports the move
|
||||
const relayInfo = {[space.url]: {redirect_to: "https://other.test/"}}
|
||||
|
||||
const alice = await as(users.alice, spacePath(space.url) + "/about", {relayInfo})
|
||||
const movedDialog = alice.locator("form")
|
||||
|
||||
await expect(alice.getByRole("heading", {name: "This space has moved"})).toBeVisible()
|
||||
await expect(movedDialog.getByText("space.test", {exact: true})).toBeVisible()
|
||||
await expect(movedDialog.getByText("other.test", {exact: true})).toBeVisible()
|
||||
|
||||
await alice.getByRole("button", {name: "Update and go"}).click()
|
||||
|
||||
await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/)
|
||||
|
||||
await alice.goto("/spaces")
|
||||
|
||||
const aliceSpaces = alice.getByRole("listitem")
|
||||
|
||||
await expect(aliceSpaces).toHaveCount(1)
|
||||
await expect(aliceSpaces.first()).toContainText(other.url)
|
||||
|
||||
const bob = await as(users.bob, spacePath(space.url) + "/about", {relayInfo})
|
||||
|
||||
await expect(bob.getByRole("heading", {name: "This space has moved"})).toBeVisible()
|
||||
|
||||
await bob.getByRole("button", {name: "Not now"}).click()
|
||||
|
||||
await expect(bob).toHaveURL(/\/spaces\/space\.test\/about/)
|
||||
|
||||
await bob.goto("/spaces")
|
||||
|
||||
const bobSpaces = bob.getByRole("listitem")
|
||||
|
||||
await expect(bobSpaces).toHaveCount(1)
|
||||
await expect(bobSpaces.first()).toContainText(space.url)
|
||||
})
|
||||
|
||||
test("US-014 leave a space", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.bob, "general")
|
||||
space.message(user.bob, "general", "signing off for a while")
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
const page = await as(users.bob, spacePath(space.url) + "/about")
|
||||
|
||||
await page.getByRole("button", {name: /space\.test/}).click()
|
||||
await page.getByRole("button", {name: "Leave Space"}).click()
|
||||
|
||||
await expect(page.getByText("Are you sure you want to leave?")).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Confirm"}).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/home/)
|
||||
|
||||
await page.goto("/spaces")
|
||||
|
||||
await expect(page.getByText("You haven't joined any spaces yet.")).toBeVisible()
|
||||
|
||||
// Nothing stops him coming back
|
||||
await page.getByRole("button", {name: "Add Space"}).click()
|
||||
await page.getByRole("button", {name: /Join a space/}).click()
|
||||
await page
|
||||
.locator("form")
|
||||
.filter({hasText: "Invite Link*"})
|
||||
.getByRole("textbox")
|
||||
.fill("https://app.flotilla.social/join?r=space.test&c=")
|
||||
|
||||
await expect(page.getByText("You're about to join:")).toBeVisible()
|
||||
|
||||
await page.getByRole("button", {name: "Join Space"}).click()
|
||||
|
||||
await expect(page.getByText("Welcome to the space!")).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/spaces\/space\.test/)
|
||||
})
|
||||
|
||||
test("US-015 view a space's details", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.admin, "general")
|
||||
space.join(user.carol, "general")
|
||||
space.join(user.alice, "general")
|
||||
space.profile(user.alice, {name: "Alice Anchor"})
|
||||
space.message(user.alice, "general", "the tide is high", at(1, HOUR))
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
|
||||
// Contact, terms, privacy and the limitation warnings are nip-11 fields zooid doesn't publish
|
||||
// on its own, so the scenario merges them over the relay's real document.
|
||||
const relayInfo = {
|
||||
[space.url]: {
|
||||
icon: "https://space.test/icon.png",
|
||||
contact: "harbormaster@space.test",
|
||||
terms_of_service: "https://space.test/terms",
|
||||
privacy_policy: "https://space.test/privacy",
|
||||
limitation: {auth_required: true, payment_required: true, min_pow_difficulty: 20},
|
||||
},
|
||||
}
|
||||
|
||||
const carol = await as(users.carol, spacePath(space.url) + "/about", {relayInfo})
|
||||
|
||||
await expect(carol.locator('img[src="https://space.test/icon.png"]').first()).toBeVisible()
|
||||
await expect(carol.getByRole("heading", {name: "space"})).toBeVisible()
|
||||
await expect(carol.getByText("space.test", {exact: true}).first()).toBeVisible()
|
||||
await expect(carol.getByText(/Throwaway relay/)).toBeVisible()
|
||||
await expect(carol.getByRole("link", {name: "Terms of Service"})).toBeVisible()
|
||||
await expect(carol.getByRole("link", {name: "Privacy Policy"})).toBeVisible()
|
||||
|
||||
await expect(carol.getByText(/Administrator:/)).toBeVisible()
|
||||
await expect(carol.getByText("Contact: harbormaster@space.test")).toBeVisible()
|
||||
await expect(carol.getByText(/Software:/)).toBeVisible()
|
||||
await expect(carol.getByText(/Version:/)).toBeVisible()
|
||||
await expect(carol.getByText("Auth Required")).toBeVisible()
|
||||
await expect(carol.getByText("Payment Required")).toBeVisible()
|
||||
await expect(carol.getByText("Min PoW: 20")).toBeVisible()
|
||||
|
||||
await expect(carol.getByRole("heading", {name: "Members"})).toBeVisible()
|
||||
await expect(carol.getByText("Admins")).toBeVisible()
|
||||
await expect(carol.getByText("New members")).toBeVisible()
|
||||
|
||||
// Nothing is featured yet, so recent activity stands in for it
|
||||
await expect(carol.getByRole("heading", {name: "Recent Activity"})).toBeVisible()
|
||||
await expect(carol.getByText("the tide is high")).toBeVisible()
|
||||
|
||||
const admin = await as(users.admin, spacePath(space.url) + "/about", {relayInfo})
|
||||
const featuredHeader = admin.getByRole("heading", {name: "Featured"}).locator("xpath=..")
|
||||
|
||||
await featuredHeader.getByRole("button").click()
|
||||
await admin.getByRole("button", {name: "Add content"}).click()
|
||||
await admin.getByPlaceholder("URL or nevent...").fill("Start with the harbor rules")
|
||||
await admin.getByRole("button", {name: "Save changes"}).click()
|
||||
|
||||
await expect(admin.getByText("Featured content updated!")).toBeVisible()
|
||||
|
||||
// What admin featured reaches every visitor, and takes the top slot from recent activity
|
||||
await expect(carol.getByRole("heading", {name: "Featured"})).toBeVisible()
|
||||
await expect(carol.getByText("Start with the harbor rules")).toBeVisible()
|
||||
|
||||
await carol.getByRole("link", {name: "View all members"}).click()
|
||||
|
||||
await expect(carol).toHaveURL(/\/spaces\/space\.test\/directory/)
|
||||
})
|
||||
|
||||
test("US-016 catch up on a space's recent activity", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.room("quiet", {name: "Quiet Corner"})
|
||||
space.join(user.alice, "general", "quiet")
|
||||
space.join(user.bob, "general", "quiet")
|
||||
space.message(user.alice, "general", "the tide is high", at(30, MINUTE))
|
||||
space.message(user.alice, "quiet", "anyone still here?", at(3, HOUR))
|
||||
space.event(
|
||||
user.alice,
|
||||
makeEvent(THREAD, {
|
||||
created_at: at(2, HOUR),
|
||||
content: "Where should we sail next?",
|
||||
tags: [
|
||||
["h", "general"],
|
||||
["title", "Next voyage"],
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
// More items than the page renders at once, so the oldest is only reachable by scrolling
|
||||
for (let day = 1; day <= 20; day++) {
|
||||
space.event(
|
||||
user.alice,
|
||||
makeEvent(THREAD, {
|
||||
created_at: at(day, DAY),
|
||||
content: `Log entry ${day}`,
|
||||
tags: [
|
||||
["h", "general"],
|
||||
["title", `Old voyage ${day}`],
|
||||
],
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// A space with a member and nothing else
|
||||
other.join(user.bob)
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
const other = scenario.space("other")
|
||||
const bob = await as(users.bob, spacePath(space.url) + "/recent")
|
||||
const items = bob.locator(".cv")
|
||||
|
||||
await expect(bob.getByText("the tide is high")).toBeVisible()
|
||||
await expect(bob.getByText("anyone still here?")).toBeVisible()
|
||||
await expect(bob.getByText("Next voyage")).toBeVisible()
|
||||
|
||||
// Newest first: the latest message in each room alongside the thread, in one feed
|
||||
await expect(items.first()).toContainText("the tide is high")
|
||||
|
||||
await bob.mouse.move(640, 400)
|
||||
await bob.mouse.wheel(0, 8000)
|
||||
|
||||
await expect(bob.getByText("Old voyage 20")).toBeVisible()
|
||||
|
||||
// A message in the room that had gone quiet pulls it back to the top
|
||||
const alice = await as(users.alice, roomPath(space.url, "quiet"))
|
||||
|
||||
await alice.locator(".chat-editor [contenteditable=true]").pressSequentially("still here!")
|
||||
await alice.locator(".chat-editor [contenteditable=true]").press("Enter")
|
||||
|
||||
await expect(alice.getByText("still here!")).toBeVisible()
|
||||
await expect(items.first()).toContainText("Quiet Corner")
|
||||
|
||||
await bob.goto(spacePath(other.url) + "/recent")
|
||||
|
||||
await expect(bob.getByText("No recent activity found!")).toBeVisible()
|
||||
})
|
||||
|
||||
test("US-017 search across a space", async ({seed, as}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.room("random", {name: "Random"})
|
||||
space.join(user.alice, "general", "random")
|
||||
space.message(user.alice, "general", "the kraken surfaced at dawn", at(2, HOUR))
|
||||
space.message(user.alice, "random", "kraken jokes only in here", at(3, DAY))
|
||||
space.message(user.alice, "general", "ancient kraken lore, volume one", at(3, WEEK))
|
||||
})
|
||||
|
||||
const space = scenario.space("space")
|
||||
|
||||
// The directory rather than a feed: search is reachable from every page in a space, and this is
|
||||
// one where a result's own text can't also be on the page behind the dialog.
|
||||
const page = await as(users.alice, spacePath(space.url) + "/directory")
|
||||
|
||||
await page.locator(".secondary-nav").getByRole("button", {name: "Search"}).click()
|
||||
|
||||
const term = page.getByPlaceholder("Search this space...")
|
||||
|
||||
await term.fill("narwhal")
|
||||
|
||||
await expect(page.getByText("No results found.")).toBeVisible()
|
||||
|
||||
await term.fill("kraken")
|
||||
|
||||
await expect(page.getByText("Last 24 Hours")).toBeVisible()
|
||||
await expect(page.getByText("Last 7 Days")).toBeVisible()
|
||||
await expect(page.getByText("Older")).toBeVisible()
|
||||
|
||||
await expect(page.getByText("the kraken surfaced at dawn")).toBeVisible()
|
||||
await expect(page.getByText("ancient kraken lore, volume one")).toBeVisible()
|
||||
|
||||
const fromRandom = page.getByRole("button").filter({hasText: "kraken jokes only in here"})
|
||||
|
||||
await expect(fromRandom).toContainText("Random")
|
||||
|
||||
await fromRandom.click()
|
||||
|
||||
await expect(page).toHaveURL(/\/spaces\/space\.test\/random\?at=/)
|
||||
})
|
||||
Loading…
Reference in a new issue