Files
psf-memo/psf-memo-client/acceptance/lib/handlers.js
T
Chris Troutner 9fb092e845 Implement recent profile identity join and Account column
DB: GET /profile/recent joins each profile's display name (names store)
and avatar URL (profilePics store) by address, reporting null when a
record is absent. The join runs on the requested page only and leaves the
profile order and pagination unchanged.

Client: the /profile/recent table gains a leftmost Account column showing
the display name and avatar, both linking to the profile, with a
truncated-address fallback when the name is absent and an identicon
fallback when the avatar is absent. The existing columns are preserved.

Add focused unit tests, acceptance fixtures, and regex step handlers for
both components.

By coder.
2026-09-20 12:46:12 -07:00

4406 lines
160 KiB
JavaScript

/*
Project step handlers for the psf-memo-client acceptance pipeline.
These handlers connect Gherkin step text to real project behavior
(src/services/memo-post.js, src/services/new-post.js, src/services/memo-reply.js,
src/services/reply-thread-page.js, src/services/memo-set-name.js, and
src/services/set-name-page.js), driving them through small injected adapters
(a fake wallet, a fake feed, a fake thread, and a fake navigator) so the
acceptance run is deterministic and offline.
Regex matching with placeholder-name capture is the default style: a single
handler pattern captures the placeholder name (e.g. <message>) and fetches
the example value from the scenario example store.
The handlers serve specs/post-memo.feature, specs/memo-new.feature,
specs/reply-memo.feature, and specs/set-name.feature, whose wording differs
but which share the same underlying Memo action/page-controller behavior.
*/
'use strict'
const MemoPost = require('../../src/services/memo-post')
const NewPostPage = require('../../src/services/new-post')
const MemoReply = require('../../src/services/memo-reply')
const ReplyThreadPage = require('../../src/services/reply-thread-page')
const MemoSetName = require('../../src/services/memo-set-name')
const SetNamePage = require('../../src/services/set-name-page')
const MemoSetBio = require('../../src/services/memo-set-bio')
const SetBioPage = require('../../src/services/set-bio-page')
const MemoSetAvatarUrl = require('../../src/services/memo-set-avatar-url')
const SetAvatarUrlPage = require('../../src/services/set-avatar-url-page')
const AccountPage = require('../../src/services/account-page')
const MemoLike = require('../../src/services/memo-like')
const LikeTipPage = require('../../src/services/like-tip-page')
const MemoFollow = require('../../src/services/memo-follow')
const MemoMute = require('../../src/services/memo-mute')
const RecentFeedPage = require('../../src/services/recent-feed-page')
const FollowingFeedPage = require('../../src/services/following-feed-page')
const FeedTabsPage = require('../../src/services/feed-tabs-page')
const ProfilePage = require('../../src/services/profile-page')
const ThreadPage = require('../../src/services/thread-page')
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
const { buildTopicsTable } = require('../../src/services/topics-table')
const TopicFeedPage = require('../../src/services/topic-feed-page')
const SearchPage = require('../../src/services/search-page')
const NotificationsPage = require('../../src/services/notifications-page')
const RecentProfilesPage = require('../../src/services/recent-profiles-page')
const { buildRecentProfilesTable } = require('../../src/services/recent-profiles-table')
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
const MemoTopicPost = require('../../src/services/memo-topic-post')
const TopicPostPage = require('../../src/services/topic-post-page')
const MemoPollCreate = require('../../src/services/memo-poll-create')
const PollCreatePage = require('../../src/services/poll-create-page')
const MemoPollOption = require('../../src/services/memo-poll-option')
const PollOptionPage = require('../../src/services/poll-option-page')
const MemoPollVote = require('../../src/services/memo-poll-vote')
const PollVotePage = require('../../src/services/poll-vote-page')
const { renderPostText } = require('./render-post')
const { renderAccountAvatar } = require('./render-account-avatar')
const { renderPostOptions } = require('./render-post-options')
const { renderLikeResult } = require('./render-like-result')
const { renderMuteResult } = require('./render-mute-result')
const { renderNotificationEntry } = require('./render-notification-entry')
const { renderRecentProfileAccount } = require('./render-recent-profile-account')
const { VIEW_POST_LABEL } = require('../../src/services/notification-entry')
const PostOptions = require('../../src/services/post-options')
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed')
const { toPushBuffer } = require('../../src/services/memo-multipush')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX
const MEMO_SET_BIO_PREFIX = MemoSetBio.MEMO_SET_BIO_PREFIX
const MEMO_SET_AVATAR_URL_PREFIX = MemoSetAvatarUrl.MEMO_SET_AVATAR_URL_PREFIX
const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX
const MEMO_FOLLOW_PREFIX = MemoFollow.MEMO_FOLLOW_PREFIX
const MEMO_UNFOLLOW_PREFIX = MemoFollow.MEMO_UNFOLLOW_PREFIX
const MEMO_MUTE_PREFIX = MemoMute.MEMO_MUTE_PREFIX
const MEMO_UNMUTE_PREFIX = MemoMute.MEMO_UNMUTE_PREFIX
const MEMO_TOPIC_MESSAGE_PREFIX = MemoTopicPost.MEMO_TOPIC_MESSAGE_PREFIX
const MEMO_TOPIC_FOLLOW_PREFIX = MemoTopicFollow.MEMO_TOPIC_FOLLOW_PREFIX
const MEMO_TOPIC_UNFOLLOW_PREFIX = MemoTopicFollow.MEMO_TOPIC_UNFOLLOW_PREFIX
const MEMO_CREATE_POLL_PREFIX = MemoPollCreate.MEMO_CREATE_POLL_PREFIX
const MEMO_ADD_POLL_OPTION_PREFIX = MemoPollOption.MEMO_ADD_POLL_OPTION_PREFIX
const MEMO_POLL_VOTE_PREFIX = MemoPollVote.MEMO_POLL_VOTE_PREFIX
// Default author address used by Gherkin steps that refer to "the author address".
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
// Placeholder addresses used by Gherkin steps that refer to 'a second address'
// or 'a third address'.
const SECOND_ADDRESS = 'bitcoincash:second-address'
const THIRD_ADDRESS = 'bitcoincash:third-address'
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
function makeWallet (address) {
const wallet = {
walletInfo: { cashAddress: address },
bchjs: {
Address: {
toHash160 (addr) {
// Stable fake hash160: first 20 bytes of sha256 of the address.
return require('crypto').createHash('sha256').update(addr).digest('hex').slice(0, 40)
}
}
},
utxos: [],
broadcasts: [],
getUtxos: async function () {
return this.utxos
},
sendOpReturn: async function (msg, prefix, bchOutput = []) {
// Multi-field actions pass an array of field pushes. Expose each push
// separately for the multi-push assertions, while keeping `msg` as the
// concatenated payload so older combined-payload assertions still work.
const isMulti = Array.isArray(msg)
const fields = isMulti ? msg : [msg]
const fieldBuffers = fields.map((field) => toPushBuffer(field))
const storedMsg = isMulti
? Buffer.concat(fieldBuffers)
: ((msg instanceof Uint8Array || ArrayBuffer.isView(msg))
? Buffer.from(msg)
: msg)
const pushes = [Buffer.from(prefix, 'hex'), ...fieldBuffers]
this.broadcasts.push({ msg: storedMsg, pushes, prefix, bchOutput })
if (this.failWith) throw new Error(this.failWith)
return 'aa'.repeat(32)
}
}
return wallet
}
// A fake feed reflecting posts added to the recent posts feed.
function makeFeed () {
const posts = []
return {
posts,
addPost: (post) => posts.push(post)
}
}
// A fake poll store recording polls, options, and votes.
function makePolls () {
const polls = []
const options = []
const votes = []
return {
polls,
options,
votes,
addPoll: (poll) => polls.push(poll),
addOption: (option) => options.push(option),
addVote: (vote) => votes.push(vote),
getPoll: (txid) => polls.find((p) => p.txid === txid) || null,
getOptions: (txid) => options.filter((o) => o.pollTxid === txid),
getVotes: (txid) => votes.filter((v) => v.pollTxid === txid)
}
}
// A fake profile store recording display names, bios, avatar URLs, and follow state.
function makeProfiles () {
const names = {}
const bios = {}
const avatarUrls = {}
const following = {}
const topicFollowing = {}
const muting = {}
return {
names,
bios,
avatarUrls,
following,
topicFollowing,
muting: {},
setMuteState: (selfAddr, targetAddr, isMuting) => {
if (!muting[selfAddr]) muting[selfAddr] = {}
muting[selfAddr][targetAddr] = isMuting
},
getMuteState: (selfAddr, targetAddr) => muting[selfAddr]?.[targetAddr] || false,
setName: (addr, name) => { names[addr] = name },
getName: (addr) => names[addr] || null,
setBio: (addr, bio) => { bios[addr] = bio },
getBio: (addr) => bios[addr] || null,
setAvatarUrl: (addr, url) => { avatarUrls[addr] = url },
getAvatarUrl: (addr) => avatarUrls[addr] || null,
setFollowState: (selfAddr, targetAddr, isFollowing) => {
if (!following[selfAddr]) following[selfAddr] = {}
following[selfAddr][targetAddr] = isFollowing
},
getFollowState: (selfAddr, targetAddr) => following[selfAddr]?.[targetAddr] || false,
setTopicFollowState: (selfAddr, room, isFollowing) => {
if (!topicFollowing[selfAddr]) topicFollowing[selfAddr] = {}
topicFollowing[selfAddr][room] = isFollowing
},
getTopicFollowState: (selfAddr, room) => topicFollowing[selfAddr]?.[room] || false
}
}
// A fake thread store recording replies added to a post thread.
function makeThread () {
const replies = []
return {
rootTxid: null,
replies,
addReply: (r) => replies.push(r)
}
}
// A fake psf-memo-db API backing the read-only feed, profile, thread,
// topic, and search pages used to verify read-side behavior.
function makeMemoDb () {
const posts = []
const profiles = []
const searchPosts = []
const searchProfiles = []
const threads = {}
const followState = {}
const muteState = {}
const replyTxids = new Set()
const replies = []
const likes = []
const followers = new Map()
const topics = []
const topicPosts = {}
const topicCounts = new Map()
const topicFollow = new Map()
const topicMetadata = new Map()
const profileNames = new Map()
const profilePics = new Map()
const profileFailures = new Set()
return {
posts,
profiles,
searchPosts,
searchProfiles,
threads,
followState,
topics,
topicPosts,
topicCounts,
topicMetadata,
addPost (post) {
posts.push(post)
},
addReply (reply) {
replyTxids.add(reply.txid)
replies.push({
txid: reply.txid,
parentTxid: reply.parentTxid,
text: reply.text,
addr: reply.addr || 'bitcoincash:reply-author',
blockHeight: reply.blockHeight ?? 100
})
posts.push({
txid: reply.txid,
addr: reply.addr || 'bitcoincash:reply-author',
text: reply.text,
blockHeight: reply.blockHeight ?? 100,
seen: reply.seen ?? 0
})
},
addSearchPost (post) {
searchPosts.push(post)
},
addSearchProfile (profile) {
searchProfiles.push(profile)
},
addProfileName (addr, name) {
profileNames.set(addr, name)
},
addProfilePic (addr, url) {
profilePics.set(addr, url)
},
addProfileFailure (addr) {
profileFailures.add(addr)
},
async getName (addr) {
if (profileFailures.has(addr)) throw new Error('profile lookup failed')
const name = profileNames.get(addr)
return name ? { name } : null
},
async getProfilePic (addr) {
if (profileFailures.has(addr)) throw new Error('profile lookup failed')
const url = profilePics.get(addr)
return url ? { url } : null
},
addLike (like) {
likes.push({
txid: like.txid,
postTxid: like.postTxid,
addr: like.addr,
blockHeight: like.blockHeight ?? 100
})
},
addFollower (followerAddr, followeeAddr, opts = {}) {
const list = followers.get(followeeAddr) || []
list.push({
followerAddr,
followeeAddr,
txid: opts.txid || require('crypto').createHash('sha256').update(`${followerAddr}:${followeeAddr}`).digest('hex'),
blockHeight: opts.blockHeight ?? 100
})
followers.set(followeeAddr, list)
},
addTopic (room, postCount) {
topicCounts.set(room, postCount)
topicMetadata.set(room, { followerCount: 0, lastSeen: 0 })
topicPosts[room] = []
for (let i = 0; i < postCount; i++) {
const txid = `${room}-post-${i + 1}`.padEnd(64, '0')
topicPosts[room].push({
txid,
addr: `addr-${i + 1}`,
text: `Sample post ${i + 1}`,
blockHeight: 100 + i
})
}
},
setTopicFollowerCount (room, followerCount) {
const meta = topicMetadata.get(room) || {}
meta.followerCount = followerCount
topicMetadata.set(room, meta)
},
setTopicLastSeen (room, lastSeen) {
const meta = topicMetadata.get(room) || {}
meta.lastSeen = lastSeen
topicMetadata.set(room, meta)
},
addTopicPost (room, post) {
if (!topicPosts[room]) topicPosts[room] = []
topicPosts[room].push(post)
topicCounts.set(room, (topicCounts.get(room) || 0) + 1)
},
addThread (txid, thread) {
threads[txid] = thread
},
setFollowState (followerAddr, followeeAddr, following) {
followState[`${followerAddr}|${followeeAddr}`] = following
},
setMuteState (muterAddr, muteeAddr, muted) {
muteState[`${muterAddr}:${muteeAddr}`] = muted
},
isMuted (muterAddr, muteeAddr) {
return muteState[`${muterAddr}:${muteeAddr}`] === true
},
setTopicFollowState (addr, room, following) {
if (!topicFollow.has(room)) topicFollow.set(room, new Map())
topicFollow.get(room).set(addr, following)
},
async getTopicFollowState (room, addr) {
return topicFollow.get(room)?.get(addr) || false
},
async getTopicFollowers (room) {
const addrs = []
for (const [addr, following] of (topicFollow.get(room) || new Map()).entries()) {
if (following) addrs.push(addr)
}
return addrs
},
async search (q, { limit = 50, offset = 0, viewer = null } = {}) {
const normalized = String(q).trim().toLowerCase()
if (normalized.length === 0) {
return { posts: [], profiles: [], pagination: { total: 0, hasMore: false } }
}
let matchedPosts = searchPosts.filter((p) =>
typeof p.text === 'string' && p.text.toLowerCase().includes(normalized)
)
if (viewer) {
matchedPosts = matchedPosts.filter((p) => !this.isMuted(viewer, p.addr))
}
const matchedProfiles = searchProfiles.filter((p) =>
(typeof p.name === 'string' && p.name.toLowerCase().includes(normalized)) ||
(typeof p.text === 'string' && p.text.toLowerCase().includes(normalized))
)
const total = matchedPosts.length
const page = matchedPosts.slice(offset, offset + limit)
return {
posts: page,
profiles: matchedProfiles,
pagination: { total, limit, offset, hasMore: offset + page.length < total }
}
},
async getRecentPosts ({ limit = 50, offset = 0, viewer = null } = {}) {
let filtered = posts
if (viewer) {
filtered = posts.filter((p) => !this.isMuted(viewer, p.addr))
}
const page = filtered.slice(offset, offset + limit)
return { posts: page, pagination: { total: filtered.length, limit, offset, hasMore: offset + page.length < filtered.length } }
},
async getPostsByAddr (addr, { limit = 50, offset = 0 } = {}) {
const filtered = posts.filter((p) => p.addr === addr)
const page = filtered.slice(offset, offset + limit)
return { posts: page, pagination: { total: filtered.length, limit, offset, hasMore: offset + page.length < filtered.length } }
},
async getPostThread (txid) {
return threads[txid] || { post: null }
},
async getFollowState (followerAddr, followeeAddr) {
return followState[`${followerAddr}|${followeeAddr}`] || false
},
async getMuteState (muterAddr, muteeAddr) {
return muteState[`${muterAddr}:${muteeAddr}`] || false
},
async getTopics ({ limit = 50, offset = 0 } = {}) {
const list = []
for (const [room, postCount] of topicCounts.entries()) {
const meta = topicMetadata.get(room) || {}
list.push({
room,
postCount,
lastSeen: meta.lastSeen ?? 0,
followerCount: meta.followerCount ?? 0
})
}
list.sort((a, b) => a.room.localeCompare(b.room))
const total = list.length
const page = list.slice(offset, offset + limit)
return {
topics: page,
pagination: { limit, offset, total, hasMore: offset + page.length < total }
}
},
async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) {
let all = topicPosts[room] || []
if (viewer) {
all = all.filter((p) => !this.isMuted(viewer, p.addr))
}
const page = all.slice(offset, offset + limit)
return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } }
},
async getNotifications (addr, { limit = 50, offset = 0 } = {}) {
const notifications = []
for (const reply of replies) {
const parent = posts.find((p) => p.txid === reply.parentTxid)
if (!parent || parent.addr !== addr) continue
if (reply.addr === addr) continue
if (this.isMuted(addr, reply.addr)) continue
notifications.push({
type: 'reply',
txid: reply.txid,
addr: reply.addr,
postTxid: reply.parentTxid,
text: reply.text,
blockHeight: reply.blockHeight ?? parent.blockHeight ?? 0
})
}
for (const like of likes) {
const post = posts.find((p) => p.txid === like.postTxid)
if (!post || post.addr !== addr) continue
if (like.addr === addr) continue
if (this.isMuted(addr, like.addr)) continue
notifications.push({
type: 'like',
txid: like.txid,
addr: like.addr,
postTxid: like.postTxid,
blockHeight: like.blockHeight ?? post.blockHeight ?? 0
})
}
for (const follow of (followers.get(addr) || [])) {
if (follow.followerAddr === addr) continue
if (this.isMuted(addr, follow.followerAddr)) continue
notifications.push({
type: 'follow',
txid: follow.txid,
addr: follow.followerAddr,
blockHeight: follow.blockHeight ?? 0
})
}
notifications.sort((a, b) => (b.blockHeight ?? 0) - (a.blockHeight ?? 0))
const total = notifications.length
const page = notifications.slice(offset, offset + limit)
return { notifications: page, pagination: { total, limit, offset, hasMore: offset + page.length < total } }
},
async getRecentProfiles ({ limit = 50, offset = 0 } = {}) {
const page = profiles.slice(offset, offset + limit)
return { profiles: page, pagination: { total: profiles.length, limit, offset, hasMore: offset + page.length < profiles.length } }
},
async getFollowingFeed (addr, { limit = 50, offset = 0 } = {}) {
const followees = new Set()
for (const [key, following] of Object.entries(followState)) {
if (!following) continue
const [follower, followee] = key.split('|')
if (follower === addr) followees.add(followee)
}
const all = posts
.filter((p) => followees.has(p.addr) && p.addr !== addr && !replyTxids.has(p.txid))
.sort((a, b) => (b.blockHeight ?? 0) - (a.blockHeight ?? 0))
const page = all.slice(offset, offset + limit)
return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } }
},
async getFollowing (addr) {
const followees = []
for (const [key, following] of Object.entries(followState)) {
if (!following) continue
const [follower, followee] = key.split('|')
if (follower === addr) followees.push(followee)
}
return followees
}
}
}
// Fresh world/state object for a single scenario execution.
function createWorld () {
const wallet = makeWallet('bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d')
const feed = makeFeed()
const profiles = makeProfiles()
const memoPost = new MemoPost({ wallet, feed })
const thread = makeThread()
const memoReply = new MemoReply({ wallet, thread })
const memoLike = new MemoLike({ wallet, feed })
const memoFollow = new MemoFollow({ wallet, profiles })
const memoMute = new MemoMute({ wallet, profiles })
const memoTopicFollow = new MemoTopicFollow({ wallet, profiles })
const polls = makePolls()
const memoPollCreate = new MemoPollCreate({ wallet, polls })
const memoDb = makeMemoDb()
const world = {
wallet,
feed,
thread,
memoPost,
memoReply,
memoLike,
memoFollow,
memoMute,
memoTopicFollow,
polls,
memoPollCreate,
memoDb,
currentPath: null,
menuOpen: false,
likedTxids: new Set()
}
// Read-only page controllers backed by the fake psf-memo-db API.
world.recentFeedPage = new RecentFeedPage({ memoDb, wallet })
world.followingFeedPage = new FollowingFeedPage({ memoDb, wallet })
world.feedTabsPage = new FeedTabsPage({ memoDb, wallet })
world.notificationsPage = new NotificationsPage({ memoDb, wallet })
world.profilePage = new ProfilePage({ memoDb })
world.threadPage = new ThreadPage({ memoDb })
world.topicDiscoveryPage = new TopicDiscoveryPage({
memoDb,
navigate: (path) => { world.currentPath = path }
})
world.searchPage = new SearchPage({
memoDb,
wallet,
navigate: (path) => { world.currentPath = path }
})
world.recentProfilesPage = new RecentProfilesPage({ memoDb })
// The New Post Page controller wraps the memo post behavior. Its navigate
// adapter updates the world's current path so navigation can be asserted.
world.newPage = new NewPostPage({
memoPost,
navigate: (path) => { world.currentPath = path },
menuLinks: []
})
// The Reply Thread Page controller wraps the memo reply behavior. It does
// not navigate on success so the user stays in the thread modal.
world.replyPage = new ReplyThreadPage({
memoReply,
navigate: () => {}
})
// The Like / Tip Page controller wraps the memo like behavior.
world.likeTipPage = new LikeTipPage({ memoLike })
// The Set Name Page and Account Page controllers share a profile store so
// a name set on one page is visible on the other.
const memoSetName = new MemoSetName({ wallet, profiles })
world.setNamePage = new SetNamePage({
memoSetName,
navigate: (path) => { world.currentPath = path }
})
// The Set Bio Page controller shares the same profile store.
const memoSetBio = new MemoSetBio({ wallet, profiles })
world.setBioPage = new SetBioPage({
memoSetBio,
navigate: (path) => { world.currentPath = path }
})
// The Set Avatar URL Page controller shares the same profile store.
const memoSetAvatarUrl = new MemoSetAvatarUrl({ wallet, profiles })
world.setAvatarUrlPage = new SetAvatarUrlPage({
memoSetAvatarUrl,
navigate: (path) => { world.currentPath = path }
})
world.accountPage = new AccountPage({
wallet,
profiles,
navigate: (path) => { world.currentPath = path }
})
// The Poll Create Page controller wraps the memo poll create behavior.
world.pollCreatePage = new PollCreatePage({
memoPollCreate,
navigate: (path) => { world.currentPath = path }
})
return world
}
// Decode a raw reply payload into its parent txid (hex) and reply text.
// The wire stores the txid little-endian, so reverse it into display order.
function decodeReplyPayload (raw) {
const buf = Buffer.from(raw)
const parentTxid = Buffer.from(buf.slice(0, 32)).reverse().toString('hex')
const text = buf.slice(32).toString('utf8')
return { parentTxid, text }
}
// Decode a raw like payload back into the liked post txid (hex) in display
// order. The wire stores the txid little-endian, so reverse it.
function decodeLikeTxid (raw) {
return Buffer.from(raw).reverse().toString('hex')
}
// Return the most recent wallet broadcast, or fail when none was sent.
function lastBroadcast (world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
return broadcasts[broadcasts.length - 1]
}
// Return the push at the given zero-based index from the most recent
// broadcast. Index 0 is the action prefix; later indexes are the fields.
function broadcastPush (world, index) {
const last = lastBroadcast(world)
if (!Array.isArray(last.pushes) || last.pushes.length <= index) {
throw new Error(`Broadcast does not have a push at index ${index}.`)
}
return Buffer.from(last.pushes[index])
}
// Reverse a display txid into little-endian wire hex.
function txidWireHex (txid) {
return Buffer.from(txid, 'hex').reverse().toString('hex')
}
// Assert that the most recent broadcast's push at `index` decodes to the
// expected UTF-8 text. Shared by the topic/text/question push assertions.
function assertUtf8Push (world, index, expected, label) {
const push = broadcastPush(world, index)
const actual = push.toString('utf8')
if (actual !== expected) {
throw new Error(`${label} push "${actual}" did not match "${expected}".`)
}
}
// Resolve a literal value or a <parameter> placeholder from the example store.
function resolveParam (value, example) {
const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim())
if (match) {
const param = match[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
return example[param]
}
return String(value).trim()
}
// Resolve a step value that may be a quoted literal or a <parameter> placeholder.
function resolveText (value, example) {
const trimmed = String(value).trim()
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
return resolveParam(trimmed.slice(1, -1), example)
}
return resolveParam(value, example)
}
// Resolve a step value that may contain one or more <parameter> placeholders
// embedded in literal text, e.g. "<displayPostCount> posts".
function resolveTemplate (value, example) {
return String(value).replace(/<([A-Za-z0-9_]+)>/g, (match, param) => {
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
return example[param]
})
}
// Look up a post that has been loaded onto one of the read-only pages.
function findDisplayedPost (txid, world) {
const fromThread = world.threadPage.getPost(txid)
if (fromThread) return fromThread
const fromProfile = world.profilePage.getPost(txid)
if (fromProfile) return fromProfile
const fromFeed = world.recentFeedPage.getPost(txid)
if (fromFeed) return fromFeed
return null
}
// True when the currently displayed page is a topic feed (used to dispatch the
// shared "Follow/Unfollow button" steps between the profile page and the topic
// feed page).
function isTopicFeedActive (world) {
return Boolean(world.currentPath && String(world.currentPath).startsWith('/topics/'))
}
// Fixture "recent-profiles-identities" from recent-profile-display.feature:
// the GET /profile/recent response already carrying each profile's display
// name (name) and avatar URL (profilePicUrl), null when absent.
function loadRecentProfilesFixture (world, name) {
if (name !== 'recent-profiles-identities') {
throw new Error(`Unknown recent profiles fixture: ${name}`)
}
const fixture = [
{ addr: 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy', text: 'alice bio', name: 'alice', profilePicUrl: 'https://example.com/alice.png' },
{ addr: 'bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r', text: 'bob bio', name: 'bob', profilePicUrl: 'https://example.com/bob.jpg' },
{ addr: 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', text: 'carol bio', name: 'carol', profilePicUrl: null },
{ addr: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', text: 'dave bio', name: null, profilePicUrl: 'https://example.com/dave.png' }
]
for (const profile of fixture) {
world.memoDb.profiles.push(profile)
}
}
// The account view model for the recent profile with the given address.
function recentProfileAccountFor (world, addr) {
const table = buildRecentProfilesTable(world.recentProfilesPage.profiles)
const row = table.rows.find((candidate) => candidate.addr === addr)
if (!row) {
throw new Error(`No recent profiles account for the address ${addr}.`)
}
return row.account
}
// Handler registry. Each entry: { pattern, run }.
// run receives (match, exampleStore, world, step).
const handlers = [
{
name: 'wallet authenticated for address',
pattern: /^a wallet authenticated for the address (.+)$/,
run (m, example, world) {
world.wallet.walletInfo.cashAddress = m[1].trim()
}
},
{
name: 'wallet has spendable output',
pattern: /^the wallet has (?:a )?spendable output to pay the transaction fee$/,
run (m, example, world) {
world.wallet.utxos = [{ txid: 'utxo-for-fee', value: 100000 }]
}
},
{
name: 'viewing recent posts feed',
pattern: /^I am viewing the recent posts feed$/,
run (m, example, world) {
world.currentPath = NewPostPage.RECENT_FEED_PATH
}
},
{
name: 'wallet fails to broadcast with error',
pattern: /^the wallet fails to broadcast with the error "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.wallet.failWith = example[param]
}
},
{
name: 'navigate to path',
pattern: /^I navigate to the path (.+)$/,
run (m, example, world, step) {
const target = m[1].trim()
if (step.keyword === 'Then') {
if (world.currentPath !== target) {
throw new Error(`Expected to be on path ${target}, but current path is ${world.currentPath}.`)
}
} else {
world.currentPath = target
}
}
},
{
name: 'remain on path',
pattern: /^I remain on the path (.+)$/,
run (m, example, world) {
const target = m[1].trim()
if (world.currentPath !== target) {
throw new Error(`Expected to remain on path ${target}, but current path is ${world.currentPath}.`)
}
}
},
{
name: 'open navigation menu',
pattern: /^I open the navigation menu$/,
run (m, example, world) {
world.menuOpen = true
}
},
{
name: 'menu shows link to path',
pattern: /^the menu shows a link to the path (.+)$/,
run (m, example, world) {
const target = m[1].trim()
if (!world.newPage.hasMenuLink(target)) {
throw new Error(`Navigation menu does not link to ${target}.`)
}
}
},
{
name: 'compose/type memo text',
pattern: /^I (?:compose|type) a memo with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.newPage.setInput(example[param])
}
},
{
name: 'type bio text',
pattern: /^I type a bio with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.setBioPage.setInput(example[param])
}
},
{
name: 'type name text',
pattern: /^I type a name with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.setNamePage.setInput(example[param])
}
},
{
name: 'submit/click post',
pattern: /^I (?:submit the memo|click the post button)$/,
async run (m, example, world) {
await world.newPage.submit()
}
},
{
name: 'submit bio',
pattern: /^I submit the bio$/,
async run (m, example, world) {
await world.setBioPage.submit()
}
},
{
name: 'submit name',
pattern: /^I submit the name$/,
async run (m, example, world) {
await world.setNamePage.submit()
}
},
{
name: 'thread modal shows reply form',
pattern: /^the thread modal shows a reply form$/,
run (m, example, world) {
// The reply form is always considered visible once the thread is open.
if (!world.replyPage) {
throw new Error('No reply page is attached to the thread.')
}
}
},
{
name: 'post with txid has no replies',
pattern: /^a post with the txid (.+) has no replies$/,
run (m, example, world) {
const txid = m[1].trim()
world.thread.rootTxid = txid
world.replyPage.setParent(txid)
// A fresh thread store already has no replies.
if (world.thread.replies.length !== 0) {
throw new Error(`Expected post ${txid} to have no replies, but it has ${world.thread.replies.length}.`)
}
}
},
{
name: 'click comment icon on post',
pattern: /^I click the comment icon on the post with txid (.+)$/,
run (m, example, world) {
const txid = m[1].trim()
// Opening the thread modal means setting the active thread txid.
world.thread.rootTxid = txid
world.replyPage.setParent(txid)
}
},
{
name: 'thread modal opens for post',
pattern: /^the thread modal opens for the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
if (world.thread.rootTxid !== txid) {
throw new Error(`Expected thread modal to open for ${txid}, but current thread is ${world.thread.rootTxid}.`)
}
if (!world.replyPage) {
throw new Error('Thread modal opened without a reply form page.')
}
}
},
{
name: 'open reply thread',
pattern: /^I open the thread for the post with txid (.+)$/,
async run (m, example, world) {
const txid = resolveParam(m[1], example)
world.thread.rootTxid = txid
world.replyPage.setParent(txid)
await world.threadPage.load(txid)
}
},
{
name: 'type reply text',
pattern: /^I type a reply with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.replyPage.setInput(example[param])
world.replyPage.setParent(world.thread.rootTxid)
}
},
{
name: 'type reply to nested reply',
pattern: /^I type a reply to the nested reply with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.replyPage.setInput(example[param])
if (!world.nestedTxid) {
throw new Error('No nested reply has been selected.')
}
world.replyPage.setParent(world.nestedTxid)
}
},
{
name: 'submit reply',
pattern: /^I submit the reply$/,
async run (m, example, world) {
await world.replyPage.submit()
}
},
{
name: 'thread shows nested reply',
pattern: /^the thread shows a nested reply with the txid (.+)$/,
run (m, example, world) {
const txid = m[1].trim()
world.nestedTxid = txid
world.thread.addReply({
txid,
address: 'someone-else',
text: 'nested reply',
parentTxid: world.thread.rootTxid
})
}
},
{
name: 'click Set Bio button',
pattern: /^I click the Set Bio button$/,
run (m, example, world) {
world.accountPage.clickSetBio()
}
},
{
name: 'type avatar URL text',
pattern: /^I type an avatar URL with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.setAvatarUrlPage.setInput(example[param])
}
},
{
name: 'submit avatar URL',
pattern: /^I submit the avatar URL$/,
async run (m, example, world) {
await world.setAvatarUrlPage.submit()
}
},
{
name: 'click Set Avatar URL button',
pattern: /^I click the Set Avatar URL button$/,
run (m, example, world) {
world.accountPage.clickSetAvatarUrl()
}
},
{
name: 'broadcasts OP_RETURN with Memo set-profile-picture prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-profile-picture prefix$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_SET_AVATAR_URL_PREFIX) {
throw new Error(`Expected Memo set-profile-picture prefix ${MEMO_SET_AVATAR_URL_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== world.setAvatarUrlPage.input) {
throw new Error('Broadcast avatar URL text did not match the typed avatar URL.')
}
}
},
{
name: 'set avatar page shows validation/length error',
pattern: /^the set avatar page shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'avatar_url_validation' : 'avatar_url_length'
if (world.setAvatarUrlPage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.setAvatarUrlPage.submitError}.`)
}
}
},
{
name: 'set avatar page remaining byte count',
pattern: /^the set avatar page shows a remaining byte count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) {
throw new Error(`Invalid expected count for "${param}".`)
}
const actual = world.setAvatarUrlPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'account page shows avatar URL',
pattern: /^the account page shows my avatar URL as "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expected = example[param]
const actual = world.accountPage.getAvatarUrl()
if (actual !== expected) {
throw new Error(`Expected account avatar URL "${expected}", got "${actual}".`)
}
}
},
{
name: 'account page shows Set Avatar URL button',
pattern: /^the account page shows a Set Avatar URL button$/,
run (m, example, world) {
if (!world.accountPage.hasSetAvatarUrlButton()) {
throw new Error('Account page does not show a Set Avatar URL button.')
}
}
},
{
name: 'account page displays avatar image',
pattern: /^the account page displays the avatar image with the URL "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expected = example[param]
if (!world.accountPage.hasAvatarImage()) {
throw new Error('Account page does not have an avatar URL to display.')
}
const actual = world.accountPage.getAvatarImageUrl()
if (actual !== expected) {
throw new Error(`Expected avatar URL "${expected}", got "${actual}".`)
}
const html = renderAccountAvatar(actual)
if (!html.includes(`src="${expected}"`)) {
throw new Error(`Account page does not render an avatar image with src="${expected}".`)
}
if (!html.includes('<img')) {
throw new Error('Account page does not render an avatar image element.')
}
}
},
{
name: 'account page does not display avatar image',
pattern: /^the account page does not display an avatar image$/,
run (m, example, world) {
if (world.accountPage.hasAvatarImage()) {
throw new Error(`Account page unexpectedly has an avatar URL: ${world.accountPage.getAvatarImageUrl()}.`)
}
const html = renderAccountAvatar(world.accountPage.getAvatarImageUrl())
if (html.includes('<img')) {
throw new Error('Account page unexpectedly renders an avatar image element.')
}
}
},
{
name: 'click Set Name button',
pattern: /^I click the Set Name button$/,
run (m, example, world) {
world.accountPage.clickSetName()
}
},
{
name: 'broadcasts/attempts OP_RETURN with Memo post prefix',
pattern: /^(?:the wallet|the app) (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo post prefix$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_POST_PREFIX) {
throw new Error(`Expected Memo post prefix ${MEMO_POST_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== world.newPage.input) {
throw new Error('Broadcast message text did not match the composed memo.')
}
}
},
{
name: 'broadcasts OP_RETURN with Memo set-name prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-name prefix$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_SET_NAME_PREFIX) {
throw new Error(`Expected Memo set-name prefix ${MEMO_SET_NAME_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== world.setNamePage.input) {
throw new Error('Broadcast name text did not match the typed name.')
}
}
},
{
name: 'broadcasts OP_RETURN with Memo set-profile prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-profile prefix$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_SET_BIO_PREFIX) {
throw new Error(`Expected Memo set-profile prefix ${MEMO_SET_BIO_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== world.setBioPage.input) {
throw new Error('Broadcast bio text did not match the typed bio.')
}
}
},
{
name: 'broadcasts OP_RETURN with Memo reply prefix',
pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo reply prefix$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_REPLY_PREFIX) {
throw new Error(`Expected Memo reply prefix ${MEMO_REPLY_PREFIX}, got "${last.prefix}".`)
}
const { parentTxid, text } = decodeReplyPayload(last.msg)
if (parentTxid !== world.replyPage.parentTxid) {
throw new Error('Broadcast parent txid did not match the expected reply target.')
}
if (text !== world.replyPage.input) {
throw new Error('Broadcast reply text did not match the typed reply.')
}
}
},
{
name: 'wallet broadcasts multi-push OP_RETURN with prefix',
pattern: /^the wallet broadcasts an OP_RETURN with the Memo (reply|topic-message|add-poll-option|poll-vote|create-poll) prefix and (\d+) pushes$/,
run (m, example, world) {
const prefix = {
reply: MEMO_REPLY_PREFIX,
'topic-message': MEMO_TOPIC_MESSAGE_PREFIX,
'add-poll-option': MEMO_ADD_POLL_OPTION_PREFIX,
'poll-vote': MEMO_POLL_VOTE_PREFIX,
'create-poll': MEMO_CREATE_POLL_PREFIX
}[m[1]]
const expectedPushes = parseInt(m[2], 10)
const last = lastBroadcast(world)
if (last.prefix !== prefix) {
throw new Error(`Expected Memo ${m[1]} prefix ${prefix}, got "${last.prefix}".`)
}
const actual = Array.isArray(last.pushes) ? last.pushes.length : 0
if (actual !== expectedPushes) {
throw new Error(`Expected ${expectedPushes} OP_RETURN pushes, got ${actual}.`)
}
}
},
{
name: 'second broadcast push is referenced txid in wire order',
pattern: /^the second broadcast push is the referenced txid (.+) in little-endian wire order$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const push = broadcastPush(world, 1)
if (push.toString('hex') !== txidWireHex(txid)) {
throw new Error(`Second push did not match the wire txid ${txid}.`)
}
}
},
{
name: 'second broadcast push is UTF-8 topic',
pattern: /^the second broadcast push is the UTF-8 topic "(.+)"$/,
run (m, example, world) {
assertUtf8Push(world, 1, resolveText(m[1], example), 'Second')
}
},
{
name: 'second broadcast push is poll type',
pattern: /^the second broadcast push is the poll type (\d+)$/,
run (m, example, world) {
const expected = parseInt(m[1], 10)
const push = broadcastPush(world, 1)
if (push.length !== 1 || push[0] !== expected) {
throw new Error(`Second push was not the poll type ${expected}.`)
}
}
},
{
name: 'third broadcast push is UTF-8 text',
pattern: /^the third broadcast push is the UTF-8 text "(.+)"$/,
run (m, example, world) {
assertUtf8Push(world, 2, resolveText(m[1], example), 'Third')
}
},
{
name: 'third broadcast push is option count',
pattern: /^the third broadcast push is the option count (.+)$/,
run (m, example, world) {
const expected = parseInt(resolveParam(m[1], example), 10)
const push = broadcastPush(world, 2)
if (push.length !== 1 || push[0] !== expected) {
throw new Error(`Third push was not the option count ${expected}.`)
}
}
},
{
name: 'fourth broadcast push is UTF-8 question',
pattern: /^the fourth broadcast push is the UTF-8 question "(.+)"$/,
run (m, example, world) {
assertUtf8Push(world, 3, resolveText(m[1], example), 'Fourth')
}
},
{
name: 'thread shows new reply from my address',
pattern: /^the thread shows a new reply from my address with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expectedText = example[param]
const myAddress = world.wallet.walletInfo.cashAddress
const found = world.thread.replies.find(
(r) => r.text === expectedText && r.address === myAddress
)
if (!found) {
throw new Error(`Thread does not show the new reply with text "${expectedText}".`)
}
}
},
{
name: 'thread shows validation/length error',
pattern: /^the thread shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'reply_validation' : 'reply_length'
if (world.replyPage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.replyPage.submitError}.`)
}
}
},
{
name: 'thread remaining byte count',
pattern: /^the thread shows a remaining byte count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) {
throw new Error(`Invalid expected count for "${param}".`)
}
const actual = world.replyPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'feed shows new post from my address',
pattern: /^the feed shows a new post from my address with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expectedText = example[param]
const myAddress = world.wallet.walletInfo.cashAddress
const found = world.feed.posts.find(
(p) => p.text === expectedText && p.address === myAddress
)
if (!found) {
throw new Error(`Feed does not show the new post with text "${expectedText}".`)
}
}
},
{
name: 'page shows error containing text',
pattern: /^the new post page shows an error containing "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expected = example[param]
const actual = world.newPage.broadcastError || ''
if (!actual.includes(expected)) {
throw new Error(`Expected an error containing "${expected}", got "${actual}".`)
}
}
},
{
name: 'page shows success modal with txid',
pattern: /^the new post page shows a success modal with the post txid$/,
run (m, example, world) {
const page = world.newPage
const txid = page.lastResult && page.lastResult.txid
if (!page.showResultModal) {
throw new Error('Expected the new post success modal to be visible.')
}
if (!page.lastResult || !page.lastResult.ok) {
throw new Error('Expected a successful post result in the modal.')
}
if (!txid) {
throw new Error('Expected the success modal to include a post txid.')
}
const expectedUrl = NewPostPage.explorerUrl(txid)
if (page.explorerUrl(txid) !== expectedUrl) {
throw new Error(`Expected explorer URL ${expectedUrl}, got ${page.explorerUrl(txid)}.`)
}
if (!expectedUrl.startsWith(NewPostPage.EXPLORER_TX_BASE + '/')) {
throw new Error(`Explorer URL ${expectedUrl} is not on bch.loping.net.`)
}
}
},
{
name: 'page shows failure modal containing text',
pattern: /^the new post page shows a failure modal containing "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expected = example[param]
const page = world.newPage
const actual = (page.lastResult && page.lastResult.message) || page.broadcastError || ''
if (!page.showResultModal) {
throw new Error('Expected the new post failure modal to be visible.')
}
if (page.lastResult && page.lastResult.ok) {
throw new Error('Expected a failed post result in the modal.')
}
if (!actual.includes(expected)) {
throw new Error(`Expected a failure modal containing "${expected}", got "${actual}".`)
}
}
},
{
name: 'dismiss result modal',
pattern: /^I dismiss the result modal$/,
run (m, example, world) {
world.newPage.dismissResult()
}
},
{
name: 'page shows validation/length error',
pattern: /^the (?:app|new post page) shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'memo_validation' : 'memo_length'
if (world.newPage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.newPage.submitError}.`)
}
}
},
{
name: 'set name page shows validation/length error',
pattern: /^the set name page shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'name_validation' : 'name_length'
if (world.setNamePage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.setNamePage.submitError}.`)
}
}
},
{
name: 'set bio page shows validation/length error',
pattern: /^the set bio page shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'bio_validation' : 'bio_length'
if (world.setBioPage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.setBioPage.submitError}.`)
}
}
},
{
name: 'remaining character count',
pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) {
throw new Error(`Invalid expected count for "${param}".`)
}
const actual = world.newPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining characters, got ${actual}.`)
}
}
},
{
name: 'remaining byte count',
pattern: /^the set name page shows a remaining byte count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) {
throw new Error(`Invalid expected count for "${param}".`)
}
const actual = world.setNamePage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'set bio remaining byte count',
pattern: /^the set bio page shows a remaining byte count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) {
throw new Error(`Invalid expected count for "${param}".`)
}
const actual = world.setBioPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'app does not broadcast any transaction',
pattern: /^(?:the wallet|the app) does not broadcast any transaction$/,
run (m, example, world) {
if (world.wallet.broadcasts.length !== 0) {
throw new Error('A transaction was broadcast when none was expected.')
}
}
},
{
name: 'account page shows name',
pattern: /^the account page shows my name as "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expected = example[param]
const actual = world.accountPage.getName()
if (actual !== expected) {
throw new Error(`Expected account name "${expected}", got "${actual}".`)
}
}
},
{
name: 'account page shows bio',
pattern: /^the account page shows my bio as "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expected = example[param]
const actual = world.accountPage.getBio()
if (actual !== expected) {
throw new Error(`Expected account bio "${expected}", got "${actual}".`)
}
}
},
{
name: 'account page shows Set Name button',
pattern: /^the account page shows a Set Name button$/,
run (m, example, world) {
if (!world.accountPage.hasSetNameButton()) {
throw new Error('Account page does not show a Set Name button.')
}
}
},
{
name: 'account page shows Set Bio button',
pattern: /^the account page shows a Set Bio button$/,
run (m, example, world) {
if (!world.accountPage.hasSetBioButton()) {
throw new Error('Account page does not show a Set Bio button.')
}
}
},
{
name: 'wallet has spendable balance',
pattern: /^the wallet has a spendable balance of (.+) sats$/,
run (m, example, world) {
const balance = parseInt(resolveParam(m[1], example), 10)
if (Number.isNaN(balance)) {
throw new Error(`Invalid balance value "${m[1]}"`)
}
world.wallet.utxos = [{ txid: 'utxo-for-balance', value: balance }]
}
},
{
name: 'post with txid authored by author address',
pattern: /^a post with the txid (.+) authored by the author address$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const post = {
txid,
addr: AUTHOR_ADDRESS,
address: AUTHOR_ADDRESS,
text: 'A sample post',
likeCount: 0
}
world.feed.addPost(post)
}
},
{
name: 'post with txid authored by my address',
pattern: /^a post with the txid (.+) authored by my address$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const myAddress = world.wallet.walletInfo.cashAddress
const post = {
txid,
addr: myAddress,
address: myAddress,
text: 'My own post',
likeCount: 0
}
world.feed.addPost(post)
}
},
{
name: 'click heart icon on post',
pattern: /^I click the heart icon on the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const post = world.feed.posts.find((p) => p.txid === txid)
const authorAddress = post ? post.addr : AUTHOR_ADDRESS
world.likeTipPage.open(txid, authorAddress)
}
},
{
name: 'like/tip modal opens for post',
pattern: /^a like\/tip modal opens for the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
if (!world.likeTipPage.modalOpen) {
throw new Error('Expected like/tip modal to be open.')
}
if (world.likeTipPage.postTxid !== txid) {
throw new Error(`Expected like/tip modal for ${txid}, but got ${world.likeTipPage.postTxid}.`)
}
}
},
{
name: 'submit like without tip',
pattern: /^I submit the like without a tip$/,
async run (m, example, world) {
world.likeTipPage.setTip('')
const result = await world.likeTipPage.submit()
if (result.ok) {
world.likedTxids.add(world.likeTipPage.postTxid)
}
}
},
{
name: 'enter tip',
pattern: /^I enter a tip of (.+)$/,
run (m, example, world) {
world.likeTipPage.setTip(resolveParam(m[1], example))
}
},
{
name: 'submit like',
pattern: /^I submit the like$/,
async run (m, example, world) {
const result = await world.likeTipPage.submit()
if (result.ok) {
world.likedTxids.add(world.likeTipPage.postTxid)
}
}
},
{
name: 'broadcasts OP_RETURN with Memo like prefix',
pattern: /^the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_LIKE_PREFIX) {
throw new Error(`Expected Memo like prefix ${MEMO_LIKE_PREFIX}, got "${last.prefix}".`)
}
if (decodeLikeTxid(last.msg) !== txid) {
throw new Error(`Broadcast liked txid did not match ${txid}.`)
}
}
},
{
name: 'wallet sends no tip',
pattern: /^the wallet sends no tip$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (!Array.isArray(last.bchOutput) || last.bchOutput.length !== 0) {
throw new Error('Expected no tip output, but one was present.')
}
}
},
{
name: 'wallet sends tip to author',
pattern: /^the wallet sends a tip of (.+) to the author address$/,
run (m, example, world) {
const expectedTip = parseInt(resolveParam(m[1], example), 10)
if (Number.isNaN(expectedTip)) {
throw new Error(`Invalid tip value "${m[1]}"`)
}
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (!Array.isArray(last.bchOutput) || last.bchOutput.length === 0) {
throw new Error('Expected a tip output, but none was present.')
}
const tipOutput = last.bchOutput[0]
if (tipOutput.amountSat !== expectedTip) {
throw new Error(`Expected tip ${expectedTip} sats, got ${tipOutput.amountSat}.`)
}
const post = world.feed.posts.find((p) => p.txid === world.likeTipPage.postTxid)
const expectedAddress = post ? post.addr : AUTHOR_ADDRESS
if (tipOutput.address !== expectedAddress) {
throw new Error(`Expected tip to ${expectedAddress}, got ${tipOutput.address}.`)
}
}
},
{
name: 'like count increases by one',
pattern: /^the like count on the post increases by one$/,
run (m, example, world) {
const postTxid = world.likeTipPage.postTxid
const post = world.feed.posts.find((p) => p.txid === postTxid)
if (!post) {
throw new Error(`Post ${postTxid} not found in feed.`)
}
if (post.likeCount !== 1) {
throw new Error(`Expected like count to be 1, got ${post.likeCount}.`)
}
}
},
{
name: 'heart icon shows as filled',
pattern: /^the heart icon on the post shows as filled$/,
run (m, example, world) {
const postTxid = world.likeTipPage.postTxid
if (!world.likedTxids.has(postTxid)) {
throw new Error(`Expected heart icon to be filled for ${postTxid}.`)
}
}
},
{
name: 'like/tip modal shows error containing text',
pattern: /^the like\/tip modal shows an error containing "(.+)"$/,
run (m, example, world) {
const expected = m[1]
const actual = world.likeTipPage.broadcastError || ''
if (!actual.includes(expected)) {
throw new Error(`Expected an error containing "${expected}", got "${actual}".`)
}
}
},
{
name: 'click cancel button',
pattern: /^I click the cancel button$/,
run (m, example, world) {
world.likeTipPage.close()
}
},
{
name: 'like/tip modal closes',
pattern: /^the like\/tip modal closes$/,
run (m, example, world) {
if (world.likeTipPage.modalOpen) {
throw new Error('Expected like/tip modal to be closed.')
}
}
},
{
name: 'like/tip modal remains open',
pattern: /^the like\/tip modal remains open$/,
run (m, example, world) {
if (!world.likeTipPage.modalOpen) {
throw new Error('Expected like/tip modal to remain open.')
}
}
},
{
name: 'like/tip modal shows a broadcast success message',
pattern: /^the like\/tip modal shows a broadcast success message$/,
run (m, example, world) {
assertBroadcastResultMessage(world, 'like')
}
},
{
name: 'like/tip modal shows the like transaction id',
pattern: /^the like\/tip modal shows the like transaction id$/,
run (m, example, world) {
assertBroadcastResultTxid(world, 'like')
}
},
{
name: 'like/tip modal shows a block explorer link',
pattern: /^the like\/tip modal shows a link to the block explorer for the like transaction$/,
run (m, example, world) {
assertBroadcastResultExplorerLink(world, 'like')
}
},
{
name: 'dismiss like result',
pattern: /^I dismiss the like result$/,
run (m, example, world) {
world.likeTipPage.dismissResult()
}
},
{
name: 'API serves post with explicit address and like count',
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with a like count of (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const addr = resolveParam(m[2], example)
const likeCount = parseInt(resolveParam(m[3], example), 10)
world.memoDb.addPost({ txid, addr, likeCount, text: 'A sample post', blockHeight: 100 })
}
},
{
name: 'API serves post with explicit address and no like count',
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with no like count recorded$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const addr = resolveParam(m[2], example)
world.memoDb.addPost({ txid, addr, likeCount: undefined, text: 'A sample post', blockHeight: 100 })
}
},
{
name: 'API serves post with second/third address and like count',
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by a (second|third) address with a like count of (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const addr = m[2] === 'second' ? SECOND_ADDRESS : THIRD_ADDRESS
const likeCount = parseInt(resolveParam(m[3], example), 10)
world.memoDb.addPost({ txid, addr, likeCount, text: 'A sample post', blockHeight: 100 })
}
},
{
name: 'API serves post with second/third address and no like count',
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by a (second|third) address with no like count recorded$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const addr = m[2] === 'second' ? SECOND_ADDRESS : THIRD_ADDRESS
world.memoDb.addPost({ txid, addr, likeCount: undefined, text: 'A sample post', blockHeight: 100 })
}
},
{
name: 'open recent posts feed',
pattern: /^I open the recent posts feed$/,
async run (m, example, world) {
await world.recentFeedPage.load()
world.currentPath = RecentFeedPage.RECENT_FEED_PATH
world.renderedFeed = world.recentFeedPage.posts.map((post) => renderPostText(post.text))
}
},
{
name: 'recent feed shows post text',
pattern: /^the recent feed shows the post with text (.+)$/,
run (m, example, world) {
const expected = resolveText(m[1], example)
const found = world.recentFeedPage.posts.find((p) => p.text === expected)
if (!found) {
throw new Error(`Recent feed does not show a post with text "${expected}".`)
}
}
},
{
name: 'recent feed does not show post text',
pattern: /^the recent feed does not show the post with text (.+)$/,
run (m, example, world) {
const expected = resolveText(m[1], example)
const found = world.recentFeedPage.posts.find((p) => p.text === expected)
if (found) {
throw new Error(`Recent feed unexpectedly shows a post with text "${expected}".`)
}
}
},
{
name: 'open posts feed',
pattern: /^I open the posts feed$/,
async run (m, example, world) {
await world.feedTabsPage.open()
world.currentPath = RecentFeedPage.RECENT_FEED_PATH
}
},
{
name: 'open posts feed with page size',
pattern: /^I open the posts feed with page size (\d+)$/,
async run (m, example, world) {
await world.feedTabsPage.open({ limit: parseInt(m[1], 10) })
world.currentPath = RecentFeedPage.RECENT_FEED_PATH
}
},
{
name: 'posts feed shows Recent and Following tabs',
pattern: /^the posts feed shows the tabs "Recent" and "Following"$/,
run (m, example, world) {
const tabs = world.feedTabsPage.tabs
if (tabs.length !== 2 || tabs[0] !== 'Recent' || tabs[1] !== 'Following') {
throw new Error(`Expected the Recent and Following tabs, got ${JSON.stringify(tabs)}.`)
}
}
},
{
name: 'Following tab is active',
pattern: /^the Following tab is active$/,
run (m, example, world) {
if (!world.feedTabsPage.isFollowing()) {
throw new Error('Expected the Following tab to be active.')
}
}
},
{
name: 'Recent tab is active',
pattern: /^the Recent tab is active$/,
run (m, example, world) {
if (!world.feedTabsPage.isRecent()) {
throw new Error('Expected the Recent tab to be active.')
}
}
},
{
name: 'click Recent tab',
pattern: /^I click the Recent tab$/,
async run (m, example, world) {
await world.feedTabsPage.selectTab('Recent')
}
},
{
name: 'click Following tab',
pattern: /^I click the Following tab$/,
async run (m, example, world) {
await world.feedTabsPage.selectTab('Following')
}
},
{
name: 'posts feed shows post text',
pattern: /^the posts feed shows the post with text (.+)$/,
run (m, example, world) {
const expected = resolveText(m[1], example)
const found = world.feedTabsPage.posts.find((p) => p.text === expected)
if (!found) {
throw new Error(`Posts feed does not show a post with text "${expected}".`)
}
}
},
{
name: 'posts feed does not show post text',
pattern: /^the posts feed does not show the post with text (.+)$/,
run (m, example, world) {
const expected = resolveText(m[1], example)
const found = world.feedTabsPage.posts.find((p) => p.text === expected)
if (found) {
throw new Error(`Posts feed unexpectedly shows a post with text "${expected}".`)
}
}
},
{
name: 'posts feed shows no posts',
pattern: /^the posts feed shows no posts$/,
run (m, example, world) {
if (world.feedTabsPage.posts.length !== 0) {
throw new Error(`Expected no posts in the posts feed, got ${world.feedTabsPage.posts.length}.`)
}
}
},
{
name: 'posts feed shows N posts',
pattern: /^the posts feed shows (\d+) posts$/,
run (m, example, world) {
const expected = parseInt(m[1], 10)
const actual = world.feedTabsPage.posts.length
if (actual !== expected) {
throw new Error(`Expected ${expected} posts in the posts feed, got ${actual}.`)
}
}
},
{
name: 'posts feed shows not following anyone message',
pattern: /^the posts feed shows a message that I am not following anyone$/,
run (m, example, world) {
if (!world.feedTabsPage.emptyBecauseNoFollows) {
throw new Error('Expected the posts feed to show the not-following-anyone message.')
}
}
},
{
name: 'posts feed does not show not following anyone message',
pattern: /^the posts feed does not show a message that I am not following anyone$/,
run (m, example, world) {
if (world.feedTabsPage.emptyBecauseNoFollows) {
throw new Error('Did not expect the posts feed to show the not-following-anyone message.')
}
}
},
{
name: 'click Next page button',
pattern: /^I click the Next page button$/,
async run (m, example, world) {
await world.feedTabsPage.nextPage()
}
},
{
name: 'open profile page for author',
pattern: /^I open the profile page for the author of the post with txid (.+)$/,
async run (m, example, world) {
const txid = resolveParam(m[1], example)
const post = world.memoDb.posts.find((p) => p.txid === txid)
if (!post) {
throw new Error(`No API post found for txid ${txid}.`)
}
world.profilePage.addr = post.addr
await world.profilePage.load()
world.currentPath = `${ProfilePage.PROFILE_PATH_PREFIX}/${encodeURIComponent(post.addr)}`
}
},
{
name: 'thread contains a reply',
pattern: /^the thread for the post with txid (.+) contains a reply with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const replyTxid = resolveParam(m[2], example)
const expectedReplyCount = parseInt(example.expected_reply, 10)
const rootPost = world.memoDb.posts.find((p) => p.txid === txid)
if (!rootPost) {
throw new Error(`No API post found for txid ${txid}.`)
}
const thread = {
post: {
...rootPost,
replies: [{
txid: replyTxid,
addr: 'bitcoincash:reply-author',
text: 'A sample reply',
likeCount: Number.isNaN(expectedReplyCount) ? 0 : expectedReplyCount,
blockHeight: rootPost.blockHeight + 1,
replies: []
}]
}
}
world.memoDb.addThread(txid, thread)
}
},
{
name: 'post shows like count',
pattern: /^the post with txid (.+) shows the like count (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const expected = parseInt(resolveParam(m[2], example), 10)
const post = findDisplayedPost(txid, world)
if (!post) {
throw new Error(`Post ${txid} is not displayed.`)
}
const actual = post.likeCount ?? 0
if (actual !== expected) {
throw new Error(`Expected like count ${expected} for ${txid}, got ${actual}.`)
}
}
},
{
name: 'reply shows like count',
pattern: /^the reply with txid (.+) shows the like count (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const expected = parseInt(resolveParam(m[2], example), 10)
const post = world.threadPage.getPost(txid)
if (!post) {
throw new Error(`Reply ${txid} is not displayed.`)
}
const actual = post.likeCount ?? 0
if (actual !== expected) {
throw new Error(`Expected like count ${expected} for reply ${txid}, got ${actual}.`)
}
}
},
{
name: 'API reports I follow address',
pattern: /^the psf-memo-db API reports that I follow the address (.+)$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.setFollowState(myAddr, addr, true)
}
},
{
name: 'open profile page for address',
pattern: /^I open the profile page for the address (.+)$/,
async run (m, example, world) {
const addr = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.profilePage = new ProfilePage({
memoDb: world.memoDb,
addr,
myAddr,
memoFollow: world.memoFollow,
memoMute: world.memoMute
})
await world.profilePage.load()
world.currentPath = `${ProfilePage.PROFILE_PATH_PREFIX}/${encodeURIComponent(addr)}`
}
},
{
name: 'open profile page for own address',
pattern: /^I open the profile page for my own address$/,
async run (m, example, world) {
const myAddr = world.wallet.walletInfo.cashAddress
world.profilePage = new ProfilePage({
memoDb: world.memoDb,
addr: myAddr,
myAddr,
memoFollow: world.memoFollow,
memoMute: world.memoMute
})
await world.profilePage.load()
world.currentPath = `${ProfilePage.PROFILE_PATH_PREFIX}/${encodeURIComponent(myAddr)}`
}
},
{
name: 'profile page shows Follow button',
pattern: /^the profile page shows a Follow button$/,
run (m, example, world) {
if (!world.profilePage) {
throw new Error('No profile page is loaded.')
}
if (!world.profilePage.canFollow()) {
throw new Error('Profile page cannot show a Follow button for this address.')
}
if (world.profilePage.isFollowing()) {
throw new Error('Profile page shows Unfollow, but Follow was expected.')
}
}
},
{
name: 'profile page shows Unfollow button',
pattern: /^the profile page shows an Unfollow button$/,
run (m, example, world) {
if (!world.profilePage) {
throw new Error('No profile page is loaded.')
}
if (!world.profilePage.canFollow()) {
throw new Error('Profile page cannot show an Unfollow button for this address.')
}
if (!world.profilePage.isFollowing()) {
throw new Error('Profile page shows Follow, but Unfollow was expected.')
}
}
},
{
name: 'profile page does not show Follow button',
pattern: /^the profile page does not show a Follow button$/,
run (m, example, world) {
if (!world.profilePage) {
throw new Error('No profile page is loaded.')
}
if (world.profilePage.canFollow()) {
throw new Error('Profile page should not show a Follow button.')
}
}
},
{
name: 'click Follow button',
pattern: /^I click the Follow button$/,
async run (m, example, world) {
if (isTopicFeedActive(world)) {
await world.topicFeedPage.follow()
} else {
await world.profilePage.follow()
}
}
},
{
name: 'click Unfollow button',
pattern: /^I click the Unfollow button$/,
async run (m, example, world) {
if (isTopicFeedActive(world)) {
await world.topicFeedPage.unfollow()
} else {
await world.profilePage.unfollow()
}
}
},
{
name: 'broadcasts OP_RETURN with Memo follow prefix for address',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo follow prefix for the address (.+)$/,
run (m, example, world) {
assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_FOLLOW_PREFIX, 'follow')
}
},
{
name: 'broadcasts OP_RETURN with Memo unfollow prefix for address',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo unfollow prefix for the address (.+)$/,
run (m, example, world) {
assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_UNFOLLOW_PREFIX, 'unfollow')
}
},
{
name: 'API reports I mute address',
pattern: /^the psf-memo-db API reports that I mute the address (.+)$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.setMuteState(myAddr, addr, true)
}
},
{
name: 'API reports I unmute address',
pattern: /^the psf-memo-db API reports that I unmute the address (.+)$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.setMuteState(myAddr, addr, false)
}
},
{
name: 'profile page shows Mute button',
pattern: /^the profile page shows a Mute button$/,
run (m, example, world) {
if (!world.profilePage) {
throw new Error('No profile page is loaded.')
}
if (!world.profilePage.canMute()) {
throw new Error('Profile page cannot show a Mute button for this address.')
}
if (world.profilePage.isMuting()) {
throw new Error('Profile page shows Unmute, but Mute was expected.')
}
}
},
{
name: 'profile page shows Unmute button',
pattern: /^the profile page shows an Unmute button$/,
run (m, example, world) {
if (!world.profilePage) {
throw new Error('No profile page is loaded.')
}
if (!world.profilePage.canMute()) {
throw new Error('Profile page cannot show an Unmute button for this address.')
}
if (!world.profilePage.isMuting()) {
throw new Error('Profile page shows Mute, but Unmute was expected.')
}
}
},
{
name: 'profile page does not show Mute button',
pattern: /^the profile page does not show a Mute button$/,
run (m, example, world) {
if (!world.profilePage) {
throw new Error('No profile page is loaded.')
}
if (world.profilePage.canMute()) {
throw new Error('Profile page should not show a Mute button.')
}
}
},
{
name: 'click Mute button',
pattern: /^I click the Mute button$/,
async run (m, example, world) {
await world.profilePage.mute()
}
},
{
name: 'click Unmute button',
pattern: /^I click the Unmute button$/,
async run (m, example, world) {
await world.profilePage.unmute()
}
},
{
name: 'broadcasts or attempts Memo mute prefix for address',
pattern: /^the app (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo mute prefix for the address (.+)$/,
run (m, example, world) {
assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_MUTE_PREFIX, 'mute')
}
},
{
name: 'broadcasts OP_RETURN with Memo unmute prefix for address',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo unmute prefix for the address (.+)$/,
run (m, example, world) {
assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_UNMUTE_PREFIX, 'unmute')
}
},
{
name: 'profile page shows a mute result modal',
pattern: /^the profile page shows a mute result modal$/,
run (m, example, world) {
if (!world.profilePage) {
throw new Error('No profile page is loaded.')
}
if (!world.profilePage.showMuteResultModal) {
throw new Error('Expected the mute result modal to be visible.')
}
}
},
{
name: 'mute result modal shows a broadcast success message',
pattern: /^the mute result modal shows a broadcast success message$/,
run (m, example, world) {
assertBroadcastResultMessage(world, 'mute')
}
},
{
name: 'mute result modal shows the mute transaction id',
pattern: /^the mute result modal shows the mute transaction id$/,
run (m, example, world) {
assertBroadcastResultTxid(world, 'mute')
}
},
{
name: 'mute result modal shows a block explorer link',
pattern: /^the mute result modal shows a link to the block explorer for the mute transaction$/,
run (m, example, world) {
assertBroadcastResultExplorerLink(world, 'mute')
}
},
{
name: 'dismiss mute result',
pattern: /^I dismiss the mute result$/,
run (m, example, world) {
world.profilePage.dismissMuteResult()
}
},
{
name: 'mute result modal closes',
pattern: /^the mute result modal closes$/,
run (m, example, world) {
if (!world.profilePage) {
throw new Error('No profile page is loaded.')
}
if (world.profilePage.showMuteResultModal) {
throw new Error('Expected the mute result modal to be closed.')
}
}
},
{
name: 'profile page shows a failure modal containing text',
pattern: /^the profile page shows a failure modal containing "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
const expected = example[param]
const page = world.profilePage
if (!page) {
throw new Error('No profile page is loaded.')
}
if (!page.showMuteResultModal) {
throw new Error('Expected the mute failure modal to be visible.')
}
const actual = page.getMuteResultError()
if (!actual.includes(expected)) {
throw new Error(`Expected a failure modal containing "${expected}", got "${actual}".`)
}
if (!renderMuteResult({ error: actual }).includes(expected)) {
throw new Error(`The rendered mute failure modal does not show "${expected}".`)
}
}
},
{
name: 'broadcasts OP_RETURN with Memo binary hash160 payload for address',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo (follow|unfollow|mute|unmute) prefix and the binary hash160 payload for the address (.+)$/,
run (m, example, world) {
const action = m[1]
const addr = resolveParam(m[2], example)
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
const prefix = {
follow: MEMO_FOLLOW_PREFIX,
unfollow: MEMO_UNFOLLOW_PREFIX,
mute: MEMO_MUTE_PREFIX,
unmute: MEMO_UNMUTE_PREFIX
}[action]
if (!prefix) {
throw new Error(`Unknown follow/mute action: ${action}`)
}
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== prefix) {
throw new Error(`Expected Memo ${action} prefix ${prefix}, got "${last.prefix}".`)
}
if (last.msg.length !== 20) {
throw new Error(`Broadcast ${action} payload is not 20 bytes.`)
}
if (last.msg.toString('hex') !== hash160) {
throw new Error(`Broadcast ${action} hash160 did not match ${addr}.`)
}
}
},
{
name: 'API serves topic with post count',
pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\S+) posts?$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const count = parseInt(resolveParam(m[2], example), 10)
world.memoDb.addTopic(room, count)
}
},
{
name: 'topic has follower count',
pattern: /^the topic "([^"]+)" has (\S+) followers?$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const followerCount = parseInt(resolveParam(m[2], example), 10)
world.memoDb.setTopicFollowerCount(room, followerCount)
}
},
{
name: 'topic was last posted at',
pattern: /^the topic "([^"]+)" was last posted at (\S+)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const lastSeen = parseInt(resolveParam(m[2], example), 10)
world.memoDb.setTopicLastSeen(room, lastSeen)
}
},
{
name: 'set current time',
pattern: /^the current time is (\S+)$/,
run (m, example, world) {
world.currentTime = parseInt(resolveParam(m[1], example), 10)
}
},
{
name: 'API serves post in topic with address and text',
pattern: /^the psf-memo-db API serves a post with txid (.+) in the topic "([^"]+)" authored by the address (.+) with text (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const room = m[2]
const addr = resolveParam(m[3], example)
const text = resolveText(m[4], example)
world.memoDb.addTopicPost(room, { txid, addr, text, blockHeight: 100 })
}
},
{
name: 'API serves post in topic with second address and text',
pattern: /^the psf-memo-db API serves a post with txid (.+) in the topic "([^"]+)" authored by a second address with text "(.+)"$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const room = m[2]
const text = m[3]
world.memoDb.addTopicPost(room, { txid, addr: SECOND_ADDRESS, text, blockHeight: 101 })
}
},
{
name: 'API serves no posts for topic',
pattern: /^the psf-memo-db API serves no posts for the topic "([^"]+)"$/,
run (m, example, world) {
const room = m[1]
world.memoDb.topicCounts.set(room, 0)
world.memoDb.topicPosts[room] = []
}
},
{
name: 'open topics page',
pattern: /^I open the topics page$/,
async run (m, example, world) {
await world.topicDiscoveryPage.load()
world.currentPath = TopicDiscoveryPage.TOPICS_PATH
}
},
{
name: 'open topics page at offset',
pattern: /^I open the topics page at offset (<[A-Za-z0-9_]+>)$/,
async run (m, example, world) {
const offset = parseInt(resolveParam(m[1], example), 10)
await world.topicDiscoveryPage.load({ limit: 50, offset })
world.currentPath = TopicDiscoveryPage.TOPICS_PATH
}
},
{
name: 'API serves N topics',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) topics$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
for (let i = 0; i < count; i++) {
world.memoDb.addTopic(`topic-${String(i + 1).padStart(3, '0')}`, 1)
}
}
},
{
name: 'topics page shows N topics',
pattern: /^the topics page shows (<[A-Za-z0-9_]+>) topics$/,
run (m, example, world) {
const expected = parseInt(resolveParam(m[1], example), 10)
const actual = world.topicDiscoveryPage.topics.length
if (actual !== expected) {
throw new Error(`Expected ${expected} topics on the topics page, got ${actual}.`)
}
}
},
{
name: 'topics page can load more topics',
pattern: /^the topics page can load more topics$/,
run (m, example, world) {
if (!world.topicDiscoveryPage.canLoadMore()) {
throw new Error('Expected the topics page to have more topics, but pagination says there are none.')
}
}
},
{
name: 'topics page has no more topics',
pattern: /^the topics page has no more topics$/,
run (m, example, world) {
if (world.topicDiscoveryPage.canLoadMore()) {
throw new Error('Expected the topics page to have no more topics, but pagination says there are more.')
}
}
},
{
name: 'topics page shows topic count',
pattern: /^the topics page shows the topic "?([^"]+?)"? with (\S+) posts$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const expected = parseInt(resolveParam(m[2], example), 10)
const topic = world.topicDiscoveryPage.getTopic(room)
if (!topic) {
throw new Error(`Topic ${room} is not shown on the topics page.`)
}
if (topic.postCount !== expected) {
throw new Error(`Expected ${room} to have ${expected} posts, got ${topic.postCount}.`)
}
}
},
{
name: 'topics page shows topic follower count',
pattern: /^the topics page shows the topic "?([^"]+?)"? with (\S+) followers?$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const expected = parseInt(resolveParam(m[2], example), 10)
const topic = world.topicDiscoveryPage.getTopic(room)
if (!topic) {
throw new Error(`Topic ${room} is not shown on the topics page.`)
}
if (topic.followerCount !== expected) {
throw new Error(`Expected ${room} to have ${expected} followers, got ${topic.followerCount}.`)
}
}
},
{
name: 'topics page shows most recent post label',
pattern: /^the topics page shows the most recent post for the topic "([^"]+)" as "(.+)"$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const now = world.currentTime ?? Date.now()
const actual = world.topicDiscoveryPage.getLastSeenLabel(room, now)
if (actual !== expected) {
throw new Error(`Expected ${room} most recent post "${expected}", got "${actual}".`)
}
}
},
{
name: 'topics table has column headers',
pattern: /^the topics table has the column headers "([^"]+)", "([^"]+)", "([^"]+)", "([^"]+)"$/,
run (m, example, world) {
const expected = [m[1], m[2], m[3], m[4]].map((value) => resolveTemplate(value, example))
const table = buildTopicsTable(world.topicDiscoveryPage.topics, { now: world.currentTime ?? Date.now() })
if (table.headers.join('|') !== expected.join('|')) {
throw new Error(`Expected headers ${expected.join(', ')}, got ${table.headers.join(', ')}.`)
}
}
},
{
name: 'topics table row has cells',
pattern: /^the topics table row for "([^"]+)" has the cells "([^"]*)", "([^"]*)", "([^"]*)" and "([^"]*)"$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const expected = [m[2], m[3], m[4], m[5]].map((value) => resolveTemplate(value, example))
const table = buildTopicsTable(world.topicDiscoveryPage.topics, { now: world.currentTime ?? Date.now() })
const row = table.rows.find((candidate) => candidate.room === room)
if (!row) {
throw new Error(`Topic ${room} is not shown in the topics table.`)
}
if (row.cells.join('|') !== expected.join('|')) {
throw new Error(`Expected cells ${expected.join(', ')}, got ${row.cells.join(', ')}.`)
}
}
},
{
name: 'topics table links topic',
pattern: /^the topics table links the topic "([^"]+)" to "([^"]+)"$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const expected = resolveTemplate(m[2], example)
const table = buildTopicsTable(world.topicDiscoveryPage.topics, { now: world.currentTime ?? Date.now() })
const row = table.rows.find((candidate) => candidate.room === room)
if (!row) {
throw new Error(`Topic ${room} is not shown in the topics table.`)
}
if (row.href !== expected) {
throw new Error(`Expected ${room} link "${expected}", got "${row.href}".`)
}
}
},
{
name: 'topics table scrolls horizontally',
pattern: /^the topics table scrolls horizontally on narrow screens$/,
run (m, example, world) {
const table = buildTopicsTable(world.topicDiscoveryPage.topics, { now: world.currentTime ?? Date.now() })
if (!String(table.wrapperClass).includes('table-responsive')) {
throw new Error(`Expected a horizontal-scroll wrapper, got "${table.wrapperClass}".`)
}
}
},
{
name: 'click topic',
pattern: /^I click the topic (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
world.topicDiscoveryPage.openTopic(room)
}
},
{
name: 'navigate to topic feed',
pattern: /^the app navigates to the topic feed for (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const expected = TopicFeedPage.topicFeedPath(room)
if (world.currentPath !== expected) {
throw new Error(`Expected to navigate to ${expected}, but current path is ${world.currentPath}.`)
}
}
},
{
name: 'open topic feed',
pattern: /^I open the topic feed for (?:the topic )?"?(<topic>|[^"]+)"?$/,
async run (m, example, world) {
const room = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.topicFeedPage = new TopicFeedPage({
memoDb: world.memoDb,
room,
myAddr,
memoTopicFollow: world.memoTopicFollow
})
await world.topicFeedPage.load()
world.currentPath = TopicFeedPage.topicFeedPath(room)
// Set up the topic post composer for this room so topic messages can be
// composed and broadcast, reflecting new posts onto the shared feed.
const memoTopicPost = new MemoTopicPost({ wallet: world.wallet, room, feed: world.feed })
world.topicPostPage = new TopicPostPage({ memoTopicPost })
}
},
{
name: 'topic feed shows post text',
pattern: /^the feed shows the post with txid (<txid>) with text (<text>)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const post = world.topicFeedPage.getPost(txid)
if (!post) {
throw new Error(`Post ${txid} is not shown in the topic feed.`)
}
if (post.text !== expected) {
throw new Error(`Expected post ${txid} text "${expected}", got "${post.text}".`)
}
}
},
{
name: 'topic feed shows post with text',
pattern: /^the topic feed shows the post with text (.+)$/,
run (m, example, world) {
const expected = resolveText(m[1], example)
const found = world.topicFeedPage.posts.find((p) => p.text === expected)
if (!found) {
throw new Error(`Topic feed does not show a post with text "${expected}".`)
}
}
},
{
name: 'topic feed does not show post with text',
pattern: /^the topic feed does not show the post with text (.+)$/,
run (m, example, world) {
const expected = resolveText(m[1], example)
const found = world.topicFeedPage.posts.find((p) => p.text === expected)
if (found) {
throw new Error(`Topic feed unexpectedly shows a post with text "${expected}".`)
}
}
},
{
name: 'topic feed shows empty message',
pattern: /^the feed shows a message that there are no posts$/,
run (m, example, world) {
const posts = world.topicFeedPage.posts
if (!Array.isArray(posts) || posts.length !== 0) {
throw new Error(`Expected topic feed to be empty, but found ${Array.isArray(posts) ? posts.length : 'non-array'} posts.`)
}
}
},
{
name: 'API reports I do not follow topic',
pattern: /^the psf-memo-db API reports that I do not follow the topic (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.setTopicFollowState(myAddr, room, false)
}
},
{
name: 'API reports I follow topic',
pattern: /^the psf-memo-db API reports that I follow the topic (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.setTopicFollowState(myAddr, room, true)
}
},
{
name: 'topic feed page shows Follow button',
pattern: /^the topic feed page shows a Follow button$/,
run (m, example, world) {
if (!world.topicFeedPage) throw new Error('No topic feed page is loaded.')
if (!world.topicFeedPage.canFollow()) throw new Error('Topic feed cannot show a Follow button.')
if (world.topicFeedPage.isFollowing()) throw new Error('Topic feed shows Unfollow, but Follow was expected.')
}
},
{
name: 'topic feed page shows Unfollow button',
pattern: /^the topic feed page shows an Unfollow button$/,
run (m, example, world) {
if (!world.topicFeedPage) throw new Error('No topic feed page is loaded.')
if (!world.topicFeedPage.canFollow()) throw new Error('Topic feed cannot show an Unfollow button.')
if (!world.topicFeedPage.isFollowing()) throw new Error('Topic feed shows Follow, but Unfollow was expected.')
}
},
{
name: 'broadcasts topic-follow prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-follow prefix for the topic (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_TOPIC_FOLLOW_PREFIX) {
throw new Error(`Expected Memo topic-follow prefix ${MEMO_TOPIC_FOLLOW_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== room) {
throw new Error(`Broadcast topic-follow payload did not match topic ${room}.`)
}
}
},
{
name: 'broadcasts topic-unfollow prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-unfollow prefix for the topic (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_TOPIC_UNFOLLOW_PREFIX) {
throw new Error(`Expected Memo topic-unfollow prefix ${MEMO_TOPIC_UNFOLLOW_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== room) {
throw new Error(`Broadcast topic-unfollow payload did not match topic ${room}.`)
}
}
},
{
name: 'compose topic message',
pattern: /^I compose a topic message with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}"`)
world.topicPostPage.setInput(example[param])
}
},
{
name: 'submit topic message',
pattern: /^I submit the topic message$/,
async run (m, example, world) {
await world.topicPostPage.submit()
}
},
{
name: 'broadcasts topic-message prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-message prefix for the topic (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_TOPIC_MESSAGE_PREFIX) {
throw new Error(`Expected Memo topic-message prefix ${MEMO_TOPIC_MESSAGE_PREFIX}, got "${last.prefix}".`)
}
const expectedPayload = room + world.topicPostPage.input
const actualPayload = Buffer.isBuffer(last.msg) ? last.msg.toString('utf8') : last.msg
if (actualPayload !== expectedPayload) {
throw new Error(`Broadcast topic-message payload did not match ${room} + input.`)
}
}
},
{
name: 'topic feed shows new post from my address',
pattern: /^the topic feed shows a new post from my address with the text "(.+)"$/,
run (m, example, world) {
const expectedText = resolveParam(m[1], example)
const myAddress = world.wallet.walletInfo.cashAddress
const found = world.feed.posts.find((p) => p.text === expectedText && p.address === myAddress)
if (!found) throw new Error(`Topic feed does not show the new post with text "${expectedText}".`)
}
},
{
name: 'topic post composer shows validation error',
pattern: /^the topic post composer shows a validation error$/,
run (m, example, world) {
if (world.topicPostPage.submitError !== 'topic_post_validation') {
throw new Error(`Expected topic_post_validation, got ${world.topicPostPage.submitError}.`)
}
}
},
{
name: 'topic post composer shows length error',
pattern: /^the topic post composer shows a length error$/,
run (m, example, world) {
if (world.topicPostPage.submitError !== 'topic_post_length') {
throw new Error(`Expected topic_post_length, got ${world.topicPostPage.submitError}.`)
}
}
},
{
name: 'topic post composer remaining byte count',
pattern: /^the topic post composer shows a remaining byte count of (<count>)$/,
run (m, example, world) {
const expected = parseInt(resolveParam(m[1], example), 10)
if (Number.isNaN(expected)) throw new Error(`Invalid expected count for "${m[1]}".`)
const actual = world.topicPostPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'poll with txid exists',
pattern: /^a poll with the txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
world.polls.addPoll({ txid, question: 'existing poll', optionCount: 2 })
world.currentPollTxid = txid
}
},
{
name: 'compose poll',
pattern: /^I compose a poll with the question "<([A-Za-z0-9_]+)>" and (.+) options$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
world.pollCreatePage.setInput(example[param])
world.pollCreatePage.setOptionCount(resolveParam(m[2], example))
}
},
{
name: 'submit poll',
pattern: /^I submit the poll$/,
async run (m, example, world) {
await world.pollCreatePage.submit()
}
},
{
name: 'broadcasts create-poll prefix for question',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo create-poll prefix for the question "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
const expected = example[param]
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_CREATE_POLL_PREFIX) {
throw new Error(`Expected Memo create-poll prefix ${MEMO_CREATE_POLL_PREFIX}, got "${last.prefix}".`)
}
const { question } = decodeCreatePollPayload(last.msg)
if (question !== expected) {
throw new Error(`Broadcast question "${question}" did not match "${expected}".`)
}
}
},
{
name: 'broadcasts create-poll prefix carrying option count',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo create-poll prefix carrying (.+) options$/,
run (m, example, world) {
const expected = parseInt(resolveParam(m[1], example), 10)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_CREATE_POLL_PREFIX) {
throw new Error(`Expected Memo create-poll prefix ${MEMO_CREATE_POLL_PREFIX}, got "${last.prefix}".`)
}
const { optionCount } = decodeCreatePollPayload(last.msg)
if (optionCount !== expected) {
throw new Error(`Expected option count ${expected}, got ${optionCount}.`)
}
}
},
{
name: 'poll composer shows validation/length error',
pattern: /^the poll composer shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'poll_create_validation' : 'poll_create_length'
if (world.pollCreatePage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.pollCreatePage.submitError}.`)
}
}
},
{
name: 'poll composer remaining byte count',
pattern: /^the poll composer shows a remaining byte count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) throw new Error(`Invalid expected count for "${param}".`)
const actual = world.pollCreatePage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'open poll',
pattern: /^I open the poll with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
world.currentPollTxid = txid
const memoPollOption = new MemoPollOption({ wallet: world.wallet, pollTxid: txid, polls: world.polls })
world.pollOptionPage = new PollOptionPage({ memoPollOption })
const memoPollVote = new MemoPollVote({ wallet: world.wallet, pollTxid: txid, polls: world.polls })
world.pollVotePage = new PollVotePage({ memoPollVote })
}
},
{
name: 'compose option',
pattern: /^I compose an option with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
world.pollOptionPage.setInput(example[param])
}
},
{
name: 'submit option',
pattern: /^I submit the option$/,
async run (m, example, world) {
await world.pollOptionPage.submit()
}
},
{
name: 'broadcasts add-poll-option prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo add-poll-option prefix for the poll (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_ADD_POLL_OPTION_PREFIX) {
throw new Error(`Expected Memo add-poll-option prefix ${MEMO_ADD_POLL_OPTION_PREFIX}, got "${last.prefix}".`)
}
const { pollTxid } = decodePollTxidPayload(last.msg)
if (pollTxid !== txid) {
throw new Error(`Broadcast poll txid ${pollTxid} did not match ${txid}.`)
}
}
},
{
name: 'poll shows new option',
pattern: /^the poll shows the new option with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
const expected = example[param]
const txid = world.currentPollTxid
const found = world.polls.getOptions(txid).find((o) => o.option === expected && o.address === world.wallet.walletInfo.cashAddress)
if (!found) {
throw new Error(`Poll does not show the new option "${expected}".`)
}
}
},
{
name: 'add-option composer shows validation/length error',
pattern: /^the add-option composer shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'poll_option_validation' : 'poll_option_length'
if (world.pollOptionPage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.pollOptionPage.submitError}.`)
}
}
},
{
name: 'add-option composer remaining byte count',
pattern: /^the add-option composer shows a remaining byte count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) throw new Error(`Invalid expected count for "${param}".`)
const actual = world.pollOptionPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'vote with comment',
pattern: /^I vote with the comment "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
world.pollVotePage.setInput(example[param])
}
},
{
name: 'submit vote',
pattern: /^I submit the vote$/,
async run (m, example, world) {
await world.pollVotePage.submit()
}
},
{
name: 'broadcasts poll-vote prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo poll-vote prefix for the poll (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_POLL_VOTE_PREFIX) {
throw new Error(`Expected Memo poll-vote prefix ${MEMO_POLL_VOTE_PREFIX}, got "${last.prefix}".`)
}
const { pollTxid } = decodePollTxidPayload(last.msg)
if (pollTxid !== txid) {
throw new Error(`Broadcast poll txid ${pollTxid} did not match ${txid}.`)
}
}
},
{
name: 'poll shows my vote',
pattern: /^the poll shows my vote with the comment "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}".`)
const expected = example[param]
const txid = world.currentPollTxid
const found = world.polls.getVotes(txid).find((v) => v.comment === expected && v.address === world.wallet.walletInfo.cashAddress)
if (!found) {
throw new Error(`Poll does not show my vote with comment "${expected}".`)
}
}
},
{
name: 'vote composer shows validation/length error',
pattern: /^the vote composer shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'poll_vote_validation' : 'poll_vote_length'
if (world.pollVotePage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.pollVotePage.submitError}.`)
}
}
},
{
name: 'API has search post',
pattern: /^the psf-memo-db API has a post with the text "(.+)"$/,
run (m, example, world) {
const text = resolveParam(m[1], example)
const txid = require('crypto').createHash('sha256').update(text).digest('hex')
world.memoDb.addSearchPost({
txid,
addr: `addr-${txid.slice(0, 8)}`,
text,
blockHeight: 100
})
}
},
{
name: 'API has search post with text and address',
pattern: /^the psf-memo-db API has a post with the text (.+) authored by the address (.+)$/,
run (m, example, world) {
const text = resolveText(m[1], example)
const addr = resolveParam(m[2], example)
const txid = require('crypto').createHash('sha256').update(`${text}:${addr}`).digest('hex')
world.memoDb.addSearchPost({ txid, addr, text, blockHeight: 100 })
}
},
{
name: 'API has search profile',
pattern: /^the psf-memo-db API has a profile named "(.+)" with the bio "(.+)"$/,
run (m, example, world) {
const name = resolveParam(m[1], example)
const text = resolveParam(m[2], example)
const addr = `addr-${require('crypto').createHash('sha256').update(name).digest('hex').slice(0, 8)}`
world.memoDb.addSearchProfile({ addr, name, text, blockHeight: 100 })
}
},
{
name: 'open search page',
pattern: /^I open the Search page$/,
async run (m, example, world) {
world.currentPath = SearchPage.SEARCH_PATH
}
},
{
name: 'submit search',
pattern: /^I submit a search for (.+)$/,
async run (m, example, world) {
const query = resolveParam(m[1], example)
world.searchPage.setQuery(query)
await world.searchPage.submit()
}
},
{
name: 'search results include post text',
pattern: /^the search results include a post with the text (.+)$/,
run (m, example, world) {
const expected = resolveParam(m[1], example)
const found = world.searchPage.posts.find((p) => p.text === expected)
if (!found) {
throw new Error(`Search results do not include a post with text "${expected}".`)
}
}
},
{
name: 'search results do not include post text',
pattern: /^the search results do not include a post with the text (.+)$/,
run (m, example, world) {
const expected = resolveText(m[1], example)
const found = world.searchPage.posts.find((p) => p.text === expected)
if (found) {
throw new Error(`Search results unexpectedly include a post with text "${expected}".`)
}
}
},
{
name: 'search results include profile name',
pattern: /^the search results include a profile named (.+)$/,
run (m, example, world) {
const expected = resolveParam(m[1], example)
const found = world.searchPage.profiles.find((p) => p.name === expected)
if (!found) {
throw new Error(`Search results do not include a profile named "${expected}".`)
}
}
},
{
name: 'search results include no posts',
pattern: /^the search results include no posts$/,
run (m, example, world) {
if (world.searchPage.posts.length !== 0) {
throw new Error(`Expected no posts in search results, got ${world.searchPage.posts.length}.`)
}
}
},
{
name: 'search results include no profiles',
pattern: /^the search results include no profiles$/,
run (m, example, world) {
if (world.searchPage.profiles.length !== 0) {
throw new Error(`Expected no profiles in search results, got ${world.searchPage.profiles.length}.`)
}
}
},
{
name: 'wallet follows address',
pattern: /^my wallet follows the address (.+)$/,
run (m, example, world) {
const followee = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.setFollowState(myAddr, followee, true)
}
},
{
name: 'API serves post with txid address text and block height',
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with text (.+) at block height (\d+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const addr = resolveParam(m[2], example)
const text = resolveText(m[3], example)
const blockHeight = parseInt(m[4], 10)
world.memoDb.addPost({ txid, addr, text, blockHeight })
}
},
{
name: 'API serves post with txid address and text',
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with text (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const addr = resolveParam(m[2], example)
const text = resolveText(m[3], example)
world.memoDb.addPost({ txid, addr, text, blockHeight: 100 })
}
},
{
name: 'API serves post with txid my address and text',
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by my wallet address with text (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
const text = resolveText(m[2], example)
world.memoDb.addPost({ txid, addr: myAddr, text, blockHeight: 100 })
}
},
{
name: 'API serves reply with txid parent and text',
pattern: /^the psf-memo-db API serves a reply with txid ([0-9a-fA-F]{64}) to the post with txid ([0-9a-fA-F]{64}) with text (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const parentTxid = resolveParam(m[2], example)
const text = resolveText(m[3], example)
world.memoDb.addReply({ txid, parentTxid, text, addr: 'bitcoincash:reply-author', blockHeight: 100 })
}
},
{
name: 'follow no one',
pattern: /^I follow no one$/,
run (m, example, world) {
const myAddr = world.wallet.walletInfo.cashAddress
for (const key of Object.keys(world.memoDb.followState)) {
if (key.startsWith(`${myAddr}|`)) {
world.memoDb.followState[key] = false
}
}
}
},
{
name: 'open following feed',
pattern: /^I open the Following feed$/,
async run (m, example, world) {
await world.followingFeedPage.load()
world.currentPath = FollowingFeedPage.FOLLOWING_FEED_PATH
}
},
{
name: 'open following feed with page size',
pattern: /^I open the Following feed with page size (\d+)$/,
async run (m, example, world) {
const limit = parseInt(m[1], 10)
await world.followingFeedPage.load({ limit })
world.currentPath = FollowingFeedPage.FOLLOWING_FEED_PATH
}
},
{
name: 'feed shows post with text',
pattern: /^the feed shows the post with text (.+)$/,
run (m, example, world) {
const expected = resolveText(m[1], example)
const found = world.followingFeedPage.posts.find((p) => p.text === expected)
if (!found) {
throw new Error(`Following feed does not show a post with text "${expected}".`)
}
}
},
{
name: 'feed does not show post with text',
pattern: /^the feed does not show the post with text (.+)$/,
run (m, example, world) {
const expected = resolveText(m[1], example)
const found = world.followingFeedPage.posts.find((p) => p.text === expected)
if (found) {
throw new Error(`Following feed unexpectedly shows a post with text "${expected}".`)
}
}
},
{
name: 'feed shows post txid before txid',
pattern: /^the feed shows the post with txid (.+) before the post with txid (.+)$/,
run (m, example, world) {
const firstTxid = resolveParam(m[1], example)
const secondTxid = resolveParam(m[2], example)
const posts = world.followingFeedPage.posts
const firstIndex = posts.findIndex((p) => p.txid === firstTxid)
const secondIndex = posts.findIndex((p) => p.txid === secondTxid)
if (firstIndex === -1) {
throw new Error(`Following feed does not show post ${firstTxid}.`)
}
if (secondIndex === -1) {
throw new Error(`Following feed does not show post ${secondTxid}.`)
}
if (firstIndex >= secondIndex) {
throw new Error(`Expected post ${firstTxid} before ${secondTxid}, but found at indices ${firstIndex}, ${secondIndex}.`)
}
}
},
{
name: 'feed shows N posts',
pattern: /^the feed shows (\d+) posts$/,
run (m, example, world) {
const expected = parseInt(m[1], 10)
const actual = world.followingFeedPage.posts.length
if (actual !== expected) {
throw new Error(`Expected ${expected} posts in following feed, got ${actual}.`)
}
}
},
{
name: 'feed can load more posts',
pattern: /^the feed can load more posts$/,
run (m, example, world) {
if (!world.followingFeedPage.canLoadMore()) {
throw new Error('Expected following feed to have more posts, but pagination says there are none.')
}
}
},
{
name: 'feed shows no posts',
pattern: /^the feed shows no posts$/,
run (m, example, world) {
if (world.followingFeedPage.posts.length !== 0) {
throw new Error(`Expected no posts in following feed, got ${world.followingFeedPage.posts.length}.`)
}
}
},
{
name: 'feed shows not following anyone message',
pattern: /^the feed shows a message that I am not following anyone$/,
run (m, example, world) {
if (!world.followingFeedPage.emptyBecauseNoFollows) {
throw new Error('Expected following feed to show the not-following-anyone message.')
}
}
},
{
name: 'API serves reply to my post by address with text',
pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) by the address (.+) with text (.+?)(?: at block height (\d+))?$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const parentTxid = resolveParam(m[2], example)
const addr = resolveParam(m[3], example)
const text = resolveText(m[4], example)
const blockHeight = m[5] ? parseInt(m[5], 10) : 100
world.memoDb.addReply({ txid, parentTxid, text, addr, blockHeight })
}
},
{
name: 'API serves reply to my post by me with text',
pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) by my wallet address with text (.+?)(?: at block height (\d+))?$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const parentTxid = resolveParam(m[2], example)
const text = resolveText(m[3], example)
const blockHeight = m[4] ? parseInt(m[4], 10) : 100
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.addReply({ txid, parentTxid, text, addr: myAddr, blockHeight })
}
},
{
name: 'API serves like on my post by address',
pattern: /^the psf-memo-db API serves a like with txid (.+) on the post with txid (.+) by the address (.+?)(?: at block height (\d+))?$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const postTxid = resolveParam(m[2], example)
const addr = resolveParam(m[3], example)
const blockHeight = m[4] ? parseInt(m[4], 10) : 100
world.memoDb.addLike({ txid, postTxid, addr, blockHeight })
}
},
{
name: 'API records address follows me',
pattern: /^the psf-memo-db API records that the address (.+) follows my wallet address$/,
run (m, example, world) {
const followerAddr = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.addFollower(followerAddr, myAddr)
}
},
{
name: 'open notifications page',
pattern: /^I open the Notifications page$/,
async run (m, example, world) {
await world.notificationsPage.load()
world.currentPath = NotificationsPage.NOTIFICATIONS_PATH
}
},
{
name: 'open notifications page with page size',
pattern: /^I open the Notifications page with page size (\d+)$/,
async run (m, example, world) {
const limit = parseInt(m[1], 10)
await world.notificationsPage.load({ limit })
world.currentPath = NotificationsPage.NOTIFICATIONS_PATH
}
},
{
name: 'notifications include reply notification',
pattern: /^the notifications include a reply notification from the address (.+) with text (.+)$/,
run (m, example, world) {
const expectedAddr = resolveParam(m[1], example)
const expectedText = resolveText(m[2], example)
const found = world.notificationsPage.notifications.find((n) =>
n.type === 'reply' && n.addr === expectedAddr && n.text === expectedText
)
if (!found) {
throw new Error(`Notifications do not include a reply from ${expectedAddr} with text "${expectedText}".`)
}
}
},
{
name: 'notifications do not include reply notification',
pattern: /^the notifications do not include a reply notification from the address (.+)$/,
run (m, example, world) {
const expectedAddr = resolveParam(m[1], example)
const found = world.notificationsPage.notifications.find((n) =>
n.type === 'reply' && n.addr === expectedAddr
)
if (found) {
throw new Error(`Notifications unexpectedly include a reply from ${expectedAddr}.`)
}
}
},
{
name: 'notifications include like notification',
pattern: /^the notifications include a like notification from the address (.+)$/,
run (m, example, world) {
const expectedAddr = resolveParam(m[1], example)
const found = world.notificationsPage.notifications.find((n) =>
n.type === 'like' && n.addr === expectedAddr
)
if (!found) {
throw new Error(`Notifications do not include a like from ${expectedAddr}.`)
}
}
},
{
name: 'notifications include follow notification',
pattern: /^the notifications include a follow notification from the address (.+)$/,
run (m, example, world) {
const expectedAddr = resolveParam(m[1], example)
const found = world.notificationsPage.notifications.find((n) =>
n.type === 'follow' && n.addr === expectedAddr
)
if (!found) {
throw new Error(`Notifications do not include a follow from ${expectedAddr}.`)
}
}
},
{
name: 'notifications show like before reply',
pattern: /^the notifications show the like notification before the reply notification$/,
run (m, example, world) {
const notifications = world.notificationsPage.notifications
const likeIndex = notifications.findIndex((n) => n.type === 'like')
const replyIndex = notifications.findIndex((n) => n.type === 'reply')
if (likeIndex === -1) throw new Error('Notifications do not include a like notification.')
if (replyIndex === -1) throw new Error('Notifications do not include a reply notification.')
if (likeIndex >= replyIndex) {
throw new Error('Expected like notification to appear before reply notification.')
}
}
},
{
name: 'notifications show N notifications',
pattern: /^the notifications show (\d+) notification$/,
run (m, example, world) {
const expected = parseInt(m[1], 10)
const actual = world.notificationsPage.notifications.length
if (actual !== expected) {
throw new Error(`Expected ${expected} notifications, got ${actual}.`)
}
}
},
{
name: 'notifications can load more',
pattern: /^the notifications can load more$/,
run (m, example, world) {
if (!world.notificationsPage.canLoadMore()) {
throw new Error('Expected notifications to have more pages, but pagination says there are none.')
}
}
},
{
name: 'notifications include no notifications',
pattern: /^the notifications include no notifications$/,
run (m, example, world) {
if (world.notificationsPage.notifications.length !== 0) {
throw new Error(`Expected no notifications, got ${world.notificationsPage.notifications.length}.`)
}
}
},
{
name: 'notifications show no notifications message',
pattern: /^the notifications show a message that I have no notifications$/,
run (m, example, world) {
if (!world.notificationsPage.empty) {
throw new Error('Expected notifications page to show the no-notifications message.')
}
}
},
{
name: 'API serves display name for address',
pattern: /^the psf-memo-db API serves the display name "([^"]*)" for the address (.+)$/,
run (m, example, world) {
const name = resolveParam(m[1], example)
const addr = resolveParam(m[2], example)
world.memoDb.addProfileName(addr, name)
}
},
{
name: 'API serves avatar for address',
pattern: /^the psf-memo-db API serves the avatar "([^"]*)" for the address (.+)$/,
run (m, example, world) {
const avatar = resolveParam(m[1], example)
const addr = resolveParam(m[2], example)
world.memoDb.addProfilePic(addr, avatar)
}
},
{
name: 'API fails to serve profile for address',
pattern: /^the psf-memo-db API fails to serve the profile for the address (.+)$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
world.memoDb.addProfileFailure(addr)
}
},
{
name: 'notification entry shows display name',
pattern: /^the notification entry from the address (.+) shows the display name "([^"]*)"$/,
run (m, example, world) {
assertEntryShows(world, resolveParam(m[1], example), 'displayName', resolveParam(m[2], example))
}
},
{
name: 'notification entry shows avatar',
pattern: /^the notification entry from the address (.+) shows the avatar "([^"]*)"$/,
run (m, example, world) {
const expected = resolveParam(m[2], example)
assertEntryShows(world, resolveParam(m[1], example), 'avatarUrl', expected, `src="${expected}"`)
}
},
{
name: 'notification entry shows address as plain text',
pattern: /^the notification entry from the address (.+) shows the address (.+) as plain text$/,
run (m, example, world) {
const shown = resolveParam(m[2], example)
assertEntryShows(world, resolveParam(m[1], example), 'addr', shown, `notification-entry-address">${shown}<`)
}
},
{
name: 'notification entry links avatar to profile',
pattern: /^the notification entry from the address (.+) links the avatar to "([^"]*)"$/,
run (m, example, world) {
assertEntryProfileLink(world, resolveParam(m[1], example), resolveParam(m[2], example),
'notification-entry-avatar-link', 'avatar')
}
},
{
name: 'notification entry links display name to profile',
pattern: /^the notification entry from the address (.+) links the display name to "([^"]*)"$/,
run (m, example, world) {
assertEntryProfileLink(world, resolveParam(m[1], example), resolveParam(m[2], example),
'notification-entry-name-link', 'display name')
}
},
{
name: 'notification entry offers View Post link',
pattern: /^the notification entry from the address (.+) offers a "View Post" link$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const entry = notificationEntryFor(world, addr)
if (!entry.showViewPost) {
throw new Error(`Expected the notification entry from ${addr} to offer a View Post link.`)
}
const html = renderNotificationEntry(entry)
const anchor = anchorsIn(html).find((a) => a.attrs.includes('notification-entry-view-post'))
if (!anchor || !anchor.text.includes(VIEW_POST_LABEL)) {
throw new Error('Rendered notification entry does not offer a View Post link.')
}
}
},
{
name: 'notification entry does not offer View Post link',
pattern: /^the notification entry from the address (.+) does not offer a "View Post" link$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const entry = notificationEntryFor(world, addr)
if (entry.showViewPost) {
throw new Error(`Expected the notification entry from ${addr} not to offer a View Post link.`)
}
if (renderNotificationEntry(entry).includes(VIEW_POST_LABEL)) {
throw new Error('Rendered notification entry unexpectedly offers a View Post link.')
}
}
},
{
name: 'notification entry shows reply text',
pattern: /^the notification entry from the address (.+) shows the reply text "([^"]*)"$/,
run (m, example, world) {
assertEntryShows(world, resolveParam(m[1], example), 'text', resolveParam(m[2], example))
}
},
{
name: 'notification entry shows identicon avatar',
pattern: /^the notification entry from the address (.+) shows an identicon avatar$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const entry = notificationEntryFor(world, addr)
if (entry.avatarUrl) {
throw new Error(`Expected the notification entry from ${addr} to fall back to an identicon, got avatar "${entry.avatarUrl}".`)
}
const html = renderNotificationEntry(entry)
if (!html.includes('notification-entry-identicon') || !html.includes('data-jdenticon-value')) {
throw new Error('Rendered notification entry does not show an identicon avatar.')
}
}
},
{
name: 'click View Post link in notification',
pattern: /^I click the "View Post" link in the notification from the address (.+)$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const entry = notificationEntryFor(world, addr)
if (!entry.showViewPost) {
throw new Error(`The notification entry from ${addr} does not offer a View Post link.`)
}
if (!entry.postTxid) {
throw new Error(`The notification entry from ${addr} has no referenced post txid.`)
}
world.thread.rootTxid = entry.postTxid
world.replyPage.setParent(entry.postTxid)
}
},
{
name: 'API serves N recent posts',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) recent posts$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
for (let i = 0; i < count; i++) {
world.memoDb.addPost({
txid: `recent-post-${i + 1}`.padEnd(64, '0'),
addr: `addr-${i + 1}`,
text: `Recent post ${i + 1}`,
blockHeight: 100 + i
})
}
}
},
{
name: 'API serves N posts by address',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) posts authored by the address (.+)$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
const addr = resolveParam(m[2], example)
for (let i = 0; i < count; i++) {
world.memoDb.addPost({
txid: `${addr}-post-${i + 1}`.padEnd(64, '0'),
addr,
text: `Post ${i + 1}`,
blockHeight: 100 + i
})
}
}
},
{
name: 'API serves N posts in topic',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) posts in the topic (.+)$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
const room = resolveParam(m[2], example)
for (let i = 0; i < count; i++) {
world.memoDb.addTopicPost(room, {
txid: `${room}-post-${i + 1}`.padEnd(64, '0'),
addr: `addr-${i + 1}`,
text: `Topic post ${i + 1}`,
blockHeight: 100 + i
})
}
}
},
{
name: 'API serves N replies to post',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) replies to the post with txid (.+)$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
const parentTxid = resolveParam(m[2], example)
for (let i = 0; i < count; i++) {
world.memoDb.addReply({
txid: `reply-${i + 1}`.padEnd(64, '0'),
parentTxid,
text: `Reply ${i + 1}`,
addr: 'bitcoincash:reply-author',
blockHeight: 100 + i
})
}
}
},
{
name: 'API serves N search posts',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) search posts matching (.+)$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
const query = resolveParam(m[2], example)
for (let i = 0; i < count; i++) {
world.memoDb.addSearchPost({
txid: `search-post-${i + 1}`.padEnd(64, '0'),
addr: `addr-${i + 1}`,
text: `${query} search result ${i + 1}`,
blockHeight: 100 + i
})
}
}
},
{
name: 'API serves N profiles',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) profiles$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
for (let i = 0; i < count; i++) {
world.memoDb.profiles.push({
addr: `profile-addr-${i + 1}`,
text: `Profile ${i + 1}`,
txid: `profile-txid-${i + 1}`.padEnd(64, '0'),
blockHeight: 100 + i,
seen: Date.now()
})
}
}
},
{
name: 'recent feed shows 50 posts',
pattern: /^the recent feed shows 50 posts$/,
run (m, example, world) {
const actual = world.recentFeedPage.posts.length
if (actual !== 50) {
throw new Error(`Expected 50 posts in recent feed, got ${actual}.`)
}
}
},
{
name: 'recent feed can load more posts',
pattern: /^the recent feed can load more posts$/,
run (m, example, world) {
if (!world.recentFeedPage.canLoadMore || !world.recentFeedPage.canLoadMore()) {
throw new Error('Expected recent feed to have more posts, but pagination says there are none.')
}
}
},
{
name: 'following feed shows 50 posts',
pattern: /^the following feed shows 50 posts$/,
run (m, example, world) {
const actual = world.followingFeedPage.posts.length
if (actual !== 50) {
throw new Error(`Expected 50 posts in following feed, got ${actual}.`)
}
}
},
{
name: 'following feed can load more posts',
pattern: /^the following feed can load more posts$/,
run (m, example, world) {
if (!world.followingFeedPage.canLoadMore()) {
throw new Error('Expected following feed to have more posts, but pagination says there are none.')
}
}
},
{
name: 'topic feed shows 50 posts',
pattern: /^the topic feed shows 50 posts$/,
run (m, example, world) {
const actual = world.topicFeedPage.posts.length
if (actual !== 50) {
throw new Error(`Expected 50 posts in topic feed, got ${actual}.`)
}
}
},
{
name: 'topic feed can load more posts',
pattern: /^the topic feed can load more posts$/,
run (m, example, world) {
if (!world.topicFeedPage.canLoadMore()) {
throw new Error('Expected topic feed to have more posts, but pagination says there are none.')
}
}
},
{
name: 'notifications show 50 notifications',
pattern: /^the notifications show 50 notifications$/,
run (m, example, world) {
const actual = world.notificationsPage.notifications.length
if (actual !== 50) {
throw new Error(`Expected 50 notifications, got ${actual}.`)
}
}
},
{
name: 'search results show 50 posts',
pattern: /^the search results show 50 posts$/,
run (m, example, world) {
const actual = world.searchPage.posts.length
if (actual !== 50) {
throw new Error(`Expected 50 posts in search results, got ${actual}.`)
}
}
},
{
name: 'search results can load more posts',
pattern: /^the search results can load more posts$/,
run (m, example, world) {
if (!world.searchPage.canLoadMore || !world.searchPage.canLoadMore()) {
throw new Error('Expected search results to have more pages, but pagination says there are none.')
}
}
},
{
name: 'profile page shows 50 posts',
pattern: /^the profile page shows 50 posts$/,
run (m, example, world) {
const actual = world.profilePage.posts.length
if (actual !== 50) {
throw new Error(`Expected 50 posts on profile page, got ${actual}.`)
}
}
},
{
name: 'profile page can load more posts',
pattern: /^the profile page can load more posts$/,
run (m, example, world) {
if (!world.profilePage.canLoadMore || !world.profilePage.canLoadMore()) {
throw new Error('Expected profile page to have more posts, but pagination says there are none.')
}
}
},
{
name: 'recent profiles page shows 50 profiles',
pattern: /^the recent profiles page shows 50 profiles$/,
run (m, example, world) {
const actual = world.recentProfilesPage.profiles.length
if (actual !== 50) {
throw new Error(`Expected 50 profiles on recent profiles page, got ${actual}.`)
}
}
},
{
name: 'recent profiles page can load more profiles',
pattern: /^the recent profiles page can load more profiles$/,
run (m, example, world) {
if (!world.recentProfilesPage.canLoadMore()) {
throw new Error('Expected recent profiles page to have more profiles, but pagination says there are none.')
}
}
},
{
name: 'open recent profiles page',
pattern: /^I open the recent profiles page$/,
async run (m, example, world) {
await world.recentProfilesPage.load()
world.currentPath = RecentProfilesPage.RECENT_PROFILES_PATH
}
},
{
name: 'API serves recent profiles fixture',
pattern: /^the psf-memo-db API serves the recent profiles fixture "([^"]+)"$/,
run (m, example, world) {
loadRecentProfilesFixture(world, m[1])
}
},
{
name: 'recent profiles account shows identity field',
pattern: /^the recent profiles account for the address (.+) shows the (display name|avatar) "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const field = m[2] === 'display name' ? 'displayName' : 'avatarUrl'
const expected = resolveParam(m[3], example)
const account = recentProfileAccountFor(world, addr)
const actual = account[field] ?? null
if (actual !== expected) {
throw new Error(`Expected the recent profiles account for ${addr} to show ${m[2]} "${expected}", got "${actual}".`)
}
const html = renderRecentProfileAccount(account)
const rendered = m[2] === 'avatar' ? `src="${expected}"` : `>${expected}<`
if (!html.includes(rendered)) {
throw new Error(`Rendered recent profiles account does not show ${m[2]} "${expected}".`)
}
}
},
{
name: 'recent profiles account shows identicon',
pattern: /^the recent profiles account for the address (.+) shows an identicon avatar$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const account = recentProfileAccountFor(world, addr)
if (account.avatarUrl) {
throw new Error(`Expected the recent profiles account for ${addr} to fall back to an identicon, got avatar "${account.avatarUrl}".`)
}
const html = renderRecentProfileAccount(account)
if (!html.includes('recent-profile-identicon') || !html.includes('data-jdenticon-value')) {
throw new Error('Rendered recent profiles account does not show an identicon avatar.')
}
}
},
{
name: 'recent profiles account links identity to profile',
pattern: /^the recent profiles account for the address (.+) links the (avatar|display name) to "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const linkField = m[2]
const expected = resolveParam(m[3], example)
const account = recentProfileAccountFor(world, addr)
if (account.profilePath !== expected) {
throw new Error(`Expected the recent profiles account for ${addr} to link the ${linkField} to "${expected}", got "${account.profilePath}".`)
}
const className = linkField === 'avatar' ? 'recent-profile-avatar-link' : 'recent-profile-name-link'
const href = anchorHref(renderRecentProfileAccount(account), className)
if (href !== expected) {
throw new Error(`Rendered recent profiles ${linkField} does not link to ${expected}.`)
}
}
},
{
name: 'recent profiles table has column headers',
pattern: /^the recent profiles table has the column headers (.+)$/,
run (m, example, world) {
const expected = [...m[1].matchAll(/"([^"]*)"/g)].map((match) => match[1])
const table = buildRecentProfilesTable(world.recentProfilesPage.profiles)
if (table.headers.join('|') !== expected.join('|')) {
throw new Error(`Expected recent profiles headers ${expected.join(', ')}, got ${table.headers.join(', ')}.`)
}
}
},
{
name: 'open profile page for address',
pattern: /^I open the profile page for the address (.+)$/,
async run (m, example, world) {
const addr = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.profilePage = new ProfilePage({
memoDb: world.memoDb,
addr,
myAddr,
memoFollow: world.memoFollow,
memoMute: world.memoMute
})
await world.profilePage.load()
world.currentPath = `${ProfilePage.PROFILE_PATH_PREFIX}/${encodeURIComponent(addr)}`
}
},
{
name: 'open topic feed for topic',
pattern: /^I open the topic feed for (.+)$/,
async run (m, example, world) {
const room = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.topicFeedPage = new TopicFeedPage({
memoDb: world.memoDb,
room,
myAddr,
memoTopicFollow: world.memoTopicFollow
})
await world.topicFeedPage.load()
world.currentPath = TopicFeedPage.topicFeedPath(room)
}
},
{
name: 'feed shows embedded YouTube player',
pattern: /^the feed shows an embedded YouTube player for the video (.+)$/,
run (m, example, world) {
const videoId = resolveParam(m[1], example)
const rendered = getRenderedFeed(world)
const needle = `${YOUTUBE_EMBED_BASE_URL}/${videoId}`
const found = rendered.some((html) => html.includes(needle))
if (!found) {
throw new Error(`Feed does not show an embedded YouTube player for ${videoId}.`)
}
}
},
{
name: 'feed shows a link that opens in a new tab',
pattern: /^the feed shows a link to (.+) that opens in a new tab$/,
run (m, example, world) {
const href = resolveParam(m[1], example)
const rendered = getRenderedFeed(world)
const found = rendered.some((html) =>
anchorsIn(html).some((anchor) =>
anchor.attrs.includes(`href="${href}"`) &&
anchor.attrs.includes('target="_blank"')
)
)
if (!found) {
throw new Error(`Feed does not show a link to ${href} that opens in a new tab.`)
}
}
},
{
name: 'feed shows a link with the text',
pattern: /^the feed shows a link with the text (.+)$/,
run (m, example, world) {
const label = resolveParam(m[1], example)
const rendered = getRenderedFeed(world)
const found = rendered.some((html) =>
anchorsIn(html).some((anchor) => anchor.text === label)
)
if (!found) {
throw new Error(`Feed does not show a link with the text "${label}".`)
}
}
},
{
name: 'feed shows no link',
pattern: /^the feed shows no link$/,
run (m, example, world) {
assertFeedHasNoElement(world, anchorsIn, 'Feed unexpectedly shows a link.')
}
},
{
name: 'feed does not show raw URL',
pattern: /^the feed does not show the raw URL (.+)$/,
run (m, example, world) {
const url = resolveText(m[1], example)
const rendered = getRenderedFeed(world)
const found = rendered.some((html) => html.includes(url))
if (found) {
throw new Error(`Feed unexpectedly shows the raw URL ${url}.`)
}
}
},
{
name: 'feed shows text',
pattern: /^the feed shows the text (.+)$/,
run (m, example, world) {
const expected = resolveText(m[1], example)
const rendered = getRenderedFeed(world)
const found = rendered.some((html) => html.replace(/<[^\u003e]+>/g, '').includes(expected))
if (!found) {
throw new Error(`Feed does not show the text "${expected}".`)
}
}
},
{
name: 'feed does not show embedded video player',
pattern: /^the feed does not show an embedded video player$/,
run (m, example, world) {
const rendered = getRenderedFeed(world)
const found = rendered.some((html) => html.includes('<iframe'))
if (found) {
throw new Error('Feed unexpectedly shows an embedded video player.')
}
}
},
{
name: 'feed shows an image',
pattern: /^the feed shows an image with the URL (.+) and alt text (.+)$/,
run (m, example, world) {
const url = resolveParam(m[1], example)
const alt = resolveParam(m[2], example)
const rendered = getRenderedFeed(world)
const found = rendered.some((html) =>
imagesIn(html).some((image) =>
image.attrs.includes(`src="${url}"`) &&
image.attrs.includes(`alt="${alt}"`)
)
)
if (!found) {
throw new Error(`Feed does not show an image with URL ${url} and alt text "${alt}".`)
}
}
},
{
name: 'feed shows no image',
pattern: /^the feed shows no image$/,
run (m, example, world) {
assertFeedHasNoElement(world, imagesIn, 'Feed unexpectedly shows an image.')
}
},
{
name: 'feed does not show the URL as text',
pattern: /^the feed does not show the URL (.+) as text$/,
run (m, example, world) {
const url = resolveParam(m[1], example)
const rendered = getRenderedFeed(world)
const found = rendered.some((html) =>
html.replace(/<[^>]+>/g, '').includes(url)
)
if (found) {
throw new Error(`Feed unexpectedly shows the URL ${url} as text.`)
}
}
},
{
name: 'image fails to load',
pattern: /^the image at (.+) fails to load$/,
run (m, example, world) {
const url = resolveParam(m[1], example)
world.failedImages = new Set([...(world.failedImages || []), url])
world.renderedFeed = world.recentFeedPage.posts.map((post) =>
renderPostText(post.text, { initialFailedImages: [...world.failedImages] })
)
}
},
{
name: 'page shows a post options button for the post',
pattern: /^the page shows a post options button for the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const post = findPostOnCurrentPage(txid, world)
if (!post) {
throw new Error(`The displayed page does not show a post with txid ${txid}.`)
}
const menu = getPostOptionsMenu(world, txid)
const html = renderPostOptions(txid, { open: menu.open })
if (!html.includes('aria-label="Post options"')) {
throw new Error(`Post ${txid} does not render a post options button.`)
}
}
},
{
name: 'post options menu is hidden',
pattern: /^the post options menu is hidden for the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const menu = getPostOptionsMenu(world, txid)
if (menu.open) {
throw new Error(`Post options menu for ${txid} is open, but it should be hidden.`)
}
const html = renderPostOptions(txid, { open: menu.open })
if (html.includes(PostOptions.BLOCK_EXPLORER_LABEL)) {
throw new Error(`Post options menu for ${txid} renders items while hidden.`)
}
}
},
{
name: 'click post options button',
pattern: /^I click the post options button for the post with txid (.+)$/,
run (m, example, world) {
togglePostOptionsMenu(world, resolveParam(m[1], example))
}
},
{
name: 'click post options button again',
pattern: /^I click the post options button again for the post with txid (.+)$/,
run (m, example, world) {
togglePostOptionsMenu(world, resolveParam(m[1], example))
}
},
{
name: 'post options menu is shown',
pattern: /^the post options menu is shown for the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const menu = getPostOptionsMenu(world, txid)
if (!menu.open) {
throw new Error(`Post options menu for ${txid} is hidden, but it should be shown.`)
}
}
},
{
name: 'first post options menu item label',
pattern: /^the first item in the post options menu is "(.+)"$/,
run (m, example, world) {
const label = m[1]
const items = PostOptions.postOptionsItems(world.activeMenuTxid)
if (!items[0] || items[0].label !== label) {
throw new Error(`Expected the first post options item to be "${label}".`)
}
}
},
{
name: 'first post options menu item links in new tab',
pattern: /^the first post options menu item links to (.+) and opens in a new tab$/,
run (m, example, world) {
const href = resolveParam(m[1], example)
const txid = world.activeMenuTxid
const first = PostOptions.postOptionsItems(txid)[0]
if (!first || first.href !== href) {
throw new Error(`Expected the first post options item to link to ${href}.`)
}
if (first.target !== '_blank') {
throw new Error('Expected the first post options item to open in a new tab.')
}
const html = renderPostOptions(txid, { open: true, focusedIndex: 0 })
if (!html.includes(`href="${href}"`) || !html.includes('target="_blank"')) {
throw new Error(`The rendered post options menu does not link to ${href} in a new tab.`)
}
if (!html.includes(PostOptions.BLOCK_EXPLORER_LABEL)) {
throw new Error('The rendered post options menu does not show the block explorer item.')
}
}
},
{
name: 'click outside post options menu',
pattern: /^I click outside the post options menu$/,
run (m, example, world) {
transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsOutsideClick)
}
},
{
name: 'press Escape key',
pattern: /^I press the Escape key$/,
run (m, example, world) {
transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsEscape)
}
},
{
name: 'press ArrowDown key',
pattern: /^I press the ArrowDown key$/,
run (m, example, world) {
const txid = world.activeMenuTxid
transitionActivePostOptionsMenu(world, (menu) =>
PostOptions.focusFirstPostOption(menu, PostOptions.postOptionsItems(txid))
)
}
},
{
name: 'first post options menu item has focus',
pattern: /^the first post options menu item has focus$/,
run (m, example, world) {
const txid = world.activeMenuTxid
const menu = getPostOptionsMenu(world, txid)
if (menu.focusedIndex !== 0) {
throw new Error('Expected the first post options item to have focus.')
}
const html = renderPostOptions(txid, { open: true, focusedIndex: 0 })
if (!html.includes('tabindex="0"')) {
throw new Error('The rendered first post options item is not focusable.')
}
}
}
]
// Find the post options menu state for a txid, creating a closed one on first use.
function getPostOptionsMenu (world, txid) {
if (!world.postOptionsMenus) world.postOptionsMenus = {}
if (!world.postOptionsMenus[txid]) {
world.postOptionsMenus[txid] = { txid, ...PostOptions.initialPostOptionsState() }
}
return world.postOptionsMenus[txid]
}
// Apply a post options state transition to the active menu.
function transitionActivePostOptionsMenu (world, transition) {
const menu = getPostOptionsMenu(world, world.activeMenuTxid)
Object.assign(menu, transition(menu))
}
// Toggle a post's options menu and make it the active menu.
function togglePostOptionsMenu (world, txid) {
const menu = getPostOptionsMenu(world, txid)
Object.assign(menu, PostOptions.togglePostOptions(menu))
world.activeMenuTxid = txid
}
// Broadcast result plumbing shared by the like/tip and mute acceptance
// assertions. Each feature names the world field holding its page, the page
// fields/getters for the visible result, and the component renderer.
const BROADCAST_RESULTS = {
like: {
page: (world) => world.likeTipPage,
showField: 'showResultModal',
resultField: 'lastResult',
getMessage: (page) => page.getBroadcastMessage(),
render: renderLikeResult
},
mute: {
page: (world) => world.profilePage,
showField: 'showMuteResultModal',
resultField: 'lastMuteResult',
getMessage: (page) => page.getMuteBroadcastMessage(),
render: renderMuteResult
}
}
// Require a successful broadcast result for `feature` and render it to static
// HTML for the acceptance assertions. The caller inspects the returned fields.
function renderBroadcastResult (world, feature) {
const spec = BROADCAST_RESULTS[feature]
const page = spec.page(world)
if (!page || !page[spec.showField] || !page[spec.resultField] || !page[spec.resultField].ok) {
throw new Error(`Expected a successful ${feature} broadcast result.`)
}
const txid = page[spec.resultField].txid
if (!txid) {
throw new Error(`Expected the ${feature} result to include a transaction id.`)
}
const message = spec.getMessage(page)
if (!message) {
throw new Error(`Expected a ${feature} broadcast success message.`)
}
const url = page.explorerUrl(txid)
const html = spec.render({ txid, message, explorerUrl: url })
return { txid, message, url, html }
}
// Assert the rendered broadcast result shows its success message.
function assertBroadcastResultMessage (world, feature) {
const { message, html } = renderBroadcastResult(world, feature)
if (!html.includes(message)) {
throw new Error(`The rendered ${feature} result does not show the message "${message}".`)
}
}
// Assert the rendered broadcast result shows its transaction id.
function assertBroadcastResultTxid (world, feature) {
const { txid, html } = renderBroadcastResult(world, feature)
if (!html.includes(txid)) {
throw new Error(`The rendered ${feature} result does not show the transaction id ${txid}.`)
}
}
// Assert the rendered broadcast result links to the block explorer in a new
// tab.
function assertBroadcastResultExplorerLink (world, feature) {
const { url, html } = renderBroadcastResult(world, feature)
if (!url.startsWith(`${ProfilePage.EXPLORER_TX_BASE}/`)) {
throw new Error(`Expected a bch.loping.net explorer link, got "${url}".`)
}
if (!html.includes(`href="${url}"`)) {
throw new Error(`The rendered ${feature} result does not link to ${url}.`)
}
if (!html.includes('target="_blank"')) {
throw new Error(`The rendered ${feature} explorer link does not open in a new tab.`)
}
}
// Assert that the wallet's most recent broadcast carries the Memo `label`
// prefix and the 20-byte hash160 payload for `addr`.
function assertMemoBroadcastPrefix (world, addr, prefix, label) {
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== prefix) {
throw new Error(`Expected Memo ${label} prefix ${prefix}, got "${last.prefix}".`)
}
if (last.msg.toString('hex') !== hash160) {
throw new Error(`Broadcast ${label} hash160 did not match ${addr}.`)
}
}
// The posts currently rendered by the page the scenario has opened.
function postsOnCurrentPage (world) {
const path = world.currentPath || ''
if (world.threadPage && world.threadPage.rootPost) return world.threadPage.allPosts || []
if (path.startsWith(ProfilePage.PROFILE_PATH_PREFIX)) return world.profilePage?.posts || []
if (path.startsWith('/topics/')) return world.topicFeedPage?.posts || []
if (path === FollowingFeedPage.FOLLOWING_FEED_PATH) return world.followingFeedPage?.posts || []
return world.recentFeedPage?.posts || []
}
function findPostOnCurrentPage (txid, world) {
return postsOnCurrentPage(world).find((post) => post && post.txid === txid) || null
}
// Return the cached rendered feed HTML, computing it on first use.
function getRenderedFeed (world) {
if (!world.renderedFeed) {
world.renderedFeed = world.recentFeedPage.posts.map((post) => renderPostText(post.text))
}
return world.renderedFeed
}
// Fail when any rendered feed HTML contains an element matched by extract.
function assertFeedHasNoElement (world, extract, message) {
const rendered = getRenderedFeed(world)
const found = rendered.some((html) => extract(html).length > 0)
if (found) {
throw new Error(message)
}
}
// Extract the anchors from a rendered HTML string. The acceptance adapter
// renders a small, controlled HTML subset, so a regex match is sufficient.
function anchorsIn (html) {
const anchors = []
const re = /<a\s([^>]*)>([\s\S]*?)<\/a>/g
let match
while ((match = re.exec(html)) !== null) {
anchors.push({ attrs: match[1], text: match[2] })
}
return anchors
}
// The href of the first rendered anchor carrying the given class, or null.
function anchorHref (html, className) {
const anchor = anchorsIn(html).find((a) => a.attrs.includes(className))
if (!anchor) return null
const match = /href="([^"]*)"/.exec(anchor.attrs)
return match ? match[1] : null
}
// The built view model for the notification whose actor is `addr`.
function notificationEntryFor (world, addr) {
const entry = world.notificationsPage.getEntryByAddr(addr)
if (!entry) {
throw new Error(`No notification entry from the address ${addr}.`)
}
return entry
}
// Assert that a notification entry exposes `field` as `expected` in its view
// model and that the rendered markup contains `rendered` (defaulting to the
// expected value itself).
function assertEntryShows (world, addr, field, expected, rendered = expected) {
const entry = notificationEntryFor(world, addr)
if (entry[field] !== expected) {
throw new Error(`Expected the notification entry from ${addr} to show ${field} "${expected}", got "${entry[field]}".`)
}
if (!renderNotificationEntry(entry).includes(rendered)) {
throw new Error(`Rendered notification entry does not show ${field} "${expected}".`)
}
}
// Assert that a notification entry links the anchor carrying `className` to the
// expected profile path, both in the view model and the rendered markup.
function assertEntryProfileLink (world, addr, expected, className, label) {
const entry = notificationEntryFor(world, addr)
if (entry.profilePath !== expected) {
throw new Error(`Expected the notification entry from ${addr} to link the ${label} to "${expected}", got "${entry.profilePath}".`)
}
const href = anchorHref(renderNotificationEntry(entry), className)
if (href !== expected) {
throw new Error(`Rendered notification ${label} does not link to ${expected}.`)
}
}
// Extract the image tags from a rendered HTML string. The acceptance adapter
// renders a small, controlled HTML subset, so a regex match is sufficient.
function imagesIn (html) {
const images = []
const re = /<img\s([^>]*?)\/?>/g
let match
while ((match = re.exec(html)) !== null) {
images.push({ attrs: match[1] })
}
return images
}
// Decode a raw create-poll payload into poll_type, option_count, and question.
function decodeCreatePollPayload (raw) {
const buf = Buffer.from(raw)
const pollType = buf[0]
const optionCount = buf[1]
const question = buf.slice(2).toString('utf8')
return { pollType, optionCount, question }
}
// Decode a raw add-poll-option or poll-vote payload into poll txid (hex).
function decodePollTxidPayload (raw) {
const buf = Buffer.from(raw)
const pollTxid = Buffer.from(buf.slice(0, 32)).reverse().toString('hex')
return { pollTxid }
}
// Route a single step to its handler. Throws on unsupported step text. Throws on unsupported step text.
async function handleStep (step, example, world) {
for (const handler of handlers) {
const match = handler.pattern.exec(step.text)
if (match) {
await handler.run(match, example, world, step)
return
}
}
throw new Error(`Unsupported step: ${step.keyword} ${step.text}`)
}
module.exports = { createWorld, handleStep }