diff --git a/e2e/ARCHITECTURE.md b/e2e/ARCHITECTURE.md index 6594411f..b794d991 100644 --- a/e2e/ARCHITECTURE.md +++ b/e2e/ARCHITECTURE.md @@ -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 diff --git a/e2e/harness/files.ts b/e2e/harness/files.ts new file mode 100644 index 00000000..7e5575bd --- /dev/null +++ b/e2e/harness/files.ts @@ -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) +} diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts index 3b28fb01..0bd0a896 100644 --- a/e2e/harness/index.ts +++ b/e2e/harness/index.ts @@ -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 diff --git a/e2e/harness/net/http.ts b/e2e/harness/net/http.ts index cfaad639..4ad6ff40 100644 --- a/e2e/harness/net/http.ts +++ b/e2e/harness/net/http.ts @@ -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 diff --git a/e2e/harness/net/websocket.ts b/e2e/harness/net/websocket.ts index 4b33e529..fa23b292 100644 --- a/e2e/harness/net/websocket.ts +++ b/e2e/harness/net/websocket.ts @@ -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 @@ -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. diff --git a/e2e/harness/seed/openRelay.ts b/e2e/harness/seed/openRelay.ts index a14c592d..07914dee 100644 --- a/e2e/harness/seed/openRelay.ts +++ b/e2e/harness/seed/openRelay.ts @@ -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 diff --git a/e2e/harness/seed/publish.ts b/e2e/harness/seed/publish.ts index 8bfb1905..131d5e1d 100644 --- a/e2e/harness/seed/publish.ts +++ b/e2e/harness/seed/publish.ts @@ -17,6 +17,13 @@ export type SeededEvent = { // anything built by one has to be deferred. export type SeededTemplate = StampedEvent | (() => MaybeAsync) +// 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 diff --git a/e2e/harness/seed/space.ts b/e2e/harness/seed/space.ts index d91fcbab..184ab77a 100644 --- a/e2e/harness/seed/space.ts +++ b/e2e/harness/seed/space.ts @@ -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, ) => 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, diff --git a/e2e/harness/ui.ts b/e2e/harness/ui.ts new file mode 100644 index 00000000..ed3e1444 --- /dev/null +++ b/e2e/harness/ui.ts @@ -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, + ) diff --git a/e2e/specs/admin.spec.ts b/e2e/specs/admin.spec.ts index 5d83fd0f..1a153eac 100644 --- a/e2e/specs/admin.spec.ts +++ b/e2e/specs/admin.spec.ts @@ -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 = {}) => ({ ...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") diff --git a/e2e/specs/articles-threads.spec.ts b/e2e/specs/articles-threads.spec.ts index 6effa6a4..726a21dc 100644 --- a/e2e/specs/articles-threads.spec.ts +++ b/e2e/specs/articles-threads.spec.ts @@ -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() diff --git a/e2e/specs/community.spec.ts b/e2e/specs/community.spec.ts index 8112091d..aca8ef01 100644 --- a/e2e/specs/community.spec.ts +++ b/e2e/specs/community.spec.ts @@ -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") diff --git a/e2e/specs/composer.spec.ts b/e2e/specs/composer.spec.ts index 0faa4004..822deaf8 100644 --- a/e2e/specs/composer.spec.ts +++ b/e2e/specs/composer.spec.ts @@ -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)) }) diff --git a/e2e/specs/delivery.spec.ts b/e2e/specs/delivery.spec.ts index 772156f5..73519a5d 100644 --- a/e2e/specs/delivery.spec.ts +++ b/e2e/specs/delivery.spec.ts @@ -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) diff --git a/e2e/specs/dms.spec.ts b/e2e/specs/dms.spec.ts index f9ef7538..c31012a4 100644 --- a/e2e/specs/dms.spec.ts +++ b/e2e/specs/dms.spec.ts @@ -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) diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index 46612fe4..c490e7a4 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -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) diff --git a/e2e/specs/people.spec.ts b/e2e/specs/people.spec.ts index c2d8c2c4..0b05356c 100644 --- a/e2e/specs/people.spec.ts +++ b/e2e/specs/people.spec.ts @@ -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() diff --git a/e2e/specs/rooms.spec.ts b/e2e/specs/rooms.spec.ts index fa8d6299..40dda716 100644 --- a/e2e/specs/rooms.spec.ts +++ b/e2e/specs/rooms.spec.ts @@ -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}) => { diff --git a/e2e/specs/settings.spec.ts b/e2e/specs/settings.spec.ts index afd56d66..279b232a 100644 --- a/e2e/specs/settings.spec.ts +++ b/e2e/specs/settings.spec.ts @@ -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")