Share the e2e locators and relay list fixtures across specs
This commit is contained in:
parent
6b49ef498d
commit
56ee9b4c44
19 changed files with 640 additions and 732 deletions
|
|
@ -339,6 +339,8 @@ e2e/
|
|||
harness/
|
||||
index.ts everything a spec imports: `test`, `expect`, helpers
|
||||
keys.ts deterministic keypairs
|
||||
ui.ts the locators specs share: dialogs, the composer, a room's messages
|
||||
files.ts the bytes an upload spec picks, and the browser's own file chooser
|
||||
zooid/
|
||||
config.ts the virtual relays and their hosts — the one place they are named
|
||||
relay.ts docker lifecycle, reset, seeding over its own authenticated socket
|
||||
|
|
@ -367,6 +369,12 @@ e2e/
|
|||
*.spec.ts
|
||||
```
|
||||
|
||||
A locator lives in the spec that uses it until a second spec needs the same one, at which point it
|
||||
moves to `harness/ui.ts`. The class or the aria label it names is then one edit when the app renames
|
||||
it, and the comment saying why it is shaped that way has one copy to keep true. What stays local is
|
||||
what one spec means differently: `dms.spec.ts` names a message by its `data-event` id, because the
|
||||
same words are sent more than once there.
|
||||
|
||||
One piece lives outside this directory: `src/lib/test/session.ts` holds the two window keys and the
|
||||
getters `src/app/session.ts` reads them through. It is the only file the app ships on the harness's
|
||||
behalf, and `harness/app/session.ts` duplicates the key names rather than importing them, since
|
||||
|
|
|
|||
30
e2e/harness/files.ts
Normal file
30
e2e/harness/files.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type {Locator, Page} from "@playwright/test"
|
||||
|
||||
// A file as the browser's own chooser takes one.
|
||||
export type TestFile = {
|
||||
name: string
|
||||
mimeType: string
|
||||
buffer: Buffer
|
||||
}
|
||||
|
||||
// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched
|
||||
// instead of re-encoding it through a canvas, so the bytes the server hashes are the bytes chosen
|
||||
// here and the url an upload resolves to is predictable from node. The base64 is what a spec hands
|
||||
// to the page, since a Buffer does not survive the trip into evaluate().
|
||||
export const GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||
|
||||
export const GIF = Buffer.from(GIF_BASE64, "base64")
|
||||
|
||||
// A 1x1 webp, which the compressor passes through for the same reason.
|
||||
export const WEBP = Buffer.from("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", "base64")
|
||||
|
||||
export const gifFile = (name: string): TestFile => ({name, mimeType: "image/gif", buffer: GIF})
|
||||
|
||||
// Every picker in the app opens the browser's own chooser, which is the only place a spec can hand
|
||||
// it a file: the input behind it is never on screen.
|
||||
export const chooseFile = async (page: Page, button: Locator, file: TestFile) => {
|
||||
const chooser = page.waitForEvent("filechooser")
|
||||
|
||||
await button.click()
|
||||
await (await chooser).setFiles(file)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type {BrowserContext, WebSocketRoute} from "@playwright/test"
|
||||
import {call, parseJson} from "@welshman/lib"
|
||||
import {normalizeRelayUrl} from "@welshman/util"
|
||||
import {RelayMessageType, isClientEvent, isClientReq} from "@welshman/net"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {ClientMessageType, RelayMessageType, isClientEvent, isClientReq} from "@welshman/net"
|
||||
import type {ClientMessage, RelayMessage} from "@welshman/net"
|
||||
import type {RelayConnection} from "../zooid/types"
|
||||
import type {Zooid} from "../zooid/relay"
|
||||
|
|
@ -16,6 +17,12 @@ export type TranscriptEntry = {
|
|||
message: ClientMessage | RelayMessage
|
||||
}
|
||||
|
||||
// An event the client published, and the relay it went to.
|
||||
export type PublishedEvent = {
|
||||
url: string
|
||||
event: TrustedEvent
|
||||
}
|
||||
|
||||
type Traffic = {
|
||||
transcript: TranscriptEntry[]
|
||||
leaks: Set<string>
|
||||
|
|
@ -103,6 +110,22 @@ export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) =>
|
|||
|
||||
export const getTranscript = (context: BrowserContext) => trafficStore.get(context).transcript
|
||||
|
||||
// Every event this context put on the wire, oldest first, with the relay it went to. One event
|
||||
// published to three relays is three entries.
|
||||
export const getPublished = (context: BrowserContext): PublishedEvent[] =>
|
||||
getTranscript(context)
|
||||
.filter(
|
||||
({direction, message}) => direction === "toRelay" && message[0] === ClientMessageType.Event,
|
||||
)
|
||||
.map(({url, message}) => ({url, event: message[1] as TrustedEvent}))
|
||||
|
||||
// The same, narrowed to one kind, which is how a spec asks what the client published rather than
|
||||
// what it rendered.
|
||||
export const getPublishedEvents = (context: BrowserContext, kind: number) =>
|
||||
getPublished(context)
|
||||
.filter(({event}) => event.kind === kind)
|
||||
.map(({event}) => event)
|
||||
|
||||
// Makes a relay answer like one that never held anything, without its url becoming a leak. `serve`
|
||||
// resolves a relay once, at open, so sockets already open keep theirs and the drop takes effect on
|
||||
// the next connection. A reload is what gives it one.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ export type SeededEvent = {
|
|||
// anything built by one has to be deferred.
|
||||
export type SeededTemplate = StampedEvent | (() => MaybeAsync<EventTemplate>)
|
||||
|
||||
// A nip-65 relay list, as the two sets a client reads off it: `write` is what an outbox-routed load
|
||||
// for this pubkey resolves to, `read` is what a feed asks for that pubkey's context.
|
||||
export type RelayListUrls = {
|
||||
read?: string[]
|
||||
write?: string[]
|
||||
}
|
||||
|
||||
export type ProfileValues = {
|
||||
name?: string
|
||||
about?: string
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from "@welshman/util"
|
||||
import type {HashedEvent} from "@welshman/util"
|
||||
import {Nip59} from "@welshman/signer"
|
||||
import {DirectMessage, EventWriter, Profile} from "@welshman/domain"
|
||||
import {DirectMessage, EventWriter, MessagingRelayList, Profile, RelayList} from "@welshman/domain"
|
||||
import type {BaseEventReader, ConfiguredKind, EventQuery, KindFactory} from "@welshman/domain"
|
||||
import type {RoomOptions, TestRelay} from "../zooid/types"
|
||||
import type {Zooid} from "../zooid/relay"
|
||||
|
|
@ -21,7 +21,7 @@ import type {SpaceName} from "../zooid/config"
|
|||
import {users} from "../keys"
|
||||
import type {TestUser} from "../keys"
|
||||
import {makePublisher} from "./publish"
|
||||
import type {Enqueue, ProfileValues, SeededEvent, SeededTemplate} from "./publish"
|
||||
import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish"
|
||||
|
||||
// @welshman/domain has no writer for NIP-29 kind-9 messages, and none of its readers describe one,
|
||||
// so this pairs the base writer with the base reader. The behavior tags it renders are everything a
|
||||
|
|
@ -54,6 +54,13 @@ export type SeededSpace = {
|
|||
message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent
|
||||
reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent
|
||||
profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent
|
||||
// Where this user reads and writes, this space by default. Outbox routing resolves everything
|
||||
// about a person through their relay list — their profile, the events they authored, the wraps
|
||||
// addressed to them — so a fixture is only loadable by somebody else once its author has one.
|
||||
relayList(user: TestUser, urls?: RelayListUrls, createdAt?: number): SeededEvent
|
||||
// The kind-10050 that says where someone's direct messages go, this space by default. Having one
|
||||
// is what makes a person reachable, so a story about messaging being off is a person without one.
|
||||
messagingRelayList(user: TestUser, urls?: string[], createdAt?: number): SeededEvent
|
||||
event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent
|
||||
// A nip-17 conversation. One kind-14 rumor, gift-wrapped once per participant including the
|
||||
// sender, whose own copy is the half of the thread their client reads back.
|
||||
|
|
@ -165,6 +172,20 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
|
|||
factory: KindFactory<R, W, Q>,
|
||||
) => factory.configure(context)
|
||||
|
||||
const relayList = (
|
||||
user: TestUser,
|
||||
{read = [url], write = [url]}: RelayListUrls = {},
|
||||
createdAt = startedAt,
|
||||
) =>
|
||||
event(
|
||||
user,
|
||||
() => kind(RelayList).writer().setReadUrls(read).setWriteUrls(write).renderTemplate(),
|
||||
createdAt,
|
||||
)
|
||||
|
||||
const messagingRelayList = (user: TestUser, urls = [url], createdAt = startedAt) =>
|
||||
event(user, () => kind(MessagingRelayList).writer().setUrls(urls).renderTemplate(), createdAt)
|
||||
|
||||
// Every wrap is published over the sender's own connection, since a gift wrap's author is an
|
||||
// ephemeral key nobody in this process can authenticate as. zooid stores it anyway, authorizing a
|
||||
// kind-1059 by the member named in its p tag.
|
||||
|
|
@ -207,6 +228,8 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
|
|||
message,
|
||||
reply,
|
||||
profile,
|
||||
relayList,
|
||||
messagingRelayList,
|
||||
event,
|
||||
dm,
|
||||
kind,
|
||||
|
|
|
|||
147
e2e/harness/ui.ts
Normal file
147
e2e/harness/ui.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import {expect} from "@playwright/test"
|
||||
import type {Locator, Page} from "@playwright/test"
|
||||
|
||||
/**
|
||||
* What a spec names on screen. A locator more than one spec reaches for belongs here, so a class or
|
||||
* a label the app renames is one edit rather than six — and so the comment explaining why a
|
||||
* locator is shaped the way it is has one copy to keep true.
|
||||
*/
|
||||
|
||||
// The panel of the modal carrying a given title. `.dialog` is on both the backdrop wrapper and the
|
||||
// panel inside it, so the last match is the panel.
|
||||
export const dialog = (page: Page, title: string) =>
|
||||
page
|
||||
.locator(".dialog")
|
||||
.filter({has: page.getByRole("heading", {name: title, exact: true})})
|
||||
.last()
|
||||
|
||||
// The modal on top, for one with no heading of its own or one pushed over another rather than
|
||||
// alongside it.
|
||||
export const topDialog = (page: Page) => page.locator(".dialog").last()
|
||||
|
||||
// A modal is mounted alongside the page it covers, so a page's own "Create" and the modal's submit
|
||||
// are both in the dom at once. Anything said about the form is scoped to the modal's own to say
|
||||
// which one is meant.
|
||||
export const modalForm = (page: Page, title: string) =>
|
||||
page.locator("form").filter({has: page.getByRole("heading", {name: title})})
|
||||
|
||||
// EventActions renders zap, emoji and menu into one join, in that order, and every one of them is
|
||||
// an icon with no accessible name.
|
||||
export const emojiButton = (scope: Locator) => scope.locator(".join").getByRole("button").nth(1)
|
||||
|
||||
export const menuButton = (scope: Locator) => scope.locator(".join").getByRole("button").last()
|
||||
|
||||
// The picker is a web component with an open shadow root, so its search field and its results are
|
||||
// reachable through it. Searching rather than browsing avoids depending on which category tab an
|
||||
// emoji happens to live under.
|
||||
export const pickEmoji = async (page: Page, opener: Locator, annotation: string) => {
|
||||
await opener.click()
|
||||
|
||||
const picker = page.locator("emoji-picker").filter({visible: true})
|
||||
|
||||
// Tippy keeps a hidden popover mounted through its fade — a quarter of a second during which the
|
||||
// picker from the last card is still visible alongside this one — so wait for there to be one
|
||||
// rather than reaching into whichever resolves first.
|
||||
await expect(picker).toHaveCount(1)
|
||||
|
||||
// A result's label is the emoji's name, its annotation and every shortcode joined together, so
|
||||
// the annotation is matched rather than the whole of it.
|
||||
await picker.locator("input.search").fill(annotation)
|
||||
await picker
|
||||
.getByRole("option", {name: new RegExp(annotation)})
|
||||
.first()
|
||||
.click()
|
||||
}
|
||||
|
||||
export const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
|
||||
|
||||
// The room's page bar carries a search button and the detail button, in that order.
|
||||
export const openRoomDetail = (page: Page) => pageBar(page).getByRole("button").last().click()
|
||||
|
||||
export const roomLink = (page: Page, name: string) =>
|
||||
page.locator(".space-menu__scroll").getByRole("link", {name})
|
||||
|
||||
// One toast at a time — src/app/toast.ts holds a single writable — so this is the toast.
|
||||
export const toast = (page: Page) => page.getByRole("alert")
|
||||
|
||||
// FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row, with the
|
||||
// label at one end and the control at the other.
|
||||
export const settingRow = (page: Page, label: string) =>
|
||||
page.locator("div.items-center.justify-between").filter({hasText: label})
|
||||
|
||||
export const settingToggle = (page: Page, label: string) =>
|
||||
settingRow(page, label).getByRole("checkbox")
|
||||
|
||||
export const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
|
||||
|
||||
// The editor is where the composer says whether it is ready. The send button is not there to ask
|
||||
// while the composer is empty, since a dictation button stands in its place. Only the conversation
|
||||
// composer has a disabled state; a room's is usable as soon as it renders.
|
||||
export const composerEnabled = (page: Page) =>
|
||||
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "false")
|
||||
|
||||
export const composerDisabled = (page: Page) =>
|
||||
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "true")
|
||||
|
||||
// The send button carries the shortcut it advertises, which differs by platform.
|
||||
export const sendButton = (page: Page) => page.locator("button[data-tip$='enter to send']")
|
||||
|
||||
export const timeline = (page: Page) => page.locator(".room__content")
|
||||
|
||||
// .room__content is column-reverse, so the message at the bottom of the room is the first one in
|
||||
// the dom.
|
||||
export const messages = (page: Page) => page.locator(".room__item")
|
||||
|
||||
export const message = (page: Page, text: string) => messages(page).filter({hasText: text})
|
||||
|
||||
// RoomItem gives its hover actions no accessible names — every one is an icon. Their order is
|
||||
// fixed by the component: zap, emoji, reply, edit (only on your own recent message), menu.
|
||||
export const messageActions = (page: Page, text: string) =>
|
||||
message(page, text).locator(".room__item-actions button")
|
||||
|
||||
export const openMessageMenu = (page: Page, text: string) =>
|
||||
messageActions(page, text).last().click()
|
||||
|
||||
export const bubble = (page: Page, text: string) =>
|
||||
page.locator(".chat-bubble").filter({hasText: text})
|
||||
|
||||
// Typing into a room's composer and sending it. The composer is waited for rather than assumed,
|
||||
// since a room still rendering has none, and clicked into when the caret is somewhere else, since
|
||||
// typing goes wherever it is. One that already holds the caret is typed into as it stands: clicking
|
||||
// would collapse the selection, and selecting the whole of it is how an edit types over the message
|
||||
// it replaces.
|
||||
export const send = async (page: Page, content: string) => {
|
||||
const editor = composer(page)
|
||||
|
||||
await expect(editor).toBeVisible()
|
||||
|
||||
const elsewhere = await editor.evaluate(el => el !== document.activeElement)
|
||||
|
||||
if (elsewhere) {
|
||||
await editor.click()
|
||||
}
|
||||
|
||||
await editor.pressSequentially(content)
|
||||
await editor.press("Enter")
|
||||
}
|
||||
|
||||
export const chatList = (page: Page) => page.locator(".secondary-nav .overflow-auto")
|
||||
|
||||
// One conversation in the sidebar list is one button; nothing inside it is one.
|
||||
export const chatItems = (page: Page) => chatList(page).locator("button")
|
||||
|
||||
// The comment and thread composers are rich text rather than chat editors, so they carry a
|
||||
// different one.
|
||||
export const noteEditor = (scope: Locator | Page) =>
|
||||
scope.locator(".note-editor [contenteditable=true]")
|
||||
|
||||
// A date as the browser formatted it rather than as node would, so the locale and the timezone are
|
||||
// the ones the app rendered with. The options mirror dateFormatter in @welshman/lib.
|
||||
export const longDate = (page: Page, seconds: number) =>
|
||||
page.evaluate(
|
||||
ts =>
|
||||
new Intl.DateTimeFormat(undefined, {year: "numeric", month: "long", day: "numeric"}).format(
|
||||
new Date(ts * 1000),
|
||||
),
|
||||
seconds,
|
||||
)
|
||||
|
|
@ -2,28 +2,22 @@ import type {Locator, Page} from "@playwright/test"
|
|||
import {DAY, HOUR, MINUTE, MONTH, now} from "@welshman/lib"
|
||||
import {Article} from "@welshman/domain"
|
||||
import {
|
||||
DEFAULT_BLOSSOM_ORIGIN,
|
||||
dialog,
|
||||
expect,
|
||||
getHosting,
|
||||
gifFile,
|
||||
makeTestUser,
|
||||
menuButton,
|
||||
mockBlossom,
|
||||
openMessageMenu,
|
||||
roomPath,
|
||||
spacePath,
|
||||
test,
|
||||
users,
|
||||
} from "../harness"
|
||||
|
||||
// Where an icon upload lands. SpaceEdit and RelayForm both call uploadFileOrFallback with no relay
|
||||
// of their own, so the space's blossom probe never runs and VITE_DEFAULT_BLOSSOM_SERVERS is what's
|
||||
// left.
|
||||
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
|
||||
|
||||
// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched
|
||||
// instead of re-encoding it through a canvas, so what the mock hashes is what was picked.
|
||||
const ICON = {
|
||||
name: "icon.gif",
|
||||
mimeType: "image/gif",
|
||||
buffer: Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"),
|
||||
}
|
||||
const ICON = gifFile("icon.gif")
|
||||
|
||||
// The plans the hosting backend offers. `free` is what RelayForm starts on, and `basic` is the paid
|
||||
// one every upgrade story moves to. PricingTable names a plan by its member limit, which is the
|
||||
|
|
@ -71,14 +65,6 @@ const hostedRelay = (overrides: Record<string, unknown> = {}) => ({
|
|||
...overrides,
|
||||
})
|
||||
|
||||
// The panel of the modal carrying a given title. `.dialog` is on both the backdrop wrapper and the
|
||||
// panel inside it, so the last match is the panel.
|
||||
const dialog = (page: Page, title: string) =>
|
||||
page
|
||||
.locator(".dialog")
|
||||
.filter({has: page.getByRole("heading", {name: title, exact: true})})
|
||||
.last()
|
||||
|
||||
// Tippy mounts a menu the first time it is opened and leaves it in the dom when it hides, so a page
|
||||
// that has opened two of them holds both — only the one on screen is visible. Exact, because
|
||||
// "Edit role" and "Edit roles" are two different menus in the same directory.
|
||||
|
|
@ -97,18 +83,7 @@ const openSpaceMenu = (page: Page) => page.getByRole("button", {name: /space\.te
|
|||
const memberCard = (page: Page, name: string) =>
|
||||
page.locator(".card-interactive").filter({hasText: name})
|
||||
|
||||
// RoomItem gives its hover actions no accessible names — every one is an icon — and the menu is
|
||||
// the last of them.
|
||||
const openMessageMenu = (page: Page, text: string) =>
|
||||
page
|
||||
.locator(".room__item")
|
||||
.filter({hasText: text})
|
||||
.locator(".room__item-actions button")
|
||||
.last()
|
||||
.click()
|
||||
|
||||
// EventActions renders zap, emoji and menu into one join, so the menu is the last button in it.
|
||||
const openEventMenu = (card: Locator) => card.locator(".join").getByRole("button").last().click()
|
||||
const openEventMenu = (card: Locator) => menuButton(card).click()
|
||||
|
||||
const articleCard = (page: Page, title: string) =>
|
||||
page.locator('[data-component="ArticleItem"]').filter({hasText: title})
|
||||
|
|
@ -135,7 +110,7 @@ test("US-092 edit a space's profile and featured content", async ({seed, as}) =>
|
|||
const {url} = scenario.space("space")
|
||||
const admin = await as(users.admin, spacePath(url) + "/about")
|
||||
|
||||
await mockBlossom(admin.context(), {server: BLOSSOM_ORIGIN})
|
||||
await mockBlossom(admin.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
|
||||
|
||||
await openSpaceMenu(admin)
|
||||
await menuItem(admin, "Edit Space").click()
|
||||
|
|
@ -165,7 +140,7 @@ test("US-092 edit a space's profile and featured content", async ({seed, as}) =>
|
|||
// All three come back off the relay's own nip-11 document
|
||||
await expect(admin.getByRole("heading", {name: "Harbor"})).toBeVisible()
|
||||
await expect(admin.getByText("Where the fleet ties up.")).toBeVisible()
|
||||
await expect(admin.locator(`img[src^="${BLOSSOM_ORIGIN}"]`).first()).toBeVisible()
|
||||
await expect(admin.locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}"]`).first()).toBeVisible()
|
||||
|
||||
const featured = admin
|
||||
.locator(".card")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}) => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue