flotilla/e2e/specs/community.spec.ts
2026-09-29 09:00:52 -07:00

862 lines
30 KiB
TypeScript

import * as nip19 from "nostr-tools/nip19"
import {DAY, HOUR, MINUTE, bech32ToHex, int} from "@welshman/lib"
import {
MESSAGE,
POLL_RESPONSE,
getAddress,
getLnUrl,
tagSpec,
tagValues,
toMsats,
} from "@welshman/util"
import type {SignedEvent} from "@welshman/util"
import {ClientMessageType} from "@welshman/net"
import {
Pin,
Pinboard,
Poll,
PollResponse,
Profile,
TimeEvent,
ZapGoal,
ZapReceipt,
ZapRequest,
} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {
GIF,
dialog,
emojiButton,
expect,
getTranscript,
longDate,
makeTestUser,
menuButton,
mockDufflepud,
modalForm,
noteEditor,
pickEmoji,
roomPath,
spacePath,
test,
users,
} from "../harness"
import type {TestUser} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string; readonly event: SignedEvent}
// A shelf is a card and its menu button side by side, so the menu is reached through their wrapper.
const shelfCard = (page: Page, title: string) =>
page.getByRole("button", {name: new RegExp(title)}).locator("xpath=..")
// Button swallows both the default and the propagation of a click, and the middle of a card is one.
const openCard = (card: Locator, title: string) => card.getByText(title).click()
// PollOption renders an option as a small card carrying its label, its count and its progress bar.
const pollOption = (page: Page, label: string) => page.locator(".card-sm").filter({hasText: label})
// A multiple choice vote goes out once the delay window closes, so "both publish" is visible here.
const pollResponses = (page: Page, pollId: string) =>
getTranscript(page.context())
.filter(
({direction, message}) =>
direction === "toRelay" &&
message[0] === ClientMessageType.Event &&
message[1].kind === POLL_RESPONSE &&
tagValues(tagSpec("e"), message[1].tags).includes(pollId),
)
.map(({message}) => tagValues(tagSpec("response"), message[1].tags))
const PARTY = "🎉"
const pickParty = (page: Page, opener: Locator) => pickEmoji(page, opener, "party popper")
test("US-046 create and browse a calendar event", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
// at() counts backwards from the moment the test started, so a negative count is the future.
space.event(
user.bob,
() =>
space
.kind(TimeEvent)
.writer()
.setIdentifier("autumn-fair")
.setTitle("Autumn Fair")
.setStart(at(-3, DAY))
.setEnd(at(-3, DAY) + int(1, HOUR))
.setContent("Autumn Fair happens here.")
.renderTemplate(),
at(1, HOUR),
)
})
const {url} = scenario.space("space")
const page = await as(users.alice, `${spacePath(url)}/calendar`)
await page.getByRole("button", {name: "Agenda", exact: true}).click()
const card = (title: string) => page.getByRole("link").filter({hasText: title})
await expect(card("Autumn Fair")).toBeVisible()
await page.getByRole("button", {name: "Create", exact: true}).click()
const composer = modalForm(page, "Create an Event")
// Field renders its label and input as siblings, so the writable fields are taken in document order.
const textInputs = composer.locator('input[type="text"]:not([readonly])')
const title = textInputs.first()
const location = textInputs.last()
const dates = composer.getByPlaceholder("Select dates")
await title.fill("Winter Market")
await location.fill("Town Hall")
// The picker takes two clicks for a range, each at the time its own time input carries.
const target = new Date()
target.setMonth(target.getMonth() + 1, 15)
target.setHours(12, 0, 0, 0)
await dates.click()
await composer.getByRole("button", {name: "Next month"}).click()
await composer.getByRole("button", {name: "15", exact: true}).click()
await composer.getByRole("button", {name: "15", exact: true}).click()
await expect(dates).not.toHaveValue("")
await composer.getByRole("button", {name: "Save Event"}).click()
await expect(page.getByRole("heading", {name: "Create an Event"})).toHaveCount(0)
const market = card("Winter Market")
await expect(market).toBeVisible()
await expect(market).toContainText(await longDate(page, Math.floor(target.getTime() / 1000)))
})
test("US-047 manage your own calendar event", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
const addEvent = (author: TestUser, d: string, name: string, place: string) =>
space.event(
author,
() =>
space
.kind(TimeEvent)
.writer()
.setIdentifier(d)
.setTitle(name)
.setLocation(place)
.setStart(at(-2, DAY))
.setEnd(at(-2, DAY) + int(2, HOUR))
.setContent("Bring a dish to share.")
.renderTemplate(),
at(1, HOUR),
)
addEvent(user.alice, "harvest-supper", "Harvest Supper", "The Old Mill")
addEvent(user.bob, "quiz-night", "Quiz Night", "The Anchor")
})
const {url} = scenario.space("space")
const calendarPath = `${spacePath(url)}/calendar`
const page = await as(users.alice, calendarPath)
await openCard(page.getByRole("link").filter({hasText: "Harvest Supper"}), "Harvest Supper")
// The hero card is always the first feature card on the page; the About tab renders one below it.
const eventCard = page.locator(".card.z-feature").first()
await expect(page.getByRole("heading", {name: "Harvest Supper", exact: true})).toBeVisible()
await expect(eventCard).toContainText("The Old Mill")
await menuButton(eventCard).click()
await page.getByRole("button", {name: "Edit Event"}).click()
const composer = modalForm(page, "Edit this Event")
const textInputs = composer.locator('input[type="text"]:not([readonly])')
await expect(textInputs.first()).toHaveValue("Harvest Supper")
await textInputs.first().fill("Harvest Supper & Ceilidh")
await textInputs.last().fill("The Village Hall")
await composer.getByRole("button", {name: "Save Event"}).click()
await expect(page.getByRole("heading", {name: "Edit this Event"})).toHaveCount(0)
await expect(page.getByRole("heading", {name: "Harvest Supper & Ceilidh"})).toBeVisible()
await expect(eventCard).toContainText("The Village Hall")
await menuButton(eventCard).click()
await page.getByRole("button", {name: "Delete Event"}).click()
const confirmDelete = page.getByRole("button", {name: "Confirm"})
await confirmDelete.click()
// The confirmation stays up until the retraction is out.
await expect(confirmDelete).toHaveCount(0)
await page.goto(calendarPath)
await expect(page.getByRole("link").filter({hasText: "Quiz Night"})).toBeVisible()
await expect(page.getByRole("link").filter({hasText: "Harvest Supper"})).toHaveCount(0)
})
test("US-048 create a poll and vote on it", async ({seed, as}) => {
let snacks!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
// A wire assertion needs option ids known up front, so this one is a fixture.
snacks = space.event(
user.alice,
() =>
space
.kind(Poll)
.writer()
.setTitle("Which snacks should we order?")
.setPollType("multiplechoice")
.addOption("Chips", "snack-chips")
.addOption("Salsa", "snack-salsa")
.addOption("Olives", "snack-olives")
.setUrls([space.url])
.renderTemplate(),
at(2, HOUR),
)
})
const {url} = scenario.space("space")
const alice = await as(users.alice, `${spacePath(url)}/polls`)
await alice.getByRole("button", {name: "Create", exact: true}).click()
const composer = modalForm(alice, "Create a Poll")
await composer.getByPlaceholder("What would you like to ask?").fill("Where should we meet?")
await composer.getByPlaceholder("Option 1").fill("The park")
await composer.getByPlaceholder("Option 2").fill("The pub")
await composer.getByRole("button", {name: "Create Poll"}).click()
await expect(alice.getByRole("heading", {name: "Create a Poll"})).toHaveCount(0)
const meetup = alice.getByRole("link").filter({hasText: "Where should we meet?"})
await expect(meetup).toBeVisible()
await openCard(meetup, "Where should we meet?")
await expect(alice.getByRole("heading", {name: "Where should we meet?"})).toBeVisible()
await pollOption(alice, "The park").getByRole("radio").check()
await expect(pollOption(alice, "The park")).toContainText("1 vote")
const pollPath = new URL(alice.url()).pathname
const bob = await as(users.bob, pollPath)
// Alice's vote reached bob over the wire, and his comes back to her without a reload.
await expect(pollOption(bob, "The park")).toContainText("1 vote")
await pollOption(bob, "The pub").getByRole("radio").check()
await expect(pollOption(alice, "The pub")).toContainText("1 vote")
// Two boxes checked inside the delay window go out as one vote for both.
await alice.goto(`${spacePath(url)}/polls/${snacks.id}`)
await pollOption(alice, "Chips").getByRole("checkbox").check()
await pollOption(alice, "Salsa").getByRole("checkbox").check()
await expect(pollOption(alice, "Chips")).toContainText("1 vote")
await expect(pollOption(alice, "Salsa")).toContainText("1 vote")
await expect.poll(() => pollResponses(alice, snacks.id)).toEqual([["snack-chips", "snack-salsa"]])
})
test("US-049 a closed poll shows final results only", async ({seed, as}) => {
let closed!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.join(user.carol, "general")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
space.profile(user.carol, {name: "Carol Chen"})
const poll = space.event(
user.alice,
() =>
space
.kind(Poll)
.writer()
.setTitle("What colour should the shed be?")
.setPollType("singlechoice")
.addOption("Blue", "shed-blue")
.addOption("Green", "shed-green")
.setEndsAt(at(3, HOUR))
.setUrls([space.url])
.renderTemplate(),
at(5, HOUR),
)
for (const voter of [user.bob, user.carol]) {
space.event(
voter,
() =>
space
.kind(PollResponse)
.writer()
.setPollId(poll.id)
.addSelection("shed-blue")
.renderTemplate(),
at(4, HOUR),
)
}
closed = poll
})
const {url} = scenario.space("space")
const page = await as(users.alice, `${spacePath(url)}/polls/${closed.id}`)
// The votes already cast still count, and there is nothing left to vote with.
await expect(pollOption(page, "Blue")).toContainText("2 votes")
await expect(page.locator("input[type=radio]")).toHaveCount(0)
})
test("US-050 create a funding goal and track its progress", async ({seed, as}) => {
// A receipt is only counted once the lnurl endpoint answers with the zapper that signed it.
const lud16 = "bob@zap.test"
const lnurl = getLnUrl(lud16)!
// A zap receipt is signed by the recipient's lightning provider rather than by either party.
const provider = makeTestUser("zapper")
let soundproofing!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.member(provider)
space.profile(user.alice, {name: "Alice Anderson"})
space.event(user.bob, () =>
space.kind(Profile).writer().update({name: "Bob Barker", lud16}).renderTemplate(),
)
soundproofing = space.event(
user.bob,
() =>
space
.kind(ZapGoal)
.writer()
.setTitle("Soundproofing")
.setSummary("The neighbours have opinions about band practice.")
.setAmount(toMsats(20000))
.setUrls([space.url])
.renderTemplate(),
at(30, HOUR),
)
// A receipt whose invoice disagrees with the amount its request asked for is thrown away.
const contribute = (from: TestUser, sats: number, createdAt: number) =>
space.event(
provider,
async () => {
const request = await from.signer.sign({
...(await space
.kind(ZapRequest)
.writer()
.setAmount(toMsats(sats))
.setLnurl(lnurl)
.setRecipient(user.bob.pubkey)
.setEvent(soundproofing.event)
.renderTemplate()),
created_at: createdAt,
})
// What was paid is read out of the invoice's human-readable part, where an `n` is a tenth of a sat.
return space
.kind(ZapReceipt)
.writer()
.setBolt11(`lnbc${sats * 10}n1p${from.name}`)
.setDescription(JSON.stringify(request))
.setRecipient(user.bob.pubkey)
.setEventId(soundproofing.id)
.renderTemplate()
},
createdAt,
)
contribute(user.alice, 1000, at(20, HOUR))
contribute(user.carol, 500, at(10, HOUR))
})
const {url} = scenario.space("space")
const page = await as(users.alice, `${spacePath(url)}/goals`)
await page.getByRole("button", {name: "Create", exact: true}).click()
const composer = modalForm(page, "Create a Funding Goal")
await composer.getByPlaceholder("What do funds go towards?").fill("Repair the roof")
await noteEditor(composer).pressSequentially("Three tiles came off in the storm.")
await composer.locator('input[type="number"]').fill("5000")
await composer.getByRole("button", {name: "Create Goal"}).click()
await expect(page.getByRole("heading", {name: "Create a Funding Goal"})).toHaveCount(0)
const roof = page.getByRole("link").filter({hasText: "Repair the roof"})
await expect(roof).toBeVisible()
await openCard(roof, "Repair the roof")
// Amounts are localized, so the thousands separator is the environment's to decide.
await expect(page.locator(".card.z-feature").filter({hasText: /of 5,?000 sats/})).toBeVisible()
// Registered after the page opened, so it answers ahead of the empty dufflepud `as()` installs.
await mockDufflepud(page.context(), {
zappers: [
{
lnurl: bech32ToHex(lnurl),
info: {pubkey: users.bob.pubkey, nostrPubkey: provider.pubkey, allowsNostr: true},
},
],
})
await page.goto(`${spacePath(url)}/goals/${soundproofing.id}`)
const soundCard = page.locator(".card.z-feature").filter({hasText: /of 20,?000 sats/})
await expect(soundCard.getByText(/^1,?500$/)).toBeVisible()
await expect(soundCard.getByText("backers", {exact: true}).locator("xpath=..")).toContainText("2")
})
test("US-051 post, edit, and close out a classified listing", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.profile(user.alice, {name: "Alice Anderson"})
})
const {url} = scenario.space("space")
const classifiedsPath = `${spacePath(url)}/classifieds`
const page = await as(users.alice, classifiedsPath, {
blossom: {server: url.replace(/^wss:/, "https:")},
})
await page.getByRole("button", {name: "Create", exact: true}).click()
const composer = modalForm(page, "Create a Classified Listing")
const topics = composer.getByPlaceholder("Add topics...")
await composer.getByPlaceholder("What is this listing for?").fill("Vintage Road Bike")
await noteEditor(composer).pressSequentially("Steel frame, new tires, barely ridden.")
await composer.locator('input[type="number"]').fill("1200")
await topics.fill("bicycles")
await topics.press("Enter")
await composer
.locator('input[type="file"]')
.setInputFiles({name: "bike.gif", mimeType: "image/gif", buffer: GIF})
await expect(composer.getByAltText("Upload preview")).toBeVisible()
await composer.getByRole("button", {name: "Save Listing"}).click()
await expect(page.getByRole("heading", {name: "Create a Classified Listing"})).toHaveCount(0)
const listing = page.getByRole("link").filter({hasText: "Vintage Road Bike"})
await expect(listing).toBeVisible()
// The card groups thousands, and which separator depends on the browser locale.
await expect(listing).toContainText(/1[,.\s]?200/)
await expect(listing).toContainText("#bicycles")
await expect(listing.locator('img[src^="https://space.test/"]')).toBeVisible()
await openCard(listing, "Vintage Road Bike")
const detail = page.locator(".card.z-feature").filter({hasText: "Vintage Road Bike"})
await menuButton(detail).click()
await page.getByRole("button", {name: "Edit Listing"}).click()
const editor = modalForm(page, "Edit this Listing")
await expect(editor.getByPlaceholder("What is this listing for?")).toHaveValue(
"Vintage Road Bike",
)
await editor.getByRole("combobox").selectOption("sold")
await editor.getByRole("button", {name: "Save Listing"}).click()
await expect(page.getByRole("heading", {name: "Edit this Listing"})).toHaveCount(0)
await expect(detail.getByText("Sold", {exact: true})).toBeVisible()
await page.goto(classifiedsPath)
await expect(listing.getByText("Sold", {exact: true})).toBeVisible()
})
test("US-052 comment on and react to community posts", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
space.event(
user.alice,
() =>
space
.kind(TimeEvent)
.writer()
.setIdentifier("autumn-fair")
.setTitle("Autumn Fair")
.setLocation("The Green")
.setStart(at(-4, DAY))
.setEnd(at(-4, DAY) + int(4, HOUR))
.setContent("Stalls, cider and a tug of war.")
.renderTemplate(),
at(3, HOUR),
)
})
const {url} = scenario.space("space")
const calendarPath = `${spacePath(url)}/calendar`
const bob = await as(users.bob, calendarPath)
await openCard(bob.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair")
// The hero card carries reactions; the description sits in its own card under the About tab.
const heroCard = bob.locator(".card.z-feature").first()
await expect(bob.getByText("Stalls, cider and a tug of war.")).toBeVisible()
await pickParty(bob, emojiButton(heroCard))
await expect(heroCard.getByRole("button", {name: PARTY})).toBeVisible()
await bob.getByRole("button", {name: /^Discussion/}).click()
await bob.getByRole("button", {name: "Add a comment"}).click()
const reply = bob.locator("form").filter({has: bob.locator(".note-editor")})
await noteEditor(reply).pressSequentially("Is there parking at The Green?")
await reply.getByRole("button", {name: "Comment"}).click()
// The composer holds what was typed until the relay confirms it, so match the comment itself.
await expect(
bob.locator('[data-component="Comment"]').filter({hasText: "Is there parking at The Green?"}),
).toBeVisible()
const alice = await as(users.alice, calendarPath)
await openCard(alice.getByRole("link").filter({hasText: "Autumn Fair"}), "Autumn Fair")
const alicesHeroCard = alice.locator(".card.z-feature").first()
await expect(alicesHeroCard.getByRole("button", {name: PARTY})).toBeVisible()
await alice.getByRole("button", {name: /^Discussion/}).click()
await expect(alice.getByText("Is there parking at The Green?")).toBeVisible()
await alice.getByRole("button", {name: "Add a comment"}).click()
const alicesReply = alice.locator("form").filter({has: alice.locator(".note-editor")})
await noteEditor(alicesReply).pressSequentially("Yes, in the field behind the pub.")
await alicesReply.getByRole("button", {name: "Comment"}).click()
await expect(
alice
.locator('[data-component="Comment"]')
.filter({hasText: "Yes, in the field behind the pub."}),
).toBeVisible()
})
test("US-053 browse and search the library", async ({seed, as}) => {
let gettingStarted!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.admin, "general")
space.join(user.alice, "general")
space.profile(user.admin, {name: "Ada Admin"})
space.profile(user.alice, {name: "Alice Anderson"})
gettingStarted = space.event(
user.admin,
() =>
space
.kind(Pinboard)
.writer()
.setIdentifier()
.setTitle("Getting Started")
.setDescription("Reading for new members")
.setCollaborative(true)
.renderTemplate(),
at(2, HOUR),
)
space.event(
user.admin,
() =>
space
.kind(Pinboard)
.writer()
.setIdentifier()
.setTitle("Recipes")
.setDescription("Food and drink from the kitchen")
.setCollaborative(true)
.renderTemplate(),
at(2, HOUR),
)
space.event(
user.admin,
() =>
space
.kind(Pin)
.writer()
.setIdentifier()
.addBoard(getAddress(gettingStarted.event))
.setTitle("The Handbook")
.setExternal("https://handbook.test/start-here")
.renderTemplate(),
at(1, HOUR),
)
})
const {url} = scenario.space("space")
const libraryPath = `${spacePath(url)}/library`
const alice = await as(users.alice, libraryPath)
const term = alice.getByPlaceholder("Search library...")
await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible()
await expect(alice.getByRole("button", {name: /Recipes/})).toBeVisible()
// A term matches a shelf on its description as well as its title.
await term.fill("new members")
await expect(alice.getByRole("button", {name: /Getting Started/})).toBeVisible()
await expect(alice.getByRole("button", {name: /Recipes/})).toHaveCount(0)
await term.fill("")
await alice.getByRole("button", {name: /Getting Started/}).click()
await expect(alice.getByText("The Handbook")).toBeVisible()
await alice.getByRole("button", {name: /Recipes/}).click()
await expect(alice.getByText("This shelf doesn't have any links yet.")).toBeVisible()
// The library is the whole space's, so an ordinary member is offered the same controls.
await expect(alice.getByRole("button", {name: "Create Shelf"})).toBeVisible()
await expect(alice.getByRole("button", {name: "Add a link"})).toBeVisible()
// Someone else's shelf is hers to add to and not to rewrite.
await shelfCard(alice, "Recipes").getByRole("button", {name: "More options"}).click()
await expect(alice.getByRole("button", {name: "Add link", exact: true})).toBeVisible()
await expect(alice.getByRole("button", {name: "Edit shelf"})).toHaveCount(0)
await expect(alice.getByRole("button", {name: "Delete shelf"})).toHaveCount(0)
})
test("US-054 curate the library", async ({seed, as}) => {
let poll!: Seeded
let message!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.admin, "general")
space.join(user.bob, "general")
space.profile(user.admin, {name: "Ada Admin"})
space.profile(user.bob, {name: "Bob Barker"})
message = space.message(user.bob, "general", "the deploy broke again", at(30, MINUTE))
poll = space.event(
user.bob,
() =>
space
.kind(Poll)
.writer()
.setTitle("Should we move standup to 10?")
.setPollType("singlechoice")
.addOption("Yes", "standup-yes")
.addOption("No", "standup-no")
.setUrls([space.url])
.renderTemplate(),
at(2, HOUR),
)
})
const {url} = scenario.space("space")
const libraryPath = `${spacePath(url)}/library`
const pollPath = `${spacePath(url)}/polls/${poll.id}`
// Curating is nobody's privilege here, so this is an ordinary member doing all of it.
const page = await as(users.bob, libraryPath)
const pollCard = page
.locator(".card.z-feature")
.filter({hasText: "Should we move standup to 10?"})
await page.getByRole("button", {name: "Create Shelf"}).click()
const shelfForm = dialog(page, "Create Shelf")
await shelfForm.getByPlaceholder("Shelf title").fill("Reading List")
await shelfForm.getByPlaceholder("What's this shelf about?").fill("Things worth reading")
await shelfForm.getByRole("button", {name: "Save changes"}).click()
// Creating a shelf drops its author straight into it.
await expect(page.getByRole("button", {name: /Reading List/})).toHaveAttribute(
"aria-pressed",
"true",
)
// An external url becomes a card of its own...
await page.getByRole("button", {name: "Add a link"}).click()
const externalLink = dialog(page, "Add Link")
await externalLink.getByPlaceholder("URL or nevent...").fill("https://handbook.test/style-guide")
await externalLink.getByPlaceholder("Optional title").fill("The Style Guide")
await externalLink.getByRole("button", {name: "Add link"}).click()
await expect(page.locator('a[href="https://handbook.test/style-guide"]')).toBeVisible()
// ...and a nostr link becomes the note it points at.
const nevent = nip19.neventEncode({id: message.id, kind: MESSAGE, relays: [url]})
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
await page.getByRole("button", {name: "Add link", exact: true}).click()
const nostrLink = dialog(page, "Add Link")
await nostrLink.getByPlaceholder("URL or nevent...").fill(`nostr:${nevent}`)
await nostrLink.getByPlaceholder("Optional title").fill("Yesterday's incident")
await nostrLink.getByRole("button", {name: "Add link"}).click()
await expect(page.getByText("the deploy broke again")).toBeVisible()
// Editing the shelf from its menu updates the list.
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
await page.getByRole("button", {name: "Edit shelf"}).click()
const shelfEdit = dialog(page, "Edit Shelf")
await shelfEdit.getByPlaceholder("What's this shelf about?").fill("Things worth reading twice")
await shelfEdit.getByRole("button", {name: "Save changes"}).click()
await expect(page.getByRole("button", {name: /Reading List/})).toContainText(
"Things worth reading twice",
)
// A post filed from its own menu lands on the shelf that was picked for it.
await page.goto(pollPath)
await menuButton(pollCard).click()
await page.getByRole("button", {name: "Add to Library"}).click()
await page.getByRole("button", {name: /Reading List/}).click()
const fromPoll = dialog(page, "Add Link")
await fromPoll.getByPlaceholder("Optional title").fill("Standup poll")
await fromPoll.getByRole("button", {name: "Add link"}).click()
await expect(page.getByRole("alert")).toContainText("Link added!")
await page.goto(libraryPath)
await page.getByRole("button", {name: /Reading List/}).click()
await expect(page.getByText("Standup poll")).toBeVisible()
await expect(page.getByText("Should we move standup to 10?")).toBeVisible()
// Deleting the shelf from its menu takes it off the list.
await shelfCard(page, "Reading List").getByRole("button", {name: "More options"}).click()
await page.getByRole("button", {name: "Delete shelf"}).click()
await page.getByRole("button", {name: "Confirm"}).click()
await expect(page.getByRole("button", {name: /Reading List/})).toHaveCount(0)
})
test("US-055 create community content from a room", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("lounge", {name: "Lounge"})
space.join(user.alice, "lounge")
space.profile(user.alice, {name: "Alice Anderson"})
})
const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "lounge"))
const compose = page.locator("form").filter({has: page.locator(".chat-editor")})
await compose.locator(".join").getByRole("button").nth(1).click()
await page.getByRole("button", {name: "Ask a Question", exact: true}).click()
const composer = modalForm(page, "Create a Poll")
await composer.getByPlaceholder("What would you like to ask?").fill("Pizza or tacos?")
await composer.getByPlaceholder("Option 1").fill("Pizza")
await composer.getByPlaceholder("Option 2").fill("Tacos")
await composer.getByRole("button", {name: "Create Poll"}).click()
await expect(page.getByRole("heading", {name: "Create a Poll"})).toHaveCount(0)
// A room item is itself a role=button wrapping its content, so the quote is the inner of the two.
const quote = page.getByRole("button").filter({hasText: "Pizza or tacos?"}).last()
await expect(quote).toBeVisible()
await quote.click()
await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/polls/[0-9a-f]{64}$`))
await expect(page.getByRole("heading", {name: "Pizza or tacos?"})).toBeVisible()
})