flotilla/e2e/specs/articles-threads.spec.ts
2026-09-25 14:56:50 -07:00

651 lines
22 KiB
TypeScript

import * as nip19 from "nostr-tools/nip19"
import {HOUR, MINUTE} from "@welshman/lib"
import {COMMENT, LONG_FORM, MESSAGE, tagSpec, tagValue} from "@welshman/util"
import type {SignedEvent} from "@welshman/util"
import {Article, Comment, Thread} from "@welshman/domain"
import type {Locator, Page} from "@playwright/test"
import {
emojiButton,
expect,
getPublishedEvents,
modalForm,
noteEditor,
pageBar,
pickEmoji,
roomPath,
spacePath,
test,
users,
} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string; readonly event: SignedEvent}
// The comment and thread-reply composers are the only forms on their pages with a rich text editor.
const composerForm = (page: Page) =>
page.locator("form").filter({has: page.locator(".note-editor")})
// RoomCompose's join is the upload button and then the compose menu.
const openComposeMenu = (page: Page) =>
page
.locator("form")
.filter({has: page.locator(".chat-editor")})
.locator(".join")
.getByRole("button")
.nth(1)
.click()
// One reaction, added and taken back off again.
const expectReactionRoundTrip = async (page: Page, scope: Locator, opener: Locator) => {
const pill = scope.getByRole("button", {name: /🎉/})
await pickEmoji(page, opener, "party popper")
await expect(pill).toHaveCount(1)
await pill.click()
await expect(pill).toHaveCount(0)
}
// The card is a div carrying an overlay link, so it is found by its component rather than a role.
const articleCards = (page: Page) => page.locator('[data-component="ArticleItem"]')
// A comment is a flat block rather than a card, and the marker sits on its own row, not its replies.
const comment = (page: Page, text: string) =>
page.locator('[data-component="Comment"]').filter({hasText: text})
// Clicked near its top-left: a card whose footer wraps puts an interactive row under the centre.
const openArticle = async (page: Page, title: string) => {
await articleCards(page)
.filter({hasText: title})
.getByRole("link", {name: title, exact: true})
.click({position: {x: 20, y: 20}})
// The card's own action bar carries the same data-component, and the list is still on screen.
await expect(page.locator("article header").getByRole("heading", {name: title})).toBeVisible()
}
test("US-037 write and publish an article", 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 page = await as(users.alice, `${spacePath(url)}/articles`)
await pageBar(page).getByRole("button", {name: "Write"}).click()
const publish = pageBar(page).getByRole("button", {name: "Publish"})
const title = page.getByPlaceholder("Title", {exact: true})
const body = noteEditor(page)
await title.fill("Half Baked")
await body.pressSequentially("Only the beginning.")
await page.goBack()
// Reopening picks up where the composer was closed rather than starting over.
await pageBar(page).getByRole("button", {name: "Write"}).click()
await expect(title).toHaveValue("Half Baked")
await expect(body).toContainText("Only the beginning.")
await title.fill("Signals in the Noise")
await publish.click()
// Publishing lands on the article itself rather than back on the list.
await expect(page.getByRole("heading", {name: "Signals in the Noise"}).first()).toBeVisible()
expect(getPublishedEvents(page.context(), LONG_FORM)).toHaveLength(1)
await page.goto(`${spacePath(url)}/articles`)
await expect(articleCards(page).filter({hasText: "Signals in the Noise"})).toBeVisible()
// A published article leaves the composer empty for the next one.
await pageBar(page).getByRole("button", {name: "Write"}).click()
await expect(title).toHaveValue("")
await expect(body).toHaveText("")
})
test("US-038 browse, filter, and read articles", async ({seed, as}) => {
const body = [
"## Why gardens matter",
"",
"Gardens are worth the trouble.",
"",
`Ask ${nip19.npubEncode(users.carol.pubkey)} what she thinks.`,
].join("\n")
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"})
space.event(
user.alice,
() =>
space
.kind(Article)
.writer()
.setIdentifier("tending-the-garden")
.setTitle("Tending the Garden")
.setSummary("A short teaser about gardens.")
.setImage("https://images.test/garden.jpg")
.setTopics(["gardening"])
.setPublishedAt(at(4, HOUR))
.setContent(body)
.renderTemplate(),
at(4, HOUR),
)
space.event(
user.alice,
() =>
space
.kind(Article)
.writer()
.setIdentifier("cooking-with-fire")
.setTitle("Cooking with Fire")
.setSummary("Heat, and what to do with it.")
.setTopics(["cooking"])
.setPublishedAt(at(3, HOUR))
.setContent("Build the fire first.")
.renderTemplate(),
at(3, HOUR),
)
space.event(
user.bob,
() =>
space
.kind(Article)
.writer()
.setIdentifier("notes-on-foraging")
.setTitle("Notes on Foraging")
.setSummary("What grows where nobody planted it.")
.setTopics(["gardening"])
.setPublishedAt(at(2, HOUR))
.setContent("Look under the hedges.")
.renderTemplate(),
at(2, HOUR),
)
})
const {url} = scenario.space("space")
const page = await as(users.bob, `${spacePath(url)}/articles`)
const garden = articleCards(page).filter({hasText: "Tending the Garden"})
await expect(articleCards(page)).toHaveCount(3)
await expect(garden).toContainText("Alice Anderson")
await expect(garden).toContainText("A short teaser about gardens.")
const authors = page
.locator("section")
.filter({has: page.getByRole("heading", {name: "Authors"})})
const topics = page.locator("section").filter({has: page.getByRole("heading", {name: "Topics"})})
await topics.getByRole("button", {name: /^#gardening/}).click()
await expect(articleCards(page)).toHaveCount(2)
await expect(articleCards(page).filter({hasText: "Notes on Foraging"})).toBeVisible()
// Author and topic together are narrower than either on its own.
await authors.getByRole("button", {name: /Alice Anderson/}).click()
await expect(articleCards(page)).toHaveCount(1)
await expect(articleCards(page).first()).toContainText("Tending the Garden")
await openArticle(page, "Tending the Garden")
await expect(page.locator('img[src="https://images.test/garden.jpg"]')).toBeVisible()
const markdown = page.locator(".content-markdown")
await expect(markdown.getByRole("heading", {name: "Why gardens matter"})).toBeVisible()
await expect(markdown.getByRole("link", {name: "@Carol Chen"})).toBeVisible()
})
test("US-039 comment on an article", 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.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"})
space.event(
user.alice,
() =>
space
.kind(Article)
.writer()
.setRoom(space.url, "general")
.setIdentifier("tending-the-garden")
.setTitle("Tending the Garden")
.setSummary("A short teaser about gardens.")
.setPublishedAt(at(4, HOUR))
.setContent("Gardens are worth the trouble.")
.renderTemplate(),
at(4, HOUR),
)
})
const {url} = scenario.space("space")
const articlesPath = `${spacePath(url)}/articles`
const bob = await as(users.bob, articlesPath)
await openArticle(bob, "Tending the Garden")
await bob.getByRole("button", {name: "Add a comment"}).click()
await noteEditor(composerForm(bob)).pressSequentially("The soil chapter is the good one.")
await composerForm(bob).getByRole("button", {name: "Comment"}).click()
// The composer holds what was typed until the relay confirms it, so the page carries this text twice.
await expect(comment(bob, "The soil chapter is the good one.")).toBeVisible()
// A comment on a room event is a room event, so without the room tag the relay sees no group.
await expect
.poll(() =>
getPublishedEvents(bob.context(), COMMENT).map(event => tagValue(tagSpec("h"), event.tags)),
)
.toEqual(["general"])
const carol = await as(users.carol, articlesPath)
await openArticle(carol, "Tending the Garden")
const bobsComment = comment(carol, "The soil chapter is the good one.")
await expect(bobsComment).toBeVisible()
await bobsComment.getByRole("button", {name: "Reply", exact: true}).click()
await noteEditor(composerForm(carol)).pressSequentially(
"Only because you skipped the water chapter.",
)
await composerForm(carol).getByRole("button", {name: "Reply", exact: true}).click()
await expect(comment(carol, "Only because you skipped the water chapter.")).toBeVisible()
const alice = await as(users.alice, articlesPath)
await openArticle(alice, "Tending the Garden")
// A reply is nested inside the comment it answers; a top level comment is not.
const nested = alice.locator('[data-component="CommentReplies"]')
await expect(nested.getByText("Only because you skipped the water chapter.")).toBeVisible()
await expect(nested.getByText("The soil chapter is the good one.")).toHaveCount(0)
})
test("US-040 react to a post with an emoji", 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 article = space.event(
user.alice,
() =>
space
.kind(Article)
.writer()
.setIdentifier("tending-the-garden")
.setTitle("Tending the Garden")
.setPublishedAt(at(4, HOUR))
.setContent("Gardens are worth the trouble.")
.renderTemplate(),
at(4, HOUR),
)
space.event(
user.alice,
() =>
space
.kind(Comment)
.writer()
.setRootFromEvent(article.event)
.setParentFromEvent(article.event)
.setContent("A note about the soil chapter.")
.renderTemplate(),
at(3, HOUR),
)
})
const {url} = scenario.space("space")
const page = await as(users.bob, `${spacePath(url)}/articles`)
await openArticle(page, "Tending the Garden")
// An article's own reactions live in the action bar under it rather than on the article itself.
const articleActions = page.locator('[data-component="ArticleActions"]')
const commentCard = comment(page, "A note about the soil")
await expect(articleActions).toBeVisible()
await expectReactionRoundTrip(
page,
articleActions,
articleActions.getByRole("button", {name: "Add a reaction"}),
)
await expect(commentCard).toBeVisible()
await expectReactionRoundTrip(page, commentCard, emojiButton(commentCard))
})
test("US-041 publish an article 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"))
await openComposeMenu(page)
await page.getByRole("button", {name: "Write an Article"}).click()
await page.getByPlaceholder("Title", {exact: true}).fill("Repotting in Winter")
await noteEditor(page).pressSequentially("Wait for a warm week.")
await pageBar(page).getByRole("button", {name: "Publish"}).click()
await expect(page.getByRole("heading", {name: "Repotting in Winter"}).first()).toBeVisible()
// The room's copy is published after the composer has moved on, and a page that unloads takes it.
await expect.poll(() => getPublishedEvents(page.context(), MESSAGE)).toHaveLength(1)
await page.goto(roomPath(url, "lounge"))
await expect(page.getByText("Repotting in Winter")).toBeVisible()
await page.goto(`${spacePath(url)}/articles`)
const card = articleCards(page).filter({hasText: "Repotting in Winter"})
await expect(card.getByRole("link", {name: /#\s*Lounge/})).toBeVisible()
})
test("US-042 start a thread and see it filed under its 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"))
await openComposeMenu(page)
await page.getByRole("button", {name: "Create Thread"}).click()
const fromRoom = modalForm(page, "Create a Thread")
await fromRoom.getByPlaceholder("What is this thread about?").fill("Bike shed colors")
await noteEditor(fromRoom).pressSequentially("Blue, surely?")
await fromRoom.getByRole("button", {name: "Create Thread"}).click()
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
// The room gets a quote of the thread rather than a second post written by hand.
await expect(page.getByText("Bike shed colors")).toBeVisible()
await page.goto(`${spacePath(url)}/threads`)
const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})})
await expect(lounge.getByRole("row").filter({hasText: "Bike shed colors"})).toBeVisible()
const general = page
.locator("section")
.filter({has: page.getByRole("heading", {name: "General", exact: true})})
// Each board creates its own threads, so the room comes from the button that was clicked.
await general.getByRole("button", {name: "Create", exact: true}).click()
const fromThreads = modalForm(page, "Create a Thread")
await fromThreads.getByPlaceholder("What is this thread about?").fill("Open floor")
await noteEditor(fromThreads).pressSequentially("Anything goes in here.")
await fromThreads.getByRole("button", {name: "Create Thread"}).click()
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
await expect(general.getByRole("row").filter({hasText: "Open floor"})).toBeVisible()
})
test("US-043 reply to a thread and to a specific post", async ({seed, as}) => {
let thread!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("lounge", {name: "Lounge"})
space.join(user.alice, "lounge")
space.join(user.bob, "lounge")
space.join(user.carol, "lounge")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
space.profile(user.carol, {name: "Carol Chen"})
const topic = space.event(
user.alice,
() =>
space
.kind(Thread)
.writer()
.setRoom(space.url, "lounge")
.setTitle("Bed rotation")
.setContent("How often do you move things around?")
.renderTemplate(),
at(3, HOUR),
)
space.event(
user.bob,
() =>
space
.kind(Comment)
.writer()
.setRootFromEvent(topic.event)
.setParentFromEvent(topic.event)
.setContent("Every other season.")
.renderTemplate(),
at(2, HOUR),
)
thread = topic
})
const {url} = scenario.space("space")
const threadPath = `${spacePath(url)}/threads/${thread.id}`
const bob = await as(users.bob, threadPath)
await expect(bob.locator(`[data-event="${thread.id}"]`)).toContainText("Bed rotation")
await bob.getByRole("button", {name: "Reply to thread"}).click()
const threadReply = composerForm(bob)
await noteEditor(threadReply).pressSequentially("Twice a year here.")
await threadReply.getByRole("button", {name: "Post Reply"}).click()
await expect(bob.getByText("Twice a year here.")).toBeVisible()
// A thread reply goes out through its own composer, which needs the room tag too.
await expect
.poll(() =>
getPublishedEvents(bob.context(), COMMENT).map(event => tagValue(tagSpec("h"), event.tags)),
)
.toEqual(["lounge"])
const carol = await as(users.carol, threadPath)
const bobsPost = carol.locator("article").filter({hasText: "Every other season."})
await bobsPost.getByRole("button", {name: "Reply", exact: true}).click()
const postReply = composerForm(carol)
await expect(postReply).toContainText("Replying to @Bob Barker")
await noteEditor(postReply).pressSequentially("Every season here.")
await postReply.getByRole("button", {name: "Post Reply"}).click()
await expect(carol.getByText("Every season here.")).toBeVisible()
})
test("US-044 navigate a long thread", async ({seed, as}) => {
let thread!: Seeded
let firstReply!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("lounge", {name: "Lounge"})
space.join(user.alice, "lounge")
space.join(user.bob, "lounge")
space.join(user.carol, "lounge")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
space.profile(user.carol, {name: "Carol Chen"})
const topic = space.event(
user.alice,
() =>
space
.kind(Thread)
.writer()
.setRoom(space.url, "lounge")
.setTitle("The long one")
.setContent("Settle in.")
.renderTemplate(),
at(4, HOUR),
)
// Forty one replies is two reveals past the twenty a thread opens with.
for (let i = 1; i <= 41; i++) {
const reply = space.event(
i % 2 === 0 ? user.alice : user.bob,
() =>
space
.kind(Comment)
.writer()
.setRootFromEvent(topic.event)
.setParentFromEvent(topic.event)
.setContent(`Reply ${String(i).padStart(2, "0")}`)
.renderTemplate(),
at(4, HOUR) + i * 60,
)
if (i === 1) {
firstReply = reply
}
}
thread = topic
})
const {url} = scenario.space("space")
const threadPath = `${spacePath(url)}/threads/${thread.id}`
const bob = await as(users.bob, threadPath, {
context: {permissions: ["clipboard-read", "clipboard-write"]},
})
const showEarlier = bob.getByRole("button", {name: "Show earlier replies"})
// Every assertion below is about which of the replies are in the window, so wait for all of them.
await expect(bob.getByText("41 replies")).toBeVisible()
// The thread opens on its newest twenty replies.
await expect(bob.getByText("Reply 22", {exact: true})).toBeVisible()
await expect(bob.getByText("Reply 21", {exact: true})).toHaveCount(0)
// Each reveal reaches twenty further back without leaving the page.
await showEarlier.click()
await expect(bob.getByText("Reply 02", {exact: true})).toBeVisible()
await expect(bob.getByText("Reply 01", {exact: true})).toHaveCount(0)
await showEarlier.click()
await expect(bob.getByText("Reply 01", {exact: true})).toBeVisible()
await expect(showEarlier).toHaveCount(0)
const oldestPost = bob.locator(`[data-event="${firstReply.id}"]`)
await oldestPost.getByRole("button", {name: "Permalink"}).click()
await expect(bob.getByRole("alert")).toBeVisible()
const permalink = new URL(await bob.evaluate(() => navigator.clipboard.readText()))
expect(permalink.pathname).toBe(threadPath)
// A permalink reaches back as far as it has to on its own, so carol never sees the control.
const carol = await as(users.carol, permalink.pathname + permalink.search)
const target = carol.locator(`[data-event="${firstReply.id}"]`)
await expect(target).toBeVisible()
await expect(target).toBeInViewport()
await expect(carol.getByRole("button", {name: "Show earlier replies"})).toHaveCount(0)
})
test("US-045 turn a chat message into a thread", async ({seed, as}) => {
let promoted!: Seeded
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
space.room("lounge", {name: "Lounge"})
space.join(user.alice, "lounge")
space.join(user.bob, "lounge")
space.profile(user.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"})
promoted = space.message(user.alice, "lounge", "the deploy broke again", at(20, MINUTE))
})
const {url} = scenario.space("space")
const page = await as(users.bob, roomPath(url, "lounge"))
const message = page.locator(`[data-event="${promoted.id}"]`)
await expect(message).toBeVisible()
await message.hover()
// RoomItem's hover actions are zap, emoji, reply and menu, all of them icons.
await message.locator(".room__item-actions").getByRole("button").last().click()
await page.getByRole("button", {name: "Create a Thread"}).click()
const composer = modalForm(page, "Create a Thread")
await composer.getByPlaceholder("What is this thread about?").fill("Deploy failures")
await composer.getByRole("button", {name: "Create Thread"}).click()
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
await expect(message.getByRole("link", {name: "Deploy failures"})).toBeVisible()
await page.goto(`${spacePath(url)}/threads`)
const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})})
await lounge.getByRole("row").filter({hasText: "Deploy failures"}).click()
await expect(page.locator("article").filter({hasText: "Deploy failures"})).toBeVisible()
await expect(page.getByText("the deploy broke again")).toBeVisible()
})