Share the e2e locators and relay list fixtures across specs

This commit is contained in:
Coracle-Bot 2026-09-10 13:54:16 +00:00 committed by hodlbod
parent 6b49ef498d
commit 56ee9b4c44
19 changed files with 640 additions and 732 deletions

View file

@ -339,6 +339,8 @@ e2e/
harness/
index.ts everything a spec imports: `test`, `expect`, helpers
keys.ts deterministic keypairs
ui.ts the locators specs share: dialogs, the composer, a room's messages
files.ts the bytes an upload spec picks, and the browser's own file chooser
zooid/
config.ts the virtual relays and their hosts — the one place they are named
relay.ts docker lifecycle, reset, seeding over its own authenticated socket
@ -367,6 +369,12 @@ e2e/
*.spec.ts
```
A locator lives in the spec that uses it until a second spec needs the same one, at which point it
moves to `harness/ui.ts`. The class or the aria label it names is then one edit when the app renames
it, and the comment saying why it is shaped that way has one copy to keep true. What stays local is
what one spec means differently: `dms.spec.ts` names a message by its `data-event` id, because the
same words are sent more than once there.
One piece lives outside this directory: `src/lib/test/session.ts` holds the two window keys and the
getters `src/app/session.ts` reads them through. It is the only file the app ships on the harness's
behalf, and `harness/app/session.ts` duplicates the key names rather than importing them, since

30
e2e/harness/files.ts Normal file
View file

@ -0,0 +1,30 @@
import type {Locator, Page} from "@playwright/test"
// A file as the browser's own chooser takes one.
export type TestFile = {
name: string
mimeType: string
buffer: Buffer
}
// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched
// instead of re-encoding it 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. The base64 is what a spec hands
// to the page, since a Buffer does not survive the trip into evaluate().
export const GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
export const GIF = Buffer.from(GIF_BASE64, "base64")
// A 1x1 webp, which the compressor passes through for the same reason.
export const WEBP = Buffer.from("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", "base64")
export const gifFile = (name: string): TestFile => ({name, mimeType: "image/gif", buffer: GIF})
// Every picker in the app opens the browser's own chooser, which is the only place a spec can hand
// it a file: the input behind it is never on screen.
export const chooseFile = async (page: Page, button: Locator, file: TestFile) => {
const chooser = page.waitForEvent("filechooser")
await button.click()
await (await chooser).setFiles(file)
}

View file

@ -1,3 +1,4 @@
import {npubEncode} from "nostr-tools/nip19"
import {test as base, expect} from "@playwright/test"
import type {BrowserContext, BrowserContextOptions, Page} from "@playwright/test"
import type {Maybe, MaybeAsync} from "@welshman/lib"
@ -31,10 +32,48 @@ export type {SeededEvent} from "./seed/publish"
export type {SeededOpenRelay} from "./seed/openRelay"
export type {SeededRumor, SeededSpace} from "./seed/space"
export type {OpenRelayName, SpaceName, TenantName} from "./zooid/config"
export type {TranscriptEntry} from "./net/websocket"
export {forgetRelay, formatTranscript, getTranscript} from "./net/websocket"
export type {PublishedEvent, TranscriptEntry} from "./net/websocket"
export {
forgetRelay,
formatTranscript,
getPublished,
getPublishedEvents,
getTranscript,
} from "./net/websocket"
export {readCachedEvents} from "./app/cache"
export {
bubble,
chatItems,
chatList,
composer,
composerDisabled,
composerEnabled,
dialog,
emojiButton,
longDate,
menuButton,
message,
messageActions,
messages,
modalForm,
noteEditor,
openMessageMenu,
openRoomDetail,
pageBar,
pickEmoji,
roomLink,
send,
sendButton,
settingRow,
settingToggle,
timeline,
toast,
topDialog,
} from "./ui"
export {GIF, GIF_BASE64, WEBP, chooseFile, gifFile} from "./files"
export type {TestFile} from "./files"
export {
DEFAULT_BLOSSOM_ORIGIN,
assertNoBlockedRequests,
getHosting,
mockBlossom,
@ -58,6 +97,16 @@ export const spacePath = (url: string) => `/spaces/${encodeRelay(url)}`
export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}`
// The path the app builds for a conversation: the other participants' pubkeys, sorted and joined
// with commas — see makeChatId in src/app/chats.ts.
export const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
export const profilePath = (pubkey: string) => `/people/${npubEncode(pubkey)}`
// A literal as a pattern, for a url that carries a query string or a modal's hash alongside the
// path being matched, or a host whose dots would otherwise be wildcards.
export const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
// 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 or a permission of

View file

@ -255,6 +255,11 @@ export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3)
return spoken
}
// Where an upload lands when nothing else is configured. 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.
export const DEFAULT_BLOSSOM_ORIGIN = "https://blossom.primal.net"
export type BlossomOptions = {
// The blossom server the scenario expects an upload to land on, e.g. a space's own url.
server: string

View file

@ -1,7 +1,8 @@
import type {BrowserContext, WebSocketRoute} from "@playwright/test"
import {call, parseJson} from "@welshman/lib"
import {normalizeRelayUrl} from "@welshman/util"
import {RelayMessageType, isClientEvent, isClientReq} from "@welshman/net"
import type {TrustedEvent} from "@welshman/util"
import {ClientMessageType, RelayMessageType, isClientEvent, isClientReq} from "@welshman/net"
import type {ClientMessage, RelayMessage} from "@welshman/net"
import type {RelayConnection} from "../zooid/types"
import type {Zooid} from "../zooid/relay"
@ -16,6 +17,12 @@ export type TranscriptEntry = {
message: ClientMessage | RelayMessage
}
// An event the client published, and the relay it went to.
export type PublishedEvent = {
url: string
event: TrustedEvent
}
type Traffic = {
transcript: TranscriptEntry[]
leaks: Set<string>
@ -103,6 +110,22 @@ export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) =>
export const getTranscript = (context: BrowserContext) => trafficStore.get(context).transcript
// Every event this context put on the wire, oldest first, with the relay it went to. One event
// published to three relays is three entries.
export const getPublished = (context: BrowserContext): PublishedEvent[] =>
getTranscript(context)
.filter(
({direction, message}) => direction === "toRelay" && message[0] === ClientMessageType.Event,
)
.map(({url, message}) => ({url, event: message[1] as TrustedEvent}))
// The same, narrowed to one kind, which is how a spec asks what the client published rather than
// what it rendered.
export const getPublishedEvents = (context: BrowserContext, kind: number) =>
getPublished(context)
.filter(({event}) => event.kind === kind)
.map(({event}) => event)
// Makes a relay answer like one that never held anything, without its url becoming a leak. `serve`
// resolves a relay once, at open, so sockets already open keep theirs and the drop takes effect on
// the next connection. A reload is what gives it one.

View file

@ -6,14 +6,7 @@ import {tenantUrl} from "../zooid/config"
import type {OpenRelayName} from "../zooid/config"
import type {TestUser} from "../keys"
import {makePublisher} from "./publish"
import type {Enqueue, ProfileValues, SeededEvent, SeededTemplate} from "./publish"
// A nip-65 relay list, as the two sets a client reads off it: `write` is what an outbox-routed load
// for this pubkey resolves to, `read` is what a feed asks for that pubkey's context.
export type RelayListUrls = {
read?: string[]
write?: string[]
}
import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish"
/**
* One public relay's fixtures. Unlike a space it has no rooms, no members and nothing behind an

View file

@ -17,6 +17,13 @@ export type SeededEvent = {
// anything built by one has to be deferred.
export type SeededTemplate = StampedEvent | (() => MaybeAsync<EventTemplate>)
// A nip-65 relay list, as the two sets a client reads off it: `write` is what an outbox-routed load
// for this pubkey resolves to, `read` is what a feed asks for that pubkey's context.
export type RelayListUrls = {
read?: string[]
write?: string[]
}
export type ProfileValues = {
name?: string
about?: string

View file

@ -12,7 +12,7 @@ import {
} from "@welshman/util"
import type {HashedEvent} from "@welshman/util"
import {Nip59} from "@welshman/signer"
import {DirectMessage, EventWriter, Profile} from "@welshman/domain"
import {DirectMessage, EventWriter, MessagingRelayList, Profile, RelayList} from "@welshman/domain"
import type {BaseEventReader, ConfiguredKind, EventQuery, KindFactory} from "@welshman/domain"
import type {RoomOptions, TestRelay} from "../zooid/types"
import type {Zooid} from "../zooid/relay"
@ -21,7 +21,7 @@ import type {SpaceName} from "../zooid/config"
import {users} from "../keys"
import type {TestUser} from "../keys"
import {makePublisher} from "./publish"
import type {Enqueue, ProfileValues, SeededEvent, SeededTemplate} from "./publish"
import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish"
// @welshman/domain has no writer for NIP-29 kind-9 messages, and none of its readers describe one,
// so this pairs the base writer with the base reader. The behavior tags it renders are everything a
@ -54,6 +54,13 @@ 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
// Where this user reads and writes, this space by default. Outbox routing resolves everything
// about a person through their relay list — their profile, the events they authored, the wraps
// addressed to them — so a fixture is only loadable by somebody else once its author has one.
relayList(user: TestUser, urls?: RelayListUrls, createdAt?: number): SeededEvent
// The kind-10050 that says where someone's direct messages go, this space by default. Having one
// is what makes a person reachable, so a story about messaging being off is a person without one.
messagingRelayList(user: TestUser, urls?: string[], createdAt?: number): SeededEvent
event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent
// A nip-17 conversation. One kind-14 rumor, gift-wrapped once per participant including the
// sender, whose own copy is the half of the thread their client reads back.
@ -165,6 +172,20 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
factory: KindFactory<R, W, Q>,
) => factory.configure(context)
const relayList = (
user: TestUser,
{read = [url], write = [url]}: RelayListUrls = {},
createdAt = startedAt,
) =>
event(
user,
() => kind(RelayList).writer().setReadUrls(read).setWriteUrls(write).renderTemplate(),
createdAt,
)
const messagingRelayList = (user: TestUser, urls = [url], createdAt = startedAt) =>
event(user, () => kind(MessagingRelayList).writer().setUrls(urls).renderTemplate(), createdAt)
// Every wrap is published over the sender's own connection, since a gift wrap's author is an
// ephemeral key nobody in this process can authenticate as. zooid stores it anyway, authorizing a
// kind-1059 by the member named in its p tag.
@ -207,6 +228,8 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
message,
reply,
profile,
relayList,
messagingRelayList,
event,
dm,
kind,

147
e2e/harness/ui.ts Normal file
View file

@ -0,0 +1,147 @@
import {expect} from "@playwright/test"
import type {Locator, Page} from "@playwright/test"
/**
* What a spec names on screen. A locator more than one spec reaches for belongs here, so a class or
* a label the app renames is one edit rather than six — and so the comment explaining why a
* locator is shaped the way it is has one copy to keep true.
*/
// 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.
export const dialog = (page: Page, title: string) =>
page
.locator(".dialog")
.filter({has: page.getByRole("heading", {name: title, exact: true})})
.last()
// The modal on top, for one with no heading of its own or one pushed over another rather than
// alongside it.
export const topDialog = (page: Page) => page.locator(".dialog").last()
// A modal is mounted alongside the page it covers, so a page's own "Create" and the modal's submit
// are both in the dom at once. Anything said about the form is scoped to the modal's own to say
// which one is meant.
export const modalForm = (page: Page, title: string) =>
page.locator("form").filter({has: page.getByRole("heading", {name: title})})
// EventActions renders zap, emoji and menu into one join, in that order, and every one of them is
// an icon with no accessible name.
export const emojiButton = (scope: Locator) => scope.locator(".join").getByRole("button").nth(1)
export const menuButton = (scope: Locator) => scope.locator(".join").getByRole("button").last()
// 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.
export const pickEmoji = async (page: Page, opener: Locator, annotation: string) => {
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(annotation)
await picker
.getByRole("option", {name: new RegExp(annotation)})
.first()
.click()
}
export const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
// The room's page bar carries a search button and the detail button, in that order.
export const openRoomDetail = (page: Page) => pageBar(page).getByRole("button").last().click()
export const roomLink = (page: Page, name: string) =>
page.locator(".space-menu__scroll").getByRole("link", {name})
// One toast at a time — src/app/toast.ts holds a single writable — so this is the toast.
export const toast = (page: Page) => page.getByRole("alert")
// 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.
export const settingRow = (page: Page, label: string) =>
page.locator("div.items-center.justify-between").filter({hasText: label})
export const settingToggle = (page: Page, label: string) =>
settingRow(page, label).getByRole("checkbox")
export const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
// The editor is where the composer says whether it is ready. The send button is not there to ask
// while the composer is empty, since a dictation button stands in its place. Only the conversation
// composer has a disabled state; a room's is usable as soon as it renders.
export const composerEnabled = (page: Page) =>
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "false")
export const composerDisabled = (page: Page) =>
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "true")
// The send button carries the shortcut it advertises, which differs by platform.
export const sendButton = (page: Page) => page.locator("button[data-tip$='enter to send']")
export const timeline = (page: Page) => page.locator(".room__content")
// .room__content is column-reverse, so the message at the bottom of the room is the first one in
// the dom.
export const messages = (page: Page) => page.locator(".room__item")
export 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.
export const messageActions = (page: Page, text: string) =>
message(page, text).locator(".room__item-actions button")
export const openMessageMenu = (page: Page, text: string) =>
messageActions(page, text).last().click()
export const bubble = (page: Page, text: string) =>
page.locator(".chat-bubble").filter({hasText: text})
// Typing into a room's composer and sending it. The composer is waited for rather than assumed,
// since a room still rendering has none, and clicked into when the caret is somewhere else, since
// typing goes wherever it is. One that already holds the caret is typed into as it stands: clicking
// would collapse the selection, and selecting the whole of it is how an edit types over the message
// it replaces.
export const send = async (page: Page, content: string) => {
const editor = composer(page)
await expect(editor).toBeVisible()
const elsewhere = await editor.evaluate(el => el !== document.activeElement)
if (elsewhere) {
await editor.click()
}
await editor.pressSequentially(content)
await editor.press("Enter")
}
export const chatList = (page: Page) => page.locator(".secondary-nav .overflow-auto")
// One conversation in the sidebar list is one button; nothing inside it is one.
export const chatItems = (page: Page) => chatList(page).locator("button")
// The comment and thread composers are rich text rather than chat editors, so they carry a
// different one.
export const noteEditor = (scope: Locator | Page) =>
scope.locator(".note-editor [contenteditable=true]")
// A date as the browser formatted it rather than as node would, so the locale and the timezone are
// the ones the app rendered with. The options mirror dateFormatter in @welshman/lib.
export const longDate = (page: Page, seconds: number) =>
page.evaluate(
ts =>
new Intl.DateTimeFormat(undefined, {year: "numeric", month: "long", day: "numeric"}).format(
new Date(ts * 1000),
),
seconds,
)

View file

@ -2,28 +2,22 @@ import type {Locator, Page} from "@playwright/test"
import {DAY, HOUR, MINUTE, MONTH, now} from "@welshman/lib"
import {Article} from "@welshman/domain"
import {
DEFAULT_BLOSSOM_ORIGIN,
dialog,
expect,
getHosting,
gifFile,
makeTestUser,
menuButton,
mockBlossom,
openMessageMenu,
roomPath,
spacePath,
test,
users,
} from "../harness"
// Where an icon upload lands. SpaceEdit and RelayForm both call uploadFileOrFallback with no relay
// of their own, so the space's blossom probe never runs and VITE_DEFAULT_BLOSSOM_SERVERS is what's
// left.
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched
// instead of re-encoding it through a canvas, so what the mock hashes is what was picked.
const ICON = {
name: "icon.gif",
mimeType: "image/gif",
buffer: Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"),
}
const ICON = gifFile("icon.gif")
// The plans the hosting backend offers. `free` is what RelayForm starts on, and `basic` is the paid
// one every upgrade story moves to. PricingTable names a plan by its member limit, which is the
@ -71,14 +65,6 @@ const hostedRelay = (overrides: Record<string, unknown> = {}) => ({
...overrides,
})
// The panel of the modal carrying a given title. `.dialog` is on both the backdrop wrapper and the
// panel inside it, so the last match is the panel.
const dialog = (page: Page, title: string) =>
page
.locator(".dialog")
.filter({has: page.getByRole("heading", {name: title, exact: true})})
.last()
// Tippy mounts a menu the first time it is opened and leaves it in the dom when it hides, so a page
// that has opened two of them holds both — only the one on screen is visible. Exact, because
// "Edit role" and "Edit roles" are two different menus in the same directory.
@ -97,18 +83,7 @@ const openSpaceMenu = (page: Page) => page.getByRole("button", {name: /space\.te
const memberCard = (page: Page, name: string) =>
page.locator(".card-interactive").filter({hasText: name})
// RoomItem gives its hover actions no accessible names — every one is an icon — and the menu is
// the last of them.
const openMessageMenu = (page: Page, text: string) =>
page
.locator(".room__item")
.filter({hasText: text})
.locator(".room__item-actions button")
.last()
.click()
// EventActions renders zap, emoji and menu into one join, so the menu is the last button in it.
const openEventMenu = (card: Locator) => card.locator(".join").getByRole("button").last().click()
const openEventMenu = (card: Locator) => menuButton(card).click()
const articleCard = (page: Page, title: string) =>
page.locator('[data-component="ArticleItem"]').filter({hasText: title})
@ -135,7 +110,7 @@ test("US-092 edit a space's profile and featured content", async ({seed, as}) =>
const {url} = scenario.space("space")
const admin = await as(users.admin, spacePath(url) + "/about")
await mockBlossom(admin.context(), {server: BLOSSOM_ORIGIN})
await mockBlossom(admin.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
await openSpaceMenu(admin)
await menuItem(admin, "Edit Space").click()
@ -165,7 +140,7 @@ test("US-092 edit a space's profile and featured content", async ({seed, as}) =>
// All three come back off the relay's own nip-11 document
await expect(admin.getByRole("heading", {name: "Harbor"})).toBeVisible()
await expect(admin.getByText("Where the fleet ties up.")).toBeVisible()
await expect(admin.locator(`img[src^="${BLOSSOM_ORIGIN}"]`).first()).toBeVisible()
await expect(admin.locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}"]`).first()).toBeVisible()
const featured = admin
.locator(".card")

View file

@ -1,56 +1,36 @@
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 {SignedEvent} from "@welshman/util"
import {Article, Comment, Thread} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {expect, getTranscript, mockBlossom, roomPath, spacePath, test, users} from "../harness"
import {
DEFAULT_BLOSSOM_ORIGIN,
GIF,
emojiButton,
expect,
getPublishedEvents,
mockBlossom,
modalForm,
noteEditor,
pageBar,
pickEmoji,
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 | Page) => 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) =>
@ -62,27 +42,7 @@ const openComposeMenu = (page: Page) =>
.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()
}
const pickParty = (page: Page, opener: Locator) => pickEmoji(page, opener, "party popper")
// One reaction, recorded as the reader's own, and taken back off again.
const expectReactionRoundTrip = async (page: Page, scope: Locator, opener: Locator) => {
@ -153,7 +113,7 @@ test("US-037 write and publish an article", async ({seed, as}) => {
const publish = pageBar(page).getByRole("button", {name: "Publish"})
const title = page.getByPlaceholder("Title", {exact: true})
const body = editorOf(page)
const body = noteEditor(page)
await expect(title).toBeVisible()
@ -167,7 +127,7 @@ test("US-037 write and publish an article", async ({seed, as}) => {
await body.pressSequentially("Only the beginning.")
// Neither refusal put anything on the wire.
expect(publishedEvents(page, LONG_FORM)).toEqual([])
expect(getPublishedEvents(page.context(), LONG_FORM)).toEqual([])
await page.goBack()
await expect(articleCards(page)).toHaveCount(1)
@ -182,7 +142,7 @@ test("US-037 write and publish an article", async ({seed, as}) => {
// Publishing lands on the article itself rather than back on the list.
await expect(page.getByRole("heading", {name: "Signals in the Noise"}).first()).toBeVisible()
expect(publishedEvents(page, LONG_FORM)).toHaveLength(1)
expect(getPublishedEvents(page.context(), LONG_FORM)).toHaveLength(1)
await page.goto(`${spacePath(url)}/articles`)
@ -367,7 +327,7 @@ test("US-039 comment on an article", async ({seed, as}) => {
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 noteEditor(composerForm(bob)).pressSequentially("The soil chapter is the good one.")
await composerForm(bob).getByRole("button", {name: "Comment"}).click()
// The comment renders from the optimistic write, but the composer holds what was typed until the
@ -383,7 +343,7 @@ test("US-039 comment on an article", async ({seed, as}) => {
await expect(bobsComment).toBeVisible()
await bobsComment.getByRole("button", {name: "Reply", exact: true}).click()
await editorOf(composerForm(carol)).pressSequentially(
await noteEditor(composerForm(carol)).pressSequentially(
"Only because you skipped the water chapter.",
)
await composerForm(carol).getByRole("button", {name: "Reply", exact: true}).click()
@ -392,7 +352,7 @@ test("US-039 comment on an article", async ({seed, as}) => {
const alice = await as(users.alice, articlesPath)
await mockBlossom(alice.context(), {server: BLOSSOM_ORIGIN})
await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
await openArticle(alice, "Tending the Garden")
await expect(alice.getByText("The soil chapter is the good one.")).toBeVisible()
@ -408,8 +368,8 @@ test("US-039 comment on an article", async ({seed, as}) => {
const composer = composerForm(alice)
await editorOf(composer).pressSequentially("Thanks both, here's the bed I meant.")
await editorOf(composer).press("Enter")
await noteEditor(composer).pressSequentially("Thanks both, here's the bed I meant.")
await noteEditor(composer).press("Enter")
const chooser = alice.waitForEvent("filechooser")
@ -425,7 +385,7 @@ test("US-039 comment on an article", async ({seed, as}) => {
const alicesComment = comment(alice, "Thanks both, here's the bed I meant.")
await expect(alicesComment).toBeVisible()
await expect(alicesComment.locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
await expect(alicesComment.locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
})
test("US-040 react to a post with an emoji", async ({seed, as}) => {
@ -442,14 +402,7 @@ test("US-040 react to a post with an emoji", async ({seed, as}) => {
// 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(),
)
space.relayList(user.alice)
const article = space.event(
user.alice,
@ -566,7 +519,7 @@ test("US-041 publish an article from a room", async ({seed, as}) => {
await page.getByRole("button", {name: "Write an Article"}).click()
await page.getByPlaceholder("Title", {exact: true}).fill("Repotting in Winter")
await editorOf(page).pressSequentially("Wait for a warm week.")
await noteEditor(page).pressSequentially("Wait for a warm week.")
await pageBar(page).getByRole("button", {name: "Publish"}).click()
await expect(page.getByRole("heading", {name: "Repotting in Winter"}).first()).toBeVisible()
@ -574,7 +527,7 @@ test("US-041 publish an article from a room", async ({seed, as}) => {
// The room hears about the article without alice posting it a second time. Its copy is published
// after the composer has moved on, so wait for it to leave — a page that unloads mid-publish
// takes it with it.
await expect.poll(() => publishedEvents(page, MESSAGE)).toHaveLength(1)
await expect.poll(() => getPublishedEvents(page.context(), MESSAGE)).toHaveLength(1)
await page.goto(roomPath(url, "lounge"))
await expect(page.getByText("Repotting in Winter")).toBeVisible()
@ -650,10 +603,10 @@ test("US-042 start a thread and see it filed under its room", async ({seed, as})
await openComposeMenu(page)
await page.getByRole("button", {name: "Create Thread"}).click()
const fromRoom = modal(page, "Create a Thread")
const fromRoom = modalForm(page, "Create a Thread")
await fromRoom.getByPlaceholder("What is this thread about?").fill("Bike shed colors")
await editorOf(fromRoom).pressSequentially("Blue, surely?")
await noteEditor(fromRoom).pressSequentially("Blue, surely?")
await fromRoom.getByRole("button", {name: "Create Thread"}).click()
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
@ -684,10 +637,10 @@ test("US-042 start a thread and see it filed under its room", async ({seed, as})
// than from a picker.
await general.getByRole("button", {name: "Create", exact: true}).click()
const fromThreads = modal(page, "Create a Thread")
const fromThreads = modalForm(page, "Create a Thread")
await fromThreads.getByPlaceholder("What is this thread about?").fill("Open floor")
await editorOf(fromThreads).pressSequentially("Anything goes in here.")
await noteEditor(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)
@ -696,10 +649,10 @@ test("US-042 start a thread and see it filed under its room", async ({seed, as})
await lounge.getByRole("button", {name: "Create", exact: true}).click()
const toLounge = modal(page, "Create a Thread")
const toLounge = modalForm(page, "Create a Thread")
await toLounge.getByPlaceholder("What is this thread about?").fill("Carpet swatches")
await editorOf(toLounge).pressSequentially("Beige is a choice.")
await noteEditor(toLounge).pressSequentially("Beige is a choice.")
await toLounge.getByRole("button", {name: "Create Thread"}).click()
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
@ -774,7 +727,7 @@ test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
await expect(threadReply).toBeVisible()
await expect(threadReply.getByText(/^Replying to/)).toHaveCount(0)
await editorOf(threadReply).pressSequentially("Twice a year here.")
await noteEditor(threadReply).pressSequentially("Twice a year here.")
await threadReply.getByRole("button", {name: "Post Reply"}).click()
await expect(bob.getByText("21 replies")).toBeVisible()
@ -804,7 +757,7 @@ test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
await expect(postReply.getByText(/^Replying to/)).toHaveCount(0)
await editorOf(postReply).pressSequentially("Answering the thread instead.")
await noteEditor(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
@ -956,12 +909,12 @@ test("US-045 turn a chat message into a thread", async ({seed, as}) => {
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 composer = modalForm(page, "Create a Thread")
const nevent = nip19.neventEncode({id: promoted.id, kind: MESSAGE, relays: [url]})
// The seeded entity is parsed, so the composer shows the editor's chip for it rather than
// the raw uri — which is also what makes the thread carry a q tag for the message.
await expect(editorOf(composer)).toContainText(`${nevent.slice(0, 16)}...`)
await expect(noteEditor(composer)).toContainText(`${nevent.slice(0, 16)}...`)
await composer.getByPlaceholder("What is this thread about?").fill("Deploy failures")
await composer.getByRole("button", {name: "Create Thread"}).click()

View file

@ -14,10 +14,18 @@ import {
} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {
GIF,
dialog,
emojiButton,
expect,
getTranscript,
longDate,
makeTestUser,
menuButton,
mockDufflepud,
modalForm,
noteEditor,
pickEmoji,
roomPath,
spacePath,
test,
@ -28,27 +36,6 @@ import type {TestUser} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string}
// A modal is mounted alongside the page it covers, so a page's own "Create" and the modal's submit
// are both in the dom at once. Form assertions are scoped to the modal's own form to say which one
// is meant.
const dialog = (page: Page, title: string) =>
page.locator("form").filter({has: page.getByRole("heading", {name: title})})
// The library's modals are plain Modals rather than forms, so the dialog wrapper is what scopes
// them. Dialog nests two elements carrying the class; the inner one holds the content.
const panel = (page: Page, title: string) =>
page
.locator(".dialog")
.filter({has: page.getByRole("heading", {name: title, exact: true})})
.last()
const editorOf = (scope: Locator) => scope.locator(".note-editor [contenteditable=true]")
// EventActions renders zap, emoji and menu into one join, in that order.
const emojiOf = (scope: Locator) => scope.locator(".join").getByRole("button").nth(1)
const menuOf = (scope: Locator) => scope.locator(".join").getByRole("button").last()
// A shelf is a card and its menu button side by side, so the menu is reached through the wrapper
// the two share.
const shelfCard = (page: Page, title: string) =>
@ -63,17 +50,6 @@ const openCard = (card: Locator, title: string) => card.getByText(title).click()
// its progress bar.
const pollOption = (page: Page, label: string) => page.locator(".card-sm").filter({hasText: label})
// The options mirror dateFormatter in @welshman/lib. Formatted by the browser rather than by node,
// so the locale and the timezone are the ones the app rendered with — see dayLabel in dms.spec.ts.
const longDate = (page: Page, seconds: number) =>
page.evaluate(
ts =>
new Intl.DateTimeFormat(undefined, {year: "numeric", month: "long", day: "numeric"}).format(
new Date(ts * 1000),
),
seconds,
)
// The selections of every poll response this page put on the wire, oldest first. A multiple choice
// vote goes out once the delay window closes, so this is where "both publish" is visible.
const pollResponses = (page: Page, pollId: string) =>
@ -89,30 +65,7 @@ const pollResponses = (page: Page, pollId: string) =>
const PARTY = "🎉"
// The picker is a web component with an open shadow root, so its search field and its results are
// reachable through it. Searching rather than browsing avoids depending on which category tab an
// emoji happens to live under.
const pickParty = async (page: Page, opener: Locator) => {
await opener.click()
const picker = page.locator("emoji-picker").filter({visible: true})
// Tippy keeps a hidden popover mounted through its fade, so wait for there to be exactly one
// picker rather than reaching into whichever resolves first.
await expect(picker).toHaveCount(1)
// A result's label is the emoji's name, its annotation and every shortcode joined together, so
// more than one result can carry the annotation being matched.
await picker.locator("input.search").fill("party popper")
await picker
.getByRole("option", {name: /party popper/})
.first()
.click()
}
// A one pixel gif. Gif goes to the uploader untouched by the compressor, so this is uploaded byte
// for byte and the blossom mock can hash it.
const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64")
const pickParty = (page: Page, opener: Locator) => pickEmoji(page, opener, "party popper")
test("US-046 create and browse a calendar event", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
@ -167,7 +120,7 @@ test("US-046 create and browse a calendar event", async ({seed, as}) => {
await page.getByRole("button", {name: "Create", exact: true}).click()
const composer = dialog(page, "Create an Event")
const composer = modalForm(page, "Create an Event")
// Field renders its label and its input as siblings rather than wiring them together, so the
// form's two writable text fields are taken in document order: title, then location. The date
@ -258,10 +211,10 @@ test("US-047 manage your own calendar event", async ({seed, as}) => {
await expect(eventCard).toContainText("The Old Mill")
await expect(eventCard).toContainText("Alice Anderson")
await menuOf(eventCard).click()
await menuButton(eventCard).click()
await page.getByRole("button", {name: "Edit Event"}).click()
const composer = dialog(page, "Edit this Event")
const composer = modalForm(page, "Edit this Event")
const textInputs = composer.locator('input[type="text"]:not([readonly])')
await expect(textInputs.first()).toHaveValue("Harvest Supper")
@ -276,7 +229,7 @@ test("US-047 manage your own calendar event", async ({seed, as}) => {
await expect(page.getByRole("heading", {name: "Harvest Supper & Ceilidh"})).toBeVisible()
await expect(eventCard).toContainText("The Village Hall")
await menuOf(eventCard).click()
await menuButton(eventCard).click()
await page.getByRole("button", {name: "Delete Event"}).click()
const confirmDelete = page.getByRole("button", {name: "Confirm"})
@ -346,7 +299,7 @@ test("US-048 create a poll and vote on it", async ({seed, as}) => {
await alice.getByRole("button", {name: "Create", exact: true}).click()
const composer = dialog(alice, "Create a Poll")
const composer = modalForm(alice, "Create a Poll")
const question = composer.getByPlaceholder("What would you like to ask?")
await composer.getByRole("button", {name: "Create Poll"}).click()
@ -589,7 +542,7 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) =
await page.getByRole("button", {name: "Create", exact: true}).click()
const composer = dialog(page, "Create a Funding Goal")
const composer = modalForm(page, "Create a Funding Goal")
const title = composer.getByPlaceholder("What do funds go towards?")
const amount = composer.locator('input[type="number"]')
const slider = composer.locator('input[type="range"]')
@ -608,7 +561,7 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) =
"Please provide details about your funding goal.",
)
await editorOf(composer).pressSequentially("Three tiles came off in the storm.")
await noteEditor(composer).pressSequentially("Three tiles came off in the storm.")
// The target answers to the field and to the slider alike.
await amount.fill("5000")
@ -687,7 +640,7 @@ test("US-051 post, edit, and close out a classified listing", async ({seed, as})
await page.getByRole("button", {name: "Create", exact: true}).click()
const composer = dialog(page, "Create a Classified Listing")
const composer = modalForm(page, "Create a Classified Listing")
const title = composer.getByPlaceholder("What is this listing for?")
const price = composer.locator('input[type="number"]')
const currency = composer.locator("button.input")
@ -705,7 +658,7 @@ test("US-051 post, edit, and close out a classified listing", async ({seed, as})
"Please provide a description for your listing.",
)
await editorOf(composer).pressSequentially("Steel frame, new tires, barely ridden.")
await noteEditor(composer).pressSequentially("Steel frame, new tires, barely ridden.")
await currency.click()
await currency.locator("input").fill("USD")
@ -742,10 +695,10 @@ test("US-051 post, edit, and close out a classified listing", async ({seed, as})
await expect(detail).toContainText("1200")
await menuOf(detail).click()
await menuButton(detail).click()
await page.getByRole("button", {name: "Edit Listing"}).click()
const editor = dialog(page, "Edit this Listing")
const editor = modalForm(page, "Edit this Listing")
await expect(editor.getByPlaceholder("What is this listing for?")).toHaveValue(
"Vintage Road Bike",
@ -823,7 +776,7 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => {
await expect(eventCard).toBeVisible()
await pickParty(bob, emojiOf(eventCard))
await pickParty(bob, emojiButton(eventCard))
const bobsPill = eventCard.getByRole("button", {name: PARTY})
@ -835,7 +788,7 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => {
const reply = bob.locator("form").filter({has: bob.locator(".note-editor")})
await editorOf(reply).pressSequentially("Is there parking at The Green?")
await noteEditor(reply).pressSequentially("Is there parking at The Green?")
await reply.getByRole("button", {name: "Post Reply"}).click()
await expect(bob.getByText("Is there parking at The Green?")).toBeVisible()
@ -856,7 +809,7 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => {
const alicesReply = alice.locator("form").filter({has: alice.locator(".note-editor")})
await editorOf(alicesReply).pressSequentially("Yes, in the field behind the pub.")
await noteEditor(alicesReply).pressSequentially("Yes, in the field behind the pub.")
await alicesReply.getByRole("button", {name: "Post Reply"}).click()
// Past four replies the rest are folded away behind a control that names how many there are.
@ -894,7 +847,7 @@ test("US-053 browse and search the library", async ({seed, as}) => {
const createShelf = async (title: string, description: string) => {
await admin.getByRole("button", {name: "Create Shelf"}).click()
const form = panel(admin, "Create Shelf")
const form = dialog(admin, "Create Shelf")
await form.getByPlaceholder("Shelf title").fill(title)
await form.getByPlaceholder("What's this shelf about?").fill(description)
@ -911,7 +864,7 @@ test("US-053 browse and search the library", async ({seed, as}) => {
await shelfCard(admin, "Getting Started").getByRole("button", {name: "More options"}).click()
await admin.getByRole("button", {name: "Add link", exact: true}).click()
const linkForm = panel(admin, "Add Link")
const linkForm = dialog(admin, "Add Link")
await linkForm.getByPlaceholder("URL or nevent...").fill("https://handbook.test/start-here")
await linkForm.getByPlaceholder("Optional title").fill("The Handbook")
@ -994,7 +947,7 @@ test("US-054 curate the library", async ({seed, as}) => {
.filter({hasText: "Should we move standup to 10?"})
// With nothing on the shelves yet, the dialog says so and points at where one is made.
await menuOf(pollCard).click()
await menuButton(pollCard).click()
await page.getByRole("button", {name: "Add to Library"}).click()
await expect(page.getByText("This space doesn't have any shelves yet.")).toBeVisible()
@ -1004,7 +957,7 @@ test("US-054 curate the library", async ({seed, as}) => {
await page.getByRole("button", {name: "Create Shelf"}).click()
const shelfForm = panel(page, "Create Shelf")
const shelfForm = dialog(page, "Create Shelf")
await shelfForm.getByPlaceholder("Shelf title").fill("Reading List")
await shelfForm.getByPlaceholder("What's this shelf about?").fill("Things worth reading")
@ -1022,7 +975,7 @@ test("US-054 curate the library", async ({seed, as}) => {
// An external url becomes a card of its own...
await page.getByRole("button", {name: "Add a link"}).click()
const externalLink = panel(page, "Add Link")
const externalLink = dialog(page, "Add Link")
await externalLink.getByPlaceholder("URL or nevent...").fill("https://handbook.test/style-guide")
await externalLink.getByPlaceholder("Optional title").fill("The Style Guide")
@ -1041,7 +994,7 @@ test("US-054 curate the library", async ({seed, as}) => {
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
await page.getByRole("button", {name: "Add link", exact: true}).click()
const nostrLink = panel(page, "Add Link")
const nostrLink = dialog(page, "Add Link")
await nostrLink.getByPlaceholder("URL or nevent...").fill(`nostr:${nevent}`)
await nostrLink.getByPlaceholder("Optional title").fill("Yesterday's incident")
@ -1056,7 +1009,7 @@ test("US-054 curate the library", async ({seed, as}) => {
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
await page.getByRole("button", {name: "Edit shelf"}).click()
const shelfEdit = panel(page, "Edit Shelf")
const shelfEdit = dialog(page, "Edit Shelf")
await shelfEdit.getByPlaceholder("What's this shelf about?").fill("Things worth reading twice")
await shelfEdit.getByRole("button", {name: "Save changes"}).click()
@ -1068,11 +1021,11 @@ test("US-054 curate the library", async ({seed, as}) => {
// A post filed from its own menu lands on the shelf that was picked for it.
await page.goto(pollPath)
await menuOf(pollCard).click()
await menuButton(pollCard).click()
await page.getByRole("button", {name: "Add to Library"}).click()
await page.getByRole("button", {name: /Reading List/}).click()
const fromPoll = panel(page, "Add Link")
const fromPoll = dialog(page, "Add Link")
await expect(fromPoll.getByPlaceholder("URL or nevent...")).not.toHaveValue("")
@ -1127,7 +1080,7 @@ test("US-055 create community content from a room", async ({seed, as}) => {
await page.getByRole("button", {name: "Ask a Question", exact: true}).click()
const composer = dialog(page, "Create a Poll")
const composer = modalForm(page, "Create a Poll")
await composer.getByPlaceholder("What would you like to ask?").fill("Pizza or tacos?")
await composer.getByPlaceholder("Option 1").fill("Pizza")

View file

@ -1,59 +1,26 @@
import type {Locator, Page} from "@playwright/test"
import {HOUR} from "@welshman/lib"
import {MessagingRelayList, RelayList} from "@welshman/domain"
import {expect, makeTestUser, mockBlossom, 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"),
})
// 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']")
// The editor is where the composer says whether it is ready. The send button is not there to
// ask while the composer is empty, since a dictation button stands in its place.
const composerEnabled = (page: Page) =>
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "false")
import {
DEFAULT_BLOSSOM_ORIGIN,
GIF_BASE64,
chatPath,
chooseFile,
composer,
composerEnabled,
expect,
gifFile,
makeTestUser,
messageActions,
mockBlossom,
roomPath,
sendButton,
test,
timeline,
users,
} from "../harness"
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()
@ -64,22 +31,6 @@ const chatUploadButton = (page: Page) => page.locator("button[data-tip='Add an i
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.
@ -103,7 +54,7 @@ const dropImage = (editor: Locator, name: string) =>
}),
)
},
{name, data: GIF},
{name, data: GIF_BASE64},
)
const pasteImage = (editor: Locator, name: string) =>
@ -119,7 +70,7 @@ const pasteImage = (editor: Locator, name: string) =>
new ClipboardEvent("paste", {bubbles: true, cancelable: true, clipboardData: transfer}),
)
},
{name, data: GIF},
{name, data: GIF_BASE64},
)
test("US-056 autocomplete a mention or a room reference", async ({seed, as}) => {
@ -134,7 +85,7 @@ test("US-056 autocomplete a mention or a room reference", async ({seed, as}) =>
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.relayList(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
@ -143,7 +94,7 @@ test("US-056 autocomplete a mention or a room reference", async ({seed, as}) =>
other.join(user.alice, "lounge")
other.join(outsider, "lounge")
other.profile(outsider, {name: "Bobbin Amaranth"})
seedRelayList(other, outsider)
other.relayList(outsider)
other.message(outsider, "lounge", "hello from the lounge", at(2, HOUR))
})
@ -227,8 +178,8 @@ test("US-057 attach and send an image", async ({seed, as}) => {
for (const person of [user.alice, user.bob]) {
space.profile(person, {name: person.name})
seedRelayList(space, person)
seedMessagingRelays(space, person)
space.relayList(person)
space.messagingRelayList(person)
}
})
@ -237,7 +188,7 @@ test("US-057 attach and send an image", async ({seed, as}) => {
const alice = await as(users.alice, path)
const bob = await as(users.bob, path)
const blossom = await mockBlossom(alice.context(), {server: BLOSSOM_ORIGIN})
const blossom = await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
await blossom.install(bob.context())
@ -258,8 +209,8 @@ test("US-057 attach and send an image", async ({seed, as}) => {
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 expect(timeline(alice).locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
await expect(timeline(bob).locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
await dropImage(composer(alice), "dropped.gif")
@ -306,7 +257,7 @@ test("US-057 attach and send an image", async ({seed, as}) => {
buffer: Buffer.from("not an image"),
})
await expect(composer(alice)).toContainText(BLOSSOM_ORIGIN)
await expect(composer(alice)).toContainText(DEFAULT_BLOSSOM_ORIGIN)
await expect(alice.getByRole("alert")).toHaveCount(0)
// What the server refuses is what the composer refuses, in the words the server used.
@ -335,8 +286,8 @@ test("US-058 drafts survive navigating away", async ({seed, as}) => {
// 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)
space.relayList(person)
space.messagingRelayList(person)
}
})
@ -399,7 +350,7 @@ test("US-059 cancel a reply or an edit in progress", async ({seed, as}) => {
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Roberts"})
seedRelayList(space, user.bob)
space.relayList(user.bob)
space.message(user.bob, "general", "morning all", at(2, HOUR))
})

View file

@ -1,79 +1,50 @@
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 {Article, Comment, Reaction} from "@welshman/domain"
import type {Page} from "@playwright/test"
import {
DEFAULT_BLOSSOM_ORIGIN,
bubble,
chatItems,
chatPath,
chooseFile,
composer,
composerEnabled,
expect,
getPublished,
getPublishedEvents,
gifFile,
menuButton,
message,
mockBlossom,
noteEditor,
pageBar,
roomPath,
send,
sendButton,
spacePath,
test,
timeline,
toast,
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(),
)
space.relayList(user)
}
// 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']")
// The editor is where the composer says whether it is ready. The send button is not there to
// ask while the composer is empty, since a dictation button stands in its place. Only the
// conversation composer has a disabled state; a room's is usable as soon as it renders.
const composerEnabled = (page: Page) =>
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "false")
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 conversation in the sidebar list is one button; nothing inside it is one.
const chatItems = (page: Page) => page.locator(".secondary-nav .overflow-auto").locator("button")
// 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 is a flat block in the comment tree rather than a card, named by its text.
const commentCard = (page: Page, text: string) =>
page.locator('[data-component="Comment"]').filter({hasText: text})
@ -81,35 +52,9 @@ const commentCard = (page: Page, text: string) =>
// The action bar under an article on its own page, which is where that article's status shows.
const articleActions = (page: Page) => page.locator('[data-component="ArticleActions"]')
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
const editorOf = (scope: Locator | Page) => scope.locator(".note-editor [contenteditable=true]")
// Every caller of this sends to a room, whose composer gates on nothing, so the wait is for the
// room to have rendered one.
const send = async (page: Page, content: string) => {
await expect(composer(page)).toBeVisible()
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)
getPublished(page.context())
.filter(({event}) => event.id === id)
.map(({url}) => url)
@ -117,20 +62,20 @@ const publishedTo = (page: Page, id: string) =>
const writeArticle = async (page: Page, title: string, body: string) => {
await pageBar(page).getByRole("button", {name: "Write"}).click()
await page.getByPlaceholder("Title", {exact: true}).fill(title)
await editorOf(page).pressSequentially(body)
await noteEditor(page).pressSequentially(body)
await pageBar(page).getByRole("button", {name: "Publish"}).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")})
const form = page.locator("form").filter({has: page.locator(".note-editor")})
// The editor takes focus itself once it has mounted, and typing into it before that puts the
// caret back at the start partway through the sentence.
await expect(editorOf(composer)).toBeFocused()
await editorOf(composer).pressSequentially(body)
await composer.getByRole("button", {name: "Comment"}).click()
await expect(noteEditor(form)).toBeFocused()
await noteEditor(form).pressSequentially(body)
await form.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
@ -152,13 +97,6 @@ const setSendDelay = async (page: Page, seconds: number) => {
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")
@ -169,7 +107,7 @@ test("US-068 watch a delayed send, and cancel it", async ({seed, as}) => {
for (const person of [user.alice, user.bob]) {
seedPerson(space, person, person.name)
enableDms(space, person, () => [space.url])
space.messagingRelayList(person)
}
space.message(user.bob, "general", "morning all", at(2, HOUR))
@ -259,10 +197,10 @@ test("US-069 see why a message failed to deliver", async ({seed, as}) => {
seedPerson(space, person, person.name)
}
enableDms(space, user.alice, () => [space.url])
space.messagingRelayList(user.alice)
// 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.messagingRelayList(user.bob, [space.url, other.url])
space.message(user.bob, "general", "morning all", at(2, HOUR))
})
@ -365,7 +303,7 @@ test("US-070 retry a failed relay", async ({seed, as}) => {
await expect(toast(alice)).toContainText("Sending...")
await expect(toast(alice)).toContainText("Message sent!")
const [sent] = publishedEvents(alice, MESSAGE)
const [sent] = getPublishedEvents(alice.context(), 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])
@ -411,7 +349,7 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
const comment = commentCard(alice, "Worth saying twice.")
await expect(menuOf(comment)).toBeVisible()
await expect(menuButton(comment)).toBeVisible()
await expect(comment.getByText("Sending...")).toHaveCount(0)
await expect(comment.getByText("Failed to send!")).toHaveCount(0)
await expect(toast(alice)).toHaveCount(0)
@ -524,7 +462,7 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
await expect(comment).toBeVisible()
await expect(comment.getByRole("button", {name: /🎉/})).toBeVisible()
await menuOf(comment).click()
await menuButton(comment).click()
await alice.getByRole("button", {name: "Delete Comment"}).click()
await alice.getByRole("button", {name: "Confirm"}).click()
@ -553,24 +491,24 @@ test("US-073 a multi-part message reports one status", async ({seed, as}) => {
seedPerson(space, person, person.name)
}
enableDms(space, user.alice, () => [space.url])
enableDms(space, user.bob, () => [space.url])
space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob)
// 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])
space.messagingRelayList(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 mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
await mockBlossom(bob.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
await composerEnabled(alice)
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")
await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), gifFile("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.
@ -599,7 +537,7 @@ test("US-073 a multi-part message reports one status", async ({seed, as}) => {
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 chooseFile(alice, alice.locator("button[data-tip='Add an image']"), gifFile("harbour.gif"))
await expect(composer(alice)).toContainText("harbour.gif")
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)

View file

@ -2,27 +2,30 @@ import {npubEncode} from "nostr-tools/nip19"
import type {Locator, Page} from "@playwright/test"
import {DAY, HOUR, MINUTE} from "@welshman/lib"
import {DIRECT_MESSAGE, REACTION} from "@welshman/util"
import {MessagingRelayList, RelayList} from "@welshman/domain"
import {MessagingRelayList} from "@welshman/domain"
import {
bubble,
chatItems,
chatList,
chatPath,
composer,
composerDisabled,
composerEnabled,
expect,
forgetRelay,
makeTestUser,
pageBar,
pathPattern,
profilePath,
readCachedEvents,
roomPath,
send,
test,
topDialog,
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.
@ -31,39 +34,14 @@ const pathPattern = (path: string) => new RegExp(path.replace(/[.?+*()[\]]/g, "\
const seedPerson = (space: SeededSpace, user: TestUser, name: string, ...rooms: string[]) => {
space.join(user, ...rooms)
space.profile(user, {name})
space.event(user, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url])
.renderTemplate(),
)
space.relayList(user)
}
// 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 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()
// The "..." menu on a profile header, which is the only ghost circle button either the profile
// page or the profile modal renders.
const profileMenu = (scope: Page | Locator) => scope.locator("button.button-circle.button-ghost")
@ -74,32 +52,21 @@ const modalBody = (page: Page, title: string) =>
.locator(".scroll-container")
.filter({has: page.getByRole("heading", {name: title, exact: true})})
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
// The editor is where the composer says whether it is ready. The send button is not there to
// ask while the composer is empty, since a dictation button stands in its place.
const composerEnabled = (page: Page) =>
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "false")
const composerDisabled = (page: Page) =>
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "true")
// 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})
// A conversation names its messages by id rather than by text: the same words are sent more than
// once in these stories, and only the id says which bubble is which.
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) => {
const sendDm = async (page: Page, content: string) => {
await composerEnabled(page)
await composer(page).pressSequentially(content)
await composer(page).press("Enter")
await send(page, content)
}
// A suggestion carries the pubkey it selects as its label, so the name is what gets typed and the
@ -175,8 +142,8 @@ test("US-029 start a one-on-one chat", async ({seed, as}) => {
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)
space.messagingRelayList(user.alice)
space.messagingRelayList(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.
@ -198,7 +165,7 @@ test("US-029 start a one-on-one chat", async ({seed, as}) => {
await expect(composer(page)).toBeVisible()
await composerEnabled(page)
await send(page, "hi bob")
await sendDm(page, "hi bob")
await expect(bubble(page, "hi bob")).toBeVisible()
@ -228,7 +195,7 @@ test("US-029 start a one-on-one chat", async ({seed, as}) => {
await page.goto("/chat")
await chatItems(page).filter({hasText: "welcome aboard"}).click()
await bubble(page, "welcome aboard").getByRole("button", {name: "Carol Cutter"}).first().click()
await profileMenu(dialog(page)).click()
await profileMenu(topDialog(page)).click()
await page.getByRole("button", {name: "Send Message"}).click()
await expect(page).toHaveURL(pathPattern(chatPath(users.carol.pubkey)))
@ -248,9 +215,9 @@ test("US-030 start a group chat", async ({seed, as}) => {
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)
space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob)
space.messagingRelayList(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))
@ -352,7 +319,7 @@ test("US-031 direct messaging has to be switched on", async ({seed, as}) => {
await composerEnabled(alice)
await expect(banner).toHaveCount(0)
await send(alice, "finally")
await sendDm(alice, "finally")
await expect(bubble(alice, "finally")).toBeVisible()
await expect(chatItems(bob).filter({hasText: "finally"})).toBeVisible()
@ -368,8 +335,8 @@ test("US-032 exchange messages in a conversation", async ({seed, as}) => {
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
enableDms(space, user.alice)
enableDms(space, user.bob)
space.messagingRelayList(user.alice)
space.messagingRelayList(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.
@ -402,7 +369,7 @@ test("US-032 exchange messages in a conversation", async ({seed, as}) => {
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 sendDm(alice, "just got here")
await expect(bubble(alice, "just got here")).toBeVisible()
await expect(bubble(alice, "just got here")).toHaveClass(/chat-bubble--user/)
@ -423,7 +390,7 @@ test("US-033 browse and search your conversations", async ({seed, as}) => {
seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter")
seedPerson(space, dave, "Dave Davits")
enableDms(space, user.alice)
space.messagingRelayList(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))
@ -469,8 +436,8 @@ test("US-034 track and clear unread conversations", async ({seed, as}) => {
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.messagingRelayList(user.alice)
space.messagingRelayList(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))
@ -488,7 +455,7 @@ test("US-034 track and clear unread conversations", async ({seed, as}) => {
// 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 sendDm(bob, "are you free later?")
await expect(bobChat).toBeVisible()
await expect(unreadDots(bobChat)).toBeVisible()
@ -529,8 +496,8 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
enableDms(space, user.alice)
enableDms(space, user.bob)
space.messagingRelayList(user.alice)
space.messagingRelayList(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))
@ -561,7 +528,7 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
await send(alice, "yes I did")
await sendDm(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?")
@ -580,7 +547,7 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
await expect(composer(alice)).toContainText("first attempt")
await composer(alice).press("ControlOrMeta+a")
await send(alice, "second attempt")
await sendDm(alice, "second attempt")
await expect(bubble(alice, "second attempt")).toBeVisible()
await expect(alice.locator(".chat-bubble").filter({hasText: "first attempt"})).toHaveCount(0)
@ -626,8 +593,8 @@ test("US-036 receive a new conversation live", async ({seed, as}) => {
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)
space.messagingRelayList(user.alice)
space.messagingRelayList(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.
@ -650,7 +617,7 @@ test("US-036 receive a new conversation live", async ({seed, as}) => {
await expect(bob).toHaveURL(pathPattern(chatPath(users.alice.pubkey)))
await send(bob, "starting a chat with you")
await sendDm(bob, "starting a chat with you")
// Her list picks the conversation up on its own
const fromBob = chatItems(alice).filter({hasText: "Bob Barnacle"})
@ -669,7 +636,7 @@ test("US-108 read messages from a relay you only use for messages", async ({seed
seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle")
enableDms(space, user.bob)
space.messagingRelayList(user.bob)
// Alice's inbox is a relay she has nothing else to do with: not a space she has joined, and
// not one of her read or write relays. Membership of it is only what lets a wrap addressed to
@ -723,8 +690,8 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => {
seedPerson(space, user.alice, "Alice Anchor", "general")
seedPerson(space, user.bob, "Bob Barnacle", "general")
enableDms(space, user.alice)
enableDms(space, user.bob)
space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob)
his = space.dm(user.bob, [user.alice], "the tide charts are up", at(2, HOUR))
hers = space.dm(user.alice, [user.bob], "thakns", at(1, HOUR))
@ -755,7 +722,7 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => {
await expect(composer(alice)).toContainText("thakns")
await composer(alice).press("ControlOrMeta+a")
await send(alice, "thanks")
await sendDm(alice, "thanks")
await expect(bubble(alice, "thanks")).toBeVisible()
await expect(alice.locator(".chat-bubble").filter({hasText: "thakns"})).toHaveCount(0)

View file

@ -2,15 +2,28 @@ import {neventEncode, npubEncode} from "nostr-tools/nip19"
import {HOUR, MINUTE} from "@welshman/lib"
import {displayRelayUrl} from "@welshman/util"
import {RelayMessageType} from "@welshman/net"
import {Classified, FollowList, MessagingRelayList, Note, RelayList, Thread} from "@welshman/domain"
import {Classified, FollowList, Note, Thread} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {expect, getTranscript, roomPath, spacePath, test, users} from "../harness"
import {
composer,
dialog,
expect,
getTranscript,
message,
messageActions,
openRoomDetail,
pageBar,
pathPattern,
roomLink,
roomPath,
send,
settingRow,
spacePath,
test,
users,
} from "../harness"
import type {SeededEvent, SeededSpace, TestUser} from "../harness"
// A literal as a pattern, for a url that carries a query string alongside the path being matched,
// or a host whose dots would otherwise be wildcards.
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.
@ -25,11 +38,6 @@ const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50")
// 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
@ -43,41 +51,23 @@ const spaceMenuNavItem = (page: Page) => page.getByRole("button", {name: "Open s
// 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))})
page.locator(".secondary-nav").getByRole("button", {name: pathPattern(displayRelayUrl(url))})
// The menu it opens closes itself on the next mouseup anywhere, and a click whose press and release
// land in the same instant can reach that listener as it mounts, shutting the menu again. A human's
// click has a gap between the two; playwright's has one only when it is asked for.
const openSpaceMenu = (menu: Locator) => menu.click({delay: 200})
const roomLink = (page: Page, name: string) =>
page.locator(".space-menu__scroll").getByRole("link", {name})
// The room's page bar carries 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")
// Every story here goes on to assert something that depends on the message having landed, so
// posting is only finished once it has rendered.
const post = async (page: Page, content: string) => {
await send(page, content)
await expect(message(page, content)).toBeVisible()
}
// 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 replyToMessage = (page: Page, text: string) =>
message(page, text).locator(".room__item-actions button").nth(2).click()
// RoomItem's hover actions are icons with no accessible names, in a fixed order: zap, emoji, reply,
// edit (only on your own recent message), menu.
const replyToMessage = (page: Page, text: string) => messageActions(page, text).nth(2).click()
// Chromium's own notifications are invisible to a test, and a tab playwright drives is never
// hidden, so both of the things the adapter reads are stubbed on the page. It takes the global at
@ -115,25 +105,16 @@ const captureNotifications = async (page: Page) => {
// 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)
await expect(pageBar(page)).toContainText(room)
await post(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(),
)
space.relayList(user)
space.messagingRelayList(user)
}
test("US-103 see and clear unread indicators", async ({seed, as}) => {
@ -162,7 +143,7 @@ test("US-103 see and clear unread indicators", async ({seed, as}) => {
await expect(unreadDot(spaceRow)).toHaveCount(0)
await expect(unreadDot(navItem)).toHaveCount(0)
await send(alice, "the server is on fire")
await post(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()
@ -231,7 +212,7 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => {
await general.click()
await expect(alice).toHaveURL(pattern(roomPath(space.url, "general")))
await expect(alice).toHaveURL(pathPattern(roomPath(space.url, "general")))
// Silence this one room from its detail panel. Mute is the stronger of the two settings there: it
// forces the room's notifications off and hides its unread badges too.
@ -253,7 +234,7 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => {
const bob = await as(users.bob, roomPath(space.url, "general"))
await send(bob, "deploy is broken")
await post(bob, "deploy is broken")
await postTo(bob, "Random", "lunch?")
// The sibling room's message raises a dot, the muted room's does not
@ -318,7 +299,7 @@ test("US-105 land on the home page", async ({seed, as}) => {
// 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)))
await expect(platform).toHaveURL(pathPattern(spacePath(space.url)))
// With none configured, /home is the dashboard, whose empty inbox offers two ways out
const page = await as(users.alice, "/home")
@ -580,7 +561,7 @@ test("US-106 share text into the app", async ({seed, as}) => {
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(page).toHaveURL(pathPattern(roomPath(space.url, "general")))
await expect(composer(page)).toHaveText(shared)
})
@ -629,7 +610,7 @@ test("US-107 open a nostr link", async ({seed, as}) => {
await page.goto("/" + link.replace(/^nostr:/, ""))
await expect(page).toHaveURL(pattern(roomPath(space.url, "general") + "?at="))
await expect(page).toHaveURL(pathPattern(roomPath(space.url, "general") + "?at="))
await expect(message(page, "the meeting moved to friday")).toBeVisible()
// A person's npub resolves the same way
@ -637,7 +618,7 @@ test("US-107 open a nostr link", async ({seed, as}) => {
await page.goto(`/${npub}`)
await expect(page).toHaveURL(pattern(`/people/${npub}`))
await expect(page).toHaveURL(pathPattern(`/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
@ -677,14 +658,14 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as
const inOther = await as(users.alice, roomPath(other.url, "general"))
await send(inOther, "the server is on fire")
await post(inOther, "the server is on fire")
await expect(unreadDot(menuButton)).toBeVisible()
// Meanwhile the space bob is sitting in gets a message too, in a room he isn't reading
const inSpace = await as(users.alice, roomPath(space.url, "random"))
await send(inSpace, "anyone seen the sextant?")
await post(inSpace, "anyone seen the sextant?")
await bob.goto(roomPath(other.url, "general"))
@ -771,7 +752,7 @@ test("US-112 see which threads are unread", async ({seed, as}) => {
await hers.click()
await expect(bob.locator('[data-component="PageBar"]')).toContainText("the server is on fire")
await expect(pageBar(bob)).toContainText("the server is on fire")
// Leaving the list is what marks its threads read, so the dot is gone on the way back
await bob.goBack()
@ -883,7 +864,7 @@ test("US-120 read what a notification says", async ({seed, as}) => {
await spaceNavItem(alice, space.name).click()
await roomLink(alice, "General").click()
await expect(alice).toHaveURL(pattern(roomPath(space.url, "general")))
await expect(alice).toHaveURL(pathPattern(roomPath(space.url, "general")))
await expect(message(alice, "when does the dock close?")).toBeVisible()
const notifications = await captureNotifications(alice)

View file

@ -4,9 +4,21 @@ 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"
import {FollowList, Note, PinList, Profile} from "@welshman/domain"
import {
DEFAULT_BLOSSOM_ORIGIN,
GIF,
WEBP,
expect,
makeTestUser,
mockBlossom,
profilePath,
spacePath,
test,
topDialog,
users,
} from "../harness"
import type {TestUser} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string; readonly event: SignedEvent}
@ -17,22 +29,10 @@ 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)}`
`${DEFAULT_BLOSSOM_ORIGIN}/${createHash("sha256").update(body).digest("hex")}.${extension}`
// displayPubkey in @welshman/domain: the npub with its middle taken out.
const shortNpub = (user: TestUser) => {
@ -49,10 +49,6 @@ const pageContent = (page: Page) => page.locator(".page__content")
// 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()
// A space path redirects to the space's entry room as the page mounts, and modal.ts closes every
// open modal on navigation, so a dialog opened before the redirect lands is thrown away with it.
const enteredSpace = (page: Page) => expect(page).toHaveURL(new RegExp("/spaces/[^/]+/."))
@ -62,7 +58,7 @@ const openSearch = (page: Page) => page.locator('.primary-nav button[data-tip="S
const searchTerm = (page: Page) => page.getByPlaceholder("Search your spaces...")
const searchResults = (page: Page) => dialog(page).locator(".card.card-interactive")
const searchResults = (page: Page) => topDialog(page).locator(".card.card-interactive")
const notes = (page: Page) => pageContent(page).locator(".cv.card")
@ -74,18 +70,6 @@ const profileMenu = (page: Page) => page.locator("button.button-circle.button-gh
// 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, so that the ten the dialog lists are visibly the best matches rather than
// everyone who matched.
@ -113,7 +97,7 @@ test("US-074 find a person", async ({seed, as}) => {
about: "Dockside cook and keeper of the ship's cat.",
})
seedRelayList(space, user.bob)
space.relayList(user.bob)
for (const [i, searcher] of searchers.entries()) {
space.member(searcher)
@ -150,7 +134,7 @@ test("US-074 find a person", async ({seed, as}) => {
await viewProfile(bobCard).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
})
@ -183,17 +167,17 @@ test("US-075 view someone's profile", async ({seed, as}) => {
.renderTemplate(),
)
seedRelayList(space, user.bob)
space.relayList(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)
space.relayList(user.carol)
})
const space = scenario.space("space")
const other = scenario.space("other")
const page = await as(users.alice, profilePath(users.bob), {context: CLIPBOARD})
const page = await as(users.alice, profilePath(users.bob.pubkey), {context: CLIPBOARD})
const region = pageContent(page)
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
@ -229,7 +213,7 @@ test("US-075 view someone's profile", async ({seed, as}) => {
await expect(page).toHaveURL(new RegExp(spacePath(space.url)))
await page.goto(profilePath(users.carol))
await page.goto(profilePath(users.carol.pubkey))
await expect(page.getByRole("heading", {name: "Carol Cutter"})).toBeVisible()
await expect(sidebar(page).getByText("No spaces found.")).toBeVisible()
@ -243,11 +227,11 @@ test("US-076 follow and unfollow", async ({seed, as}) => {
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)
space.relayList(user.alice)
space.relayList(user.bob)
})
const page = await as(users.alice, profilePath(users.bob), {context: DESKTOP})
const page = await as(users.alice, profilePath(users.bob.pubkey), {context: DESKTOP})
const follow = page.getByRole("button", {name: "Follow", exact: true})
const unfollow = page.getByRole("button", {name: "Unfollow", exact: true})
@ -258,7 +242,7 @@ test("US-076 follow and unfollow", async ({seed, as}) => {
// 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 expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
await unfollow.click()
@ -278,9 +262,9 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
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)
space.relayList(user.alice)
space.relayList(user.bob)
space.relayList(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.
@ -313,7 +297,7 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
await viewProfile(bobCard).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
// 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/)
@ -326,7 +310,7 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
await viewProfile(carolCard).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.carol)}$`))
await expect(page).toHaveURL(new RegExp(`${profilePath(users.carol.pubkey)}$`))
await page.getByRole("button", {name: "Follow", exact: true}).click()
@ -365,12 +349,12 @@ test("US-078 edit your own profile", async ({seed, as}) => {
.setNip05("alice@flotilla.test")
.renderTemplate(),
)
seedRelayList(space, user.alice)
space.relayList(user.alice)
})
const page = await as(users.alice, profilePath(users.alice), {context: DESKTOP})
const page = await as(users.alice, profilePath(users.alice.pubkey), {context: DESKTOP})
await mockBlossom(page.context(), {server: BLOSSOM_ORIGIN})
await mockBlossom(page.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
const region = pageContent(page)
const edit = page.getByRole("button", {name: "Edit profile"})
@ -445,8 +429,8 @@ test("US-079 read a person's notes", async ({seed, as}) => {
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)
space.relayList(user.alice)
space.relayList(user.carol)
carolNote = space.event(
user.carol,
@ -493,7 +477,7 @@ test("US-079 read a person's notes", async ({seed, as}) => {
})
const {url} = scenario.space("space")
const page = await as(users.bob, profilePath(users.alice), {context: DESKTOP})
const page = await as(users.bob, profilePath(users.alice.pubkey), {context: DESKTOP})
const list = notes(page)
const newest = list.filter({hasText: "NEWEST"})
@ -549,7 +533,7 @@ test("US-079 read a person's notes", async ({seed, as}) => {
await expect(list.nth(1)).toContainText("LIVE")
// Carol's own note is on her profile; alice's reply to it is alice's, so it isn't here.
await page.goto(profilePath(users.carol))
await page.goto(profilePath(users.carol.pubkey))
await expect(notes(page).filter({hasText: "Anyone seen the tide charts?"})).toBeVisible()
await expect(notes(page).filter({hasText: "REPLY"})).toHaveCount(0)
@ -570,7 +554,7 @@ test("US-080 preview a profile from anywhere", async ({seed, as}) => {
picture: avatar,
})
space.message(user.bob, "general", "the cat has the helm", at(1, HOUR))
seedRelayList(space, user.bob)
space.relayList(user.bob)
})
const {url} = scenario.space("space")
@ -589,10 +573,10 @@ test("US-080 preview a profile from anywhere", async ({seed, as}) => {
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 expect(topDialog(page).getByText("Bob Barnacle")).toBeVisible()
await expect(topDialog(page).locator(`img[src="${avatar}"]`)).toBeVisible()
await expect(topDialog(page).getByText("Deckhand, dockside cook")).toBeVisible()
await expect(topDialog(page).getByText(/Last active/)).toBeVisible()
await page.keyboard.press("Escape")
@ -602,7 +586,7 @@ test("US-080 preview a profile from anywhere", async ({seed, as}) => {
await preview.click()
await dialog(page).getByRole("button", {name: "Go back"}).click()
await topDialog(page).getByRole("button", {name: "Go back"}).click()
// Closed, and alice is exactly where she opened it from.
await expect(page.locator(".dialog")).toHaveCount(0)
@ -612,7 +596,7 @@ test("US-080 preview a profile from anywhere", async ({seed, as}) => {
await preview.click()
await page.getByRole("button", {name: "View Full Profile"}).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
})
@ -624,17 +608,17 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."})
seedRelayList(space, user.bob)
space.relayList(user.bob)
})
const page = await as(users.alice, profilePath(users.bob), {context: CLIPBOARD})
const page = await as(users.alice, profilePath(users.bob.pubkey), {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 info = topDialog(page)
// Each of these is an input and its copy button inside one bordered label, in dialog order.
const linkField = info.locator("label.input").first()
const pubkeyField = info.locator("label.input").nth(1)
@ -677,7 +661,7 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
// "Share Profile Info", so the dialog's heading is matched exactly.
await expect(page.getByText("Share Profile", {exact: true})).toBeVisible()
const share = dialog(page)
const share = topDialog(page)
const qr = share.locator("canvas")
const shareLink = share.locator("input")
@ -702,8 +686,8 @@ test("US-082 mute an account", async ({seed, as}) => {
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.relayList(user.alice)
space.relayList(user.bob)
space.event(
user.bob,
@ -749,7 +733,7 @@ test("US-082 mute an account", async ({seed, as}) => {
await expect(card).toHaveCount(1)
await viewProfile(card).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob)}$`))
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
}
await openBobsProfile()

View file

@ -1,59 +1,40 @@
import {npubEncode} from "nostr-tools/nip19"
import {DAY, HOUR, MINUTE, WEEK, bech32ToHex} from "@welshman/lib"
import {getLnUrl} from "@welshman/util"
import {MessagingRelayList, Profile, RelayList, displayPubkey} from "@welshman/domain"
import {Profile, displayPubkey} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {
composer,
dialog,
expect,
makeTestUser,
message,
messageActions,
messages,
mockDufflepud,
mockOpenRouterSpeech,
openMessageMenu,
openRoomDetail,
pageBar,
pathPattern,
pickEmoji,
roomLink,
roomPath,
send,
spacePath,
test,
users,
} from "../harness"
import type {SeededEvent, SeededSpace, TestUser} from "../harness"
// A path as a pattern, for a url that carries a query string or a modal's hash alongside it.
const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
// 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})
// The room's way back to the live end, up only while the bottom of the container isn't it.
const jumpToNewest = (page: Page) => page.getByRole("button", {name: "Jump to newest"})
// 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 search button and the detail button, in that order.
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.
@ -77,9 +58,6 @@ const roomSection = (page: Page, name: string) =>
}
}, 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
@ -96,38 +74,13 @@ const permission = (form: Locator, label: string) =>
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")
}
const react = (page: Page, opener: Locator) => pickEmoji(page, opener, "party popper")
// 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(),
)
space.relayList(user)
space.messagingRelayList(user)
}
test("US-018 send and receive a room message in real time", async ({seed, as}) => {

View file

@ -1,43 +1,13 @@
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"
import {dialog, expect, roomPath, settingToggle, test, toast, topDialog, users} 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")
// 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 relayPicker = (page: Page) => topDialog(page)
const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name})
@ -45,7 +15,7 @@ const relayCard = (scope: Locator, name: string) => scope.locator(".card").filte
// 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)
const waitForToastToClear = (page: Page) => expect(toast(page)).toHaveCount(0)
test("US-084 block a relay you never want used", async ({seed, as}) => {
await seed(({relay, user}) => {
@ -59,7 +29,7 @@ test("US-084 block a relay you never want used", async ({seed, as}) => {
space.room("general", {name: "General"})
space.join(user.bob, "general")
relayList(space, user.bob)
space.relayList(user.bob)
})
const page = await as(users.bob, "/settings/privacy")
@ -69,24 +39,22 @@ test("US-084 block a relay you never want used", async ({seed, as}) => {
await blocked.click()
await expect(
relayDialog(page, "Blocked Relays").getByText("No relay selections found."),
).toBeVisible()
await expect(dialog(page, "Blocked Relays").getByText("No relay selections found.")).toBeVisible()
await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
await dialog(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()
await expect(relayDialog(page, "Blocked Relays").getByText("other.test")).toBeVisible()
await expect(dialog(page, "Blocked Relays").getByText("other.test")).toBeVisible()
await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Go back"}).click()
await dialog(page, "Blocked Relays").getByRole("button", {name: "Go back"}).click()
await expect(blocked).toContainText("1 Blocked")
// A blocked relay is one bob never wants used, so it stops being offered as a suggestion. It was
// the picker's only offer a moment ago, which is what makes its absence about the block.
await blocked.click()
await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
await dialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
await expect(relayPicker(page).getByText("space.test")).toBeVisible()
await expect(relayPicker(page).getByText("other.test")).toHaveCount(0)
@ -99,13 +67,13 @@ test("US-086 configure alerts", async ({seed, as}) => {
space.room("general", {name: "General"})
space.join(user.alice, "general")
relayList(space, user.alice)
space.relayList(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 sound = settingToggle(page, "Play sound for new activity")
const push = settingToggle(page, "Enable push notifications")
const alertTypes = page.locator("div.card").filter({has: page.getByText("Alert Types")})
await expect(sound).toBeChecked()
@ -140,7 +108,7 @@ test("US-086 configure alerts", async ({seed, as}) => {
await waitForToastToClear(page)
await page.reload()
await expect(toggle(page, "Play sound for new activity")).not.toBeChecked()
await expect(settingToggle(page, "Play sound for new activity")).not.toBeChecked()
})
test("US-087 configure content display", async ({seed, as}) => {
@ -157,14 +125,14 @@ test("US-087 configure content display", async ({seed, as}) => {
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)
space.relayList(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?")
const hideSensitive = settingToggle(page, "Hide sensitive content?")
const showMedia = settingToggle(page, "Show media?")
await expect(hideSensitive).toBeChecked()
await expect(showMedia).toBeChecked()
@ -190,7 +158,7 @@ test("US-087 configure content display", async ({seed, as}) => {
await page.goto("/settings/content")
await expect(toggle(page, "Hide sensitive content?")).not.toBeChecked()
await expect(settingToggle(page, "Hide sensitive content?")).not.toBeChecked()
})
test("US-088 adjust send delay and media servers", async ({seed, as}) => {
@ -200,7 +168,7 @@ test("US-088 adjust send delay and media servers", async ({seed, as}) => {
space.room("general", {name: "General"})
space.join(user.alice, "general")
relayList(space, user.alice)
space.relayList(user.alice)
})
const page = await as(users.alice, "/settings/content")
@ -251,13 +219,13 @@ test("US-089 configure privacy preferences", async ({seed, as}) => {
space.room("general", {name: "General"})
space.join(user.alice, "general")
relayList(space, user.alice)
space.relayList(user.alice)
})
const page = await as(users.alice, "/settings/privacy")
const auth = toggle(page, "Authenticate with unknown relays?")
const usage = toggle(page, "Report usage?")
const auth = settingToggle(page, "Authenticate with unknown relays?")
const usage = settingToggle(page, "Report usage?")
await expect(auth).not.toBeChecked()
await expect(usage).toBeChecked()
@ -278,8 +246,8 @@ test("US-089 configure privacy preferences", async ({seed, as}) => {
await waitForToastToClear(page)
await page.reload()
await expect(toggle(page, "Authenticate with unknown relays?")).toBeChecked()
await expect(toggle(page, "Report usage?")).not.toBeChecked()
await expect(settingToggle(page, "Authenticate with unknown relays?")).toBeChecked()
await expect(settingToggle(page, "Report usage?")).not.toBeChecked()
})
test("US-090 change the app's appearance", async ({seed, as}) => {
@ -289,7 +257,7 @@ test("US-090 change the app's appearance", async ({seed, as}) => {
space.room("general", {name: "General"})
space.join(user.alice, "general")
relayList(space, user.alice)
space.relayList(user.alice)
})
const page = await as(users.alice, "/settings/theme", {context: {colorScheme: "light"}})
@ -345,7 +313,7 @@ test("US-091 set up how people zap you", async ({seed, as}) => {
space.join(user.alice, "general")
space.profile(user.alice, {name: "Alice Anders"})
relayList(space, user.alice)
space.relayList(user.alice)
})
const page = await as(users.alice, "/settings/wallet")