2026-08-20 20:33:20 +00:00
|
|
|
import * as nip19 from "nostr-tools/nip19"
|
|
|
|
|
import {HOUR, MINUTE} from "@welshman/lib"
|
2026-09-11 00:32:41 +00:00
|
|
|
import {COMMENT, LONG_FORM, MESSAGE, NOTE, makeEvent, tagSpec, tagValue} from "@welshman/util"
|
2026-09-10 13:54:16 +00:00
|
|
|
import type {SignedEvent} from "@welshman/util"
|
|
|
|
|
import {Article, Comment, Thread} from "@welshman/domain"
|
2026-08-20 20:33:20 +00:00
|
|
|
import type {Locator, Page} from "@playwright/test"
|
2026-09-10 13:54:16 +00:00
|
|
|
import {
|
|
|
|
|
DEFAULT_BLOSSOM_ORIGIN,
|
|
|
|
|
GIF,
|
|
|
|
|
emojiButton,
|
|
|
|
|
expect,
|
|
|
|
|
getPublishedEvents,
|
|
|
|
|
mockBlossom,
|
|
|
|
|
modalForm,
|
|
|
|
|
noteEditor,
|
|
|
|
|
pageBar,
|
|
|
|
|
pickEmoji,
|
|
|
|
|
roomPath,
|
|
|
|
|
spacePath,
|
|
|
|
|
test,
|
|
|
|
|
users,
|
|
|
|
|
} from "../harness"
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
// A handle to a seeded event, which only reads once seed() has drained its queue.
|
|
|
|
|
type Seeded = {readonly id: string; readonly event: SignedEvent}
|
|
|
|
|
|
|
|
|
|
const PARTY = "🎉"
|
|
|
|
|
|
|
|
|
|
// The comment and thread-reply composers are the only forms on their pages carrying a rich text
|
|
|
|
|
// editor.
|
|
|
|
|
const composerForm = (page: Page) =>
|
|
|
|
|
page.locator("form").filter({has: page.locator(".note-editor")})
|
|
|
|
|
|
|
|
|
|
// RoomCompose's join is the upload button and then the compose menu, which is where an article or
|
|
|
|
|
// a thread written from inside a room is started.
|
|
|
|
|
const openComposeMenu = (page: Page) =>
|
|
|
|
|
page
|
|
|
|
|
.locator("form")
|
|
|
|
|
.filter({has: page.locator(".chat-editor")})
|
|
|
|
|
.locator(".join")
|
|
|
|
|
.getByRole("button")
|
|
|
|
|
.nth(1)
|
|
|
|
|
.click()
|
|
|
|
|
|
2026-09-10 13:54:16 +00:00
|
|
|
const pickParty = (page: Page, opener: Locator) => pickEmoji(page, opener, "party popper")
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
// One reaction, recorded as the reader's own, and taken back off again.
|
|
|
|
|
const expectReactionRoundTrip = async (page: Page, scope: Locator, opener: Locator) => {
|
|
|
|
|
const pill = scope.getByRole("button", {name: new RegExp(PARTY)})
|
|
|
|
|
|
|
|
|
|
await pickParty(page, opener)
|
|
|
|
|
await expect(pill).toHaveCount(1)
|
|
|
|
|
await expect(pill).toHaveClass(/button-primary/)
|
|
|
|
|
|
|
|
|
|
await pill.click()
|
|
|
|
|
await expect(pill).toHaveCount(0)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-31 23:18:07 +00:00
|
|
|
// formatTimestamp renders a short date and a short time, so the date half of it is what a spec can
|
|
|
|
|
// name without pinning a format. Formatted by the browser rather than by node, so the locale and the
|
|
|
|
|
// timezone are the ones the app rendered with — see dayLabel in dms.spec.ts.
|
|
|
|
|
const shortDate = (page: Page, seconds: number) =>
|
|
|
|
|
page.evaluate(
|
|
|
|
|
ts => new Intl.DateTimeFormat(undefined, {dateStyle: "short"}).format(new Date(ts * 1000)),
|
|
|
|
|
seconds,
|
|
|
|
|
)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-08-22 17:55:06 +00:00
|
|
|
// The card is a div carrying an overlay link, so it is found by its component rather than by a
|
|
|
|
|
// role — its own contents include a profile button and the room and action links.
|
|
|
|
|
const articleCards = (page: Page) => page.locator('[data-component="ArticleItem"]')
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-09 01:07:48 +00:00
|
|
|
// A comment is a flat block in the tree rather than a card, so it carries a component marker for
|
|
|
|
|
// the specs to name; the marker sits on the comment's own row, not on its replies.
|
|
|
|
|
const comment = (page: Page, text: string) =>
|
|
|
|
|
page.locator('[data-component="Comment"]').filter({hasText: text})
|
|
|
|
|
|
2026-09-11 16:03:15 +00:00
|
|
|
// Clicked near its top-left corner rather than at its centre: the link is an overlay covering the
|
|
|
|
|
// whole card, and a card whose footer wraps onto a second line puts that interactive row under the
|
|
|
|
|
// centre point, where it swallows the click.
|
2026-09-16 15:53:50 +00:00
|
|
|
const openArticle = async (page: Page, title: string) => {
|
|
|
|
|
await articleCards(page)
|
2026-09-11 16:03:15 +00:00
|
|
|
.filter({hasText: title})
|
|
|
|
|
.getByRole("link", {name: title, exact: true})
|
|
|
|
|
.click({position: {x: 20, y: 20}})
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-16 15:53:50 +00:00
|
|
|
// The card's own action bar carries a data-component the article page carries too, and the list
|
|
|
|
|
// is still on screen while the route loads, so a spec that names one straight after this click
|
|
|
|
|
// gets the card's. The article body is only on the page it navigated to.
|
|
|
|
|
await expect(page.locator("article header").getByRole("heading", {name: title})).toBeVisible()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
test("US-037 write and publish 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.profile(user.alice, {name: "Alice Anderson"})
|
|
|
|
|
space.profile(user.bob, {name: "Bob Barker"})
|
|
|
|
|
|
|
|
|
|
// An older article, so "at the top of the list" is a statement about order.
|
|
|
|
|
space.event(
|
|
|
|
|
user.bob,
|
|
|
|
|
() =>
|
|
|
|
|
space
|
|
|
|
|
.kind(Article)
|
|
|
|
|
.writer()
|
|
|
|
|
.setIdentifier("old-news")
|
|
|
|
|
.setTitle("Old News")
|
|
|
|
|
.setSummary("Something published a while back.")
|
|
|
|
|
.setPublishedAt(at(3, HOUR))
|
|
|
|
|
.setContent("Yesterday's headlines.")
|
|
|
|
|
.renderTemplate(),
|
|
|
|
|
at(3, HOUR),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
const page = await as(users.alice, `${spacePath(url)}/articles`)
|
|
|
|
|
|
|
|
|
|
await expect(articleCards(page)).toHaveCount(1)
|
|
|
|
|
|
|
|
|
|
await pageBar(page).getByRole("button", {name: "Write"}).click()
|
|
|
|
|
|
2026-09-02 22:21:18 +00:00
|
|
|
const publish = pageBar(page).getByRole("button", {name: "Publish"})
|
|
|
|
|
const title = page.getByPlaceholder("Title", {exact: true})
|
2026-09-10 13:54:16 +00:00
|
|
|
const body = noteEditor(page)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-02 22:21:18 +00:00
|
|
|
await expect(title).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-02 22:21:18 +00:00
|
|
|
await publish.click()
|
2026-08-20 20:33:20 +00:00
|
|
|
await expect(page.getByRole("alert")).toContainText("Please provide a title for your article.")
|
|
|
|
|
|
|
|
|
|
await title.fill("Half Baked")
|
2026-09-02 22:21:18 +00:00
|
|
|
await publish.click()
|
2026-08-20 20:33:20 +00:00
|
|
|
await expect(page.getByRole("alert")).toContainText("Please write something for your article.")
|
|
|
|
|
|
|
|
|
|
await body.pressSequentially("Only the beginning.")
|
|
|
|
|
|
|
|
|
|
// Neither refusal put anything on the wire.
|
2026-09-10 13:54:16 +00:00
|
|
|
expect(getPublishedEvents(page.context(), LONG_FORM)).toEqual([])
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-02 22:21:18 +00:00
|
|
|
await page.goBack()
|
|
|
|
|
await expect(articleCards(page)).toHaveCount(1)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
// 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")
|
2026-09-02 22:21:18 +00:00
|
|
|
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()
|
2026-09-10 13:54:16 +00:00
|
|
|
expect(getPublishedEvents(page.context(), LONG_FORM)).toHaveLength(1)
|
2026-09-02 22:21:18 +00:00
|
|
|
|
|
|
|
|
await page.goto(`${spacePath(url)}/articles`)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await expect(articleCards(page)).toHaveCount(2)
|
|
|
|
|
await expect(articleCards(page).first()).toContainText("Signals in the Noise")
|
|
|
|
|
|
|
|
|
|
// 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**.",
|
|
|
|
|
"",
|
|
|
|
|
"- Soil",
|
|
|
|
|
"- Water",
|
|
|
|
|
"- Sunlight",
|
|
|
|
|
"",
|
|
|
|
|
`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),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
space.event(
|
|
|
|
|
user.carol,
|
|
|
|
|
() =>
|
|
|
|
|
space
|
|
|
|
|
.kind(Article)
|
|
|
|
|
.writer()
|
|
|
|
|
.setIdentifier("winter-reading")
|
|
|
|
|
.setTitle("Winter Reading")
|
|
|
|
|
.setSummary("Six books for the dark months.")
|
2026-09-10 23:28:36 +00:00
|
|
|
.setTopics([
|
|
|
|
|
"books",
|
|
|
|
|
"keepnostrweird",
|
|
|
|
|
"longform",
|
|
|
|
|
"recommendations",
|
|
|
|
|
"wintering",
|
|
|
|
|
"shortdays",
|
|
|
|
|
])
|
2026-08-20 20:33:20 +00:00
|
|
|
.setPublishedAt(at(1, HOUR))
|
|
|
|
|
.setContent("Start with the shortest one.")
|
|
|
|
|
.renderTemplate(),
|
|
|
|
|
at(1, HOUR),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const {at} = scenario
|
|
|
|
|
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(4)
|
|
|
|
|
|
|
|
|
|
// Author, published date and a preview, all on the card itself.
|
|
|
|
|
await expect(garden).toContainText("Written by")
|
|
|
|
|
await expect(garden).toContainText("Alice Anderson")
|
|
|
|
|
await expect(garden).toContainText("A short teaser about gardens.")
|
2026-08-31 23:18:07 +00:00
|
|
|
await expect(garden).toContainText(await shortDate(page, at(4, HOUR)))
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-10 23:28:36 +00:00
|
|
|
// A card with more topics than fit on one line wraps them, rather than widening its action row
|
|
|
|
|
// until the reactions and the action menu fall off the card's edge.
|
|
|
|
|
const winter = articleCards(page).filter({hasText: "Winter Reading"})
|
|
|
|
|
const winterBox = (await winter.boundingBox())!
|
|
|
|
|
const winterActions = (await winter.locator('[data-component="ArticleActions"]').boundingBox())!
|
|
|
|
|
|
|
|
|
|
expect(winterActions.x + winterActions.width).toBeLessThanOrEqual(winterBox.x + winterBox.width)
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
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 expect(authors.getByRole("button", {name: /Alice Anderson/})).toBeVisible()
|
|
|
|
|
await expect(authors.getByRole("button", {name: /Carol Chen/})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
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.getByRole("heading", {name: "Tending the Garden"}).first()).toBeVisible()
|
|
|
|
|
await expect(page.locator('img[src="https://images.test/garden.jpg"]')).toBeVisible()
|
|
|
|
|
await expect(page.getByText("A short teaser about gardens.")).toBeVisible()
|
2026-09-09 01:07:48 +00:00
|
|
|
// The byline pairs the published date with the reading time, so the header is what carries it.
|
|
|
|
|
await expect(page.locator("article header")).toContainText(await shortDate(page, at(4, HOUR)))
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
const markdown = page.locator(".content-markdown")
|
|
|
|
|
|
|
|
|
|
await expect(markdown.getByRole("heading", {name: "Why gardens matter"})).toBeVisible()
|
|
|
|
|
await expect(markdown.locator("strong")).toHaveText("worth the trouble")
|
|
|
|
|
await expect(markdown.locator("li")).toHaveText(["Soil", "Water", "Sunlight"])
|
|
|
|
|
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()
|
2026-09-11 00:32:41 +00:00
|
|
|
.setRoom(space.url, "general")
|
2026-08-20 20:33:20 +00:00
|
|
|
.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()
|
2026-09-10 13:54:16 +00:00
|
|
|
await noteEditor(composerForm(bob)).pressSequentially("The soil chapter is the good one.")
|
2026-08-20 20:33:20 +00:00
|
|
|
await composerForm(bob).getByRole("button", {name: "Comment"}).click()
|
|
|
|
|
|
2026-08-22 16:24:36 +00:00
|
|
|
// The comment renders from the optimistic write, but the composer holds what was typed until the
|
2026-09-09 01:07:48 +00:00
|
|
|
// relay confirms it, so for a moment the page carries this text twice. Match the rendered comment.
|
|
|
|
|
await expect(comment(bob, "The soil chapter is the good one.")).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-11 00:32:41 +00:00
|
|
|
// A comment on a room event is a room event, so it carries the room the root lives in. Without
|
|
|
|
|
// that tag the relay doesn't see it as part of the group, and neither its access rules nor a
|
|
|
|
|
// room deletion ever reach it.
|
|
|
|
|
await expect
|
|
|
|
|
.poll(() =>
|
|
|
|
|
getPublishedEvents(bob.context(), COMMENT).map(event => tagValue(tagSpec("h"), event.tags)),
|
|
|
|
|
)
|
|
|
|
|
.toEqual(["general"])
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
const carol = await as(users.carol, articlesPath)
|
|
|
|
|
|
|
|
|
|
await openArticle(carol, "Tending the Garden")
|
|
|
|
|
|
2026-09-09 01:07:48 +00:00
|
|
|
const bobsComment = comment(carol, "The soil chapter is the good one.")
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await expect(bobsComment).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await bobsComment.getByRole("button", {name: "Reply", exact: true}).click()
|
2026-09-10 13:54:16 +00:00
|
|
|
await noteEditor(composerForm(carol)).pressSequentially(
|
2026-08-20 20:33:20 +00:00
|
|
|
"Only because you skipped the water chapter.",
|
|
|
|
|
)
|
|
|
|
|
await composerForm(carol).getByRole("button", {name: "Reply", exact: true}).click()
|
|
|
|
|
|
2026-09-09 01:07:48 +00:00
|
|
|
await expect(comment(carol, "Only because you skipped the water chapter.")).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
const alice = await as(users.alice, articlesPath)
|
|
|
|
|
|
2026-09-10 13:54:16 +00:00
|
|
|
await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
|
2026-08-20 20:33:20 +00:00
|
|
|
await openArticle(alice, "Tending the Garden")
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByText("The soil chapter is the good one.")).toBeVisible()
|
|
|
|
|
await expect(alice.getByText("Only because you skipped the water chapter.")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
// A reply is nested inside the comment it answers; a top level comment is not.
|
2026-09-09 01:07:48 +00:00
|
|
|
const nested = alice.locator('[data-component="CommentReplies"]')
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
await alice.getByRole("button", {name: "Add a comment"}).click()
|
|
|
|
|
|
|
|
|
|
const composer = composerForm(alice)
|
|
|
|
|
|
2026-09-10 13:54:16 +00:00
|
|
|
await noteEditor(composer).pressSequentially("Thanks both, here's the bed I meant.")
|
|
|
|
|
await noteEditor(composer).press("Enter")
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
const chooser = alice.waitForEvent("filechooser")
|
|
|
|
|
|
|
|
|
|
await composer.locator('[data-tip="Add an image"]').click()
|
|
|
|
|
await (await chooser).setFiles({name: "bed.gif", mimeType: "image/gif", buffer: GIF})
|
|
|
|
|
|
|
|
|
|
// The attachment carries an uploading marker from the moment the request goes out until the
|
|
|
|
|
// blossom descriptor comes back and replaces its blob url.
|
|
|
|
|
await expect(composer.locator(".tiptap-object")).not.toHaveClass(/tiptap-uploading/)
|
|
|
|
|
|
|
|
|
|
await composer.getByRole("button", {name: "Comment"}).click()
|
|
|
|
|
|
2026-09-09 01:07:48 +00:00
|
|
|
const alicesComment = comment(alice, "Thanks both, here's the bed I meant.")
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await expect(alicesComment).toBeVisible()
|
2026-09-10 13:54:16 +00:00
|
|
|
await expect(alicesComment.locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("US-040 react to a post with an emoji", async ({seed, as}) => {
|
|
|
|
|
let thread!: 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 profile's notes are loaded through its author's outbox relays, so alice needs a relay list
|
|
|
|
|
// for her note to be findable at all.
|
2026-09-10 13:54:16 +00:00
|
|
|
space.relayList(user.alice)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
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 topic = space.event(
|
|
|
|
|
user.alice,
|
|
|
|
|
() =>
|
|
|
|
|
space
|
|
|
|
|
.kind(Thread)
|
|
|
|
|
.writer()
|
|
|
|
|
.setRoom(space.url, "general")
|
|
|
|
|
.setTitle("Bed rotation")
|
|
|
|
|
.setContent("How often do you move things around?")
|
|
|
|
|
.renderTemplate(),
|
|
|
|
|
at(3, HOUR),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
space.event(
|
|
|
|
|
user.alice,
|
|
|
|
|
() =>
|
|
|
|
|
space
|
|
|
|
|
.kind(Comment)
|
|
|
|
|
.writer()
|
|
|
|
|
.setRootFromEvent(topic.event)
|
|
|
|
|
.setParentFromEvent(topic.event)
|
|
|
|
|
.setContent("Every other season, usually.")
|
|
|
|
|
.renderTemplate(),
|
|
|
|
|
at(2, HOUR),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
space.event(
|
|
|
|
|
user.alice,
|
|
|
|
|
makeEvent(NOTE, {content: "A short note from the garden.", created_at: at(2, HOUR)}),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
thread = topic
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
const page = await as(users.bob, `${spacePath(url)}/articles`)
|
|
|
|
|
|
|
|
|
|
await openArticle(page, "Tending the Garden")
|
|
|
|
|
|
2026-09-09 01:07:48 +00:00
|
|
|
// 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")
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-09 01:07:48 +00:00
|
|
|
await expect(articleActions).toBeVisible()
|
|
|
|
|
await expectReactionRoundTrip(
|
|
|
|
|
page,
|
|
|
|
|
articleActions,
|
|
|
|
|
articleActions.getByRole("button", {name: "Add a reaction"}),
|
|
|
|
|
)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await expect(commentCard).toBeVisible()
|
|
|
|
|
await expectReactionRoundTrip(page, commentCard, emojiButton(commentCard))
|
|
|
|
|
|
|
|
|
|
await page.goto(`${spacePath(url)}/threads/${thread.id}`)
|
|
|
|
|
|
|
|
|
|
const threadPost = page.locator("article").filter({hasText: "Every other season, usually."})
|
|
|
|
|
|
|
|
|
|
await expect(threadPost).toBeVisible()
|
|
|
|
|
await expectReactionRoundTrip(page, threadPost, emojiButton(threadPost))
|
|
|
|
|
|
|
|
|
|
await page.goto(`/people/${nip19.npubEncode(users.alice.pubkey)}`)
|
|
|
|
|
|
|
|
|
|
const noteCard = page
|
|
|
|
|
.locator(".card.card-interactive")
|
|
|
|
|
.filter({hasText: "A short note from the garden."})
|
|
|
|
|
|
|
|
|
|
await expect(noteCard).toBeVisible()
|
|
|
|
|
|
|
|
|
|
// NoteItem's picker is the last control on the card rather than part of an EventActions join.
|
|
|
|
|
await expectReactionRoundTrip(page, noteCard, noteCard.getByRole("button").last())
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-09-02 22:21:18 +00:00
|
|
|
await page.getByPlaceholder("Title", {exact: true}).fill("Repotting in Winter")
|
2026-09-10 13:54:16 +00:00
|
|
|
await noteEditor(page).pressSequentially("Wait for a warm week.")
|
2026-09-02 22:21:18 +00:00
|
|
|
await pageBar(page).getByRole("button", {name: "Publish"}).click()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-02 22:21:18 +00:00
|
|
|
await expect(page.getByRole("heading", {name: "Repotting in Winter"}).first()).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-09 01:07:48 +00:00
|
|
|
// The room hears about the article without alice posting it a second time. Its copy is published
|
|
|
|
|
// after the composer has moved on, so wait for it to leave — a page that unloads mid-publish
|
|
|
|
|
// takes it with it.
|
2026-09-10 13:54:16 +00:00
|
|
|
await expect.poll(() => getPublishedEvents(page.context(), MESSAGE)).toHaveLength(1)
|
2026-09-09 01:07:48 +00:00
|
|
|
|
2026-09-02 22:21:18 +00:00
|
|
|
await page.goto(roomPath(url, "lounge"))
|
2026-08-20 20:33:20 +00:00
|
|
|
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).toBeVisible()
|
2026-08-22 17:55:06 +00:00
|
|
|
await expect(card.getByRole("link", {name: /#\s*Lounge/})).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await openArticle(page, "Repotting in Winter")
|
|
|
|
|
|
2026-09-09 01:07:48 +00:00
|
|
|
// A card says which room an article was posted in; the article's own page carries that in its
|
|
|
|
|
// page bar instead.
|
|
|
|
|
const roomLink = pageBar(page).getByRole("link", {name: /#\s*Lounge/})
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-09 01:07:48 +00:00
|
|
|
await expect(roomLink).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-09 01:07:48 +00:00
|
|
|
await roomLink.click()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await expect(page).toHaveURL(new RegExp(`${roomPath(url, "lounge")}$`))
|
|
|
|
|
await expect(page.locator(".chat-editor")).toBeVisible()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("US-042 start a thread and see it filed under its room", async ({seed, as}) => {
|
|
|
|
|
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"})
|
|
|
|
|
|
|
|
|
|
// An older topic with replies, so the board's reply count and last-post time are statements
|
|
|
|
|
// about the thread rather than about an empty row.
|
|
|
|
|
const topic = space.event(
|
|
|
|
|
user.bob,
|
|
|
|
|
() =>
|
|
|
|
|
space
|
|
|
|
|
.kind(Thread)
|
|
|
|
|
.writer()
|
|
|
|
|
.setRoom(space.url, "lounge")
|
|
|
|
|
.setTitle("Chair procurement")
|
|
|
|
|
.setContent("The old ones are done for.")
|
|
|
|
|
.renderTemplate(),
|
|
|
|
|
at(5, HOUR),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const replies = ["Mesh, obviously.", "Anything but mesh."]
|
|
|
|
|
|
|
|
|
|
replies.forEach((content, index) => {
|
|
|
|
|
space.event(
|
|
|
|
|
index === 0 ? user.alice : user.bob,
|
|
|
|
|
() =>
|
|
|
|
|
space
|
|
|
|
|
.kind(Comment)
|
|
|
|
|
.writer()
|
|
|
|
|
.setRootFromEvent(topic.event)
|
|
|
|
|
.setParentFromEvent(topic.event)
|
|
|
|
|
.setContent(content)
|
|
|
|
|
.renderTemplate(),
|
|
|
|
|
at(4, HOUR) + index * 60,
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const {at} = scenario
|
|
|
|
|
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()
|
|
|
|
|
|
2026-09-10 13:54:16 +00:00
|
|
|
const fromRoom = modalForm(page, "Create a Thread")
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await fromRoom.getByPlaceholder("What is this thread about?").fill("Bike shed colors")
|
2026-09-10 13:54:16 +00:00
|
|
|
await noteEditor(fromRoom).pressSequentially("Blue, surely?")
|
2026-08-20 20:33:20 +00:00
|
|
|
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"})})
|
|
|
|
|
const chairs = lounge.getByRole("row").filter({hasText: "Chair procurement"})
|
|
|
|
|
|
|
|
|
|
await expect(lounge.getByRole("row").filter({hasText: "Bike shed colors"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
// Topic, author, reply count and last post, in that order.
|
|
|
|
|
await expect(chairs.getByRole("cell").nth(0)).toContainText("Chair procurement")
|
|
|
|
|
await expect(chairs.getByRole("cell").nth(1)).toContainText("Bob Barker")
|
|
|
|
|
await expect(chairs.getByRole("cell").nth(2)).toHaveText("2")
|
2026-08-31 23:18:07 +00:00
|
|
|
await expect(chairs.getByRole("cell").nth(3)).toContainText(
|
|
|
|
|
await shortDate(page, at(4, HOUR) + 60),
|
|
|
|
|
)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-09 17:13:16 +00:00
|
|
|
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 rather
|
|
|
|
|
// than from a picker.
|
|
|
|
|
await general.getByRole("button", {name: "Create", exact: true}).click()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-10 13:54:16 +00:00
|
|
|
const fromThreads = modalForm(page, "Create a Thread")
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await fromThreads.getByPlaceholder("What is this thread about?").fill("Open floor")
|
2026-09-10 13:54:16 +00:00
|
|
|
await noteEditor(fromThreads).pressSequentially("Anything goes in here.")
|
2026-08-20 20:33:20 +00:00
|
|
|
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()
|
2026-09-09 15:22:44 +00:00
|
|
|
|
2026-09-09 17:13:16 +00:00
|
|
|
await lounge.getByRole("button", {name: "Create", exact: true}).click()
|
2026-09-09 15:22:44 +00:00
|
|
|
|
2026-09-10 13:54:16 +00:00
|
|
|
const toLounge = modalForm(page, "Create a Thread")
|
2026-09-09 15:22:44 +00:00
|
|
|
|
|
|
|
|
await toLounge.getByPlaceholder("What is this thread about?").fill("Carpet swatches")
|
2026-09-10 13:54:16 +00:00
|
|
|
await noteEditor(toLounge).pressSequentially("Beige is a choice.")
|
2026-09-09 15:22:44 +00:00
|
|
|
await toLounge.getByRole("button", {name: "Create Thread"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("heading", {name: "Create a Thread"})).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
await expect(lounge.getByRole("row").filter({hasText: "Carpet swatches"})).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
// Twenty replies is exactly the window a thread opens with, and the last of them is alice's,
|
|
|
|
|
// so her OP badge has to survive bob's own reply pushing the oldest one out of it.
|
2026-08-20 20:33:20 +00:00
|
|
|
for (let i = 1; i <= 20; i++) {
|
|
|
|
|
space.event(
|
|
|
|
|
i === 20 ? user.alice : i % 2 === 0 ? user.carol : user.bob,
|
|
|
|
|
() =>
|
|
|
|
|
space
|
|
|
|
|
.kind(Comment)
|
|
|
|
|
.writer()
|
|
|
|
|
.setRootFromEvent(topic.event)
|
|
|
|
|
.setParentFromEvent(topic.event)
|
|
|
|
|
.setContent(`Reply ${String(i).padStart(2, "0")}`)
|
|
|
|
|
.renderTemplate(),
|
|
|
|
|
at(3, HOUR) + i * 60,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thread = topic
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
const threadPath = `${spacePath(url)}/threads/${thread.id}`
|
|
|
|
|
const bob = await as(users.bob, threadPath)
|
|
|
|
|
|
|
|
|
|
const openingPost = bob.locator(`[data-event="${thread.id}"]`)
|
|
|
|
|
|
|
|
|
|
await expect(openingPost).toBeVisible()
|
|
|
|
|
await expect(openingPost.getByText("OP", {exact: true})).toBeVisible()
|
|
|
|
|
await expect(bob.getByText("20 replies")).toBeVisible()
|
|
|
|
|
|
2026-09-09 17:14:46 +00:00
|
|
|
// The page bar carries the title, so the opening post does not repeat it.
|
|
|
|
|
await expect(bob.getByText("Bed rotation")).toHaveCount(1)
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
await bob.getByRole("button", {name: "Reply to thread"}).click()
|
|
|
|
|
|
|
|
|
|
const threadReply = composerForm(bob)
|
|
|
|
|
|
|
|
|
|
await expect(threadReply).toBeVisible()
|
|
|
|
|
await expect(threadReply.getByText(/^Replying to/)).toHaveCount(0)
|
|
|
|
|
|
2026-09-10 13:54:16 +00:00
|
|
|
await noteEditor(threadReply).pressSequentially("Twice a year here.")
|
2026-08-20 20:33:20 +00:00
|
|
|
await threadReply.getByRole("button", {name: "Post Reply"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(bob.getByText("21 replies")).toBeVisible()
|
2026-09-10 03:37:37 +00:00
|
|
|
await expect(bob.getByText("Twice a year here.")).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-11 00:32:41 +00:00
|
|
|
// The same for a thread reply, which goes out through a different composer.
|
|
|
|
|
await expect
|
|
|
|
|
.poll(() =>
|
|
|
|
|
getPublishedEvents(bob.context(), COMMENT).map(event => tagValue(tagSpec("h"), event.tags)),
|
|
|
|
|
)
|
|
|
|
|
.toEqual(["lounge"])
|
|
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
// His is the twenty first reply, so the oldest one drops out of the window, and the opening
|
|
|
|
|
// post stays above whatever the window holds.
|
|
|
|
|
const showEarlier = bob.getByRole("button", {name: "Show earlier replies"})
|
|
|
|
|
|
|
|
|
|
await expect(showEarlier).toBeVisible()
|
|
|
|
|
await expect(bob.getByText("Reply 01", {exact: true})).toHaveCount(0)
|
|
|
|
|
await expect(openingPost).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
// The thread's author is marked OP wherever their posts turn up.
|
2026-08-20 20:33:20 +00:00
|
|
|
const alicesLastPost = bob.locator("article").filter({hasText: "Reply 20"})
|
|
|
|
|
|
|
|
|
|
await expect(alicesLastPost.getByText("OP", {exact: true})).toBeVisible()
|
2026-09-10 03:37:37 +00:00
|
|
|
|
|
|
|
|
await showEarlier.click()
|
|
|
|
|
|
|
|
|
|
await expect(bob.getByText("Reply 01", {exact: true})).toBeVisible()
|
|
|
|
|
await expect(showEarlier).toHaveCount(0)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
const carol = await as(users.carol, threadPath)
|
2026-09-10 03:37:37 +00:00
|
|
|
|
|
|
|
|
await carol.getByRole("button", {name: "Show earlier replies"}).click()
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
const bobsFirstPost = carol.locator("article").filter({hasText: "Reply 01"})
|
|
|
|
|
|
|
|
|
|
await expect(bobsFirstPost).toBeVisible()
|
|
|
|
|
await bobsFirstPost.getByRole("button", {name: "Reply", exact: true}).click()
|
|
|
|
|
|
|
|
|
|
const postReply = composerForm(carol)
|
|
|
|
|
const replyingTo = postReply.locator(".border-l-2")
|
|
|
|
|
|
|
|
|
|
await expect(replyingTo).toContainText("Replying to @Bob Barker")
|
|
|
|
|
await expect(replyingTo).toContainText("Reply 01")
|
|
|
|
|
|
|
|
|
|
await replyingTo.getByRole("button").last().click()
|
|
|
|
|
|
|
|
|
|
await expect(postReply.getByText(/^Replying to/)).toHaveCount(0)
|
|
|
|
|
|
2026-09-10 13:54:16 +00:00
|
|
|
await noteEditor(postReply).pressSequentially("Answering the thread instead.")
|
2026-08-20 20:33:20 +00:00
|
|
|
await postReply.getByRole("button", {name: "Post Reply"}).click()
|
|
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
// Her post lands at the end of the one list, so there is nothing to click to reach it.
|
2026-08-20 20:33:20 +00:00
|
|
|
await expect(carol.getByText("Answering the thread instead.")).toBeVisible()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("US-044 navigate a long thread", async ({seed, as}) => {
|
|
|
|
|
let thread!: Seeded
|
2026-09-10 03:37:37 +00:00
|
|
|
let firstReply!: Seeded
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
// Forty one replies is two reveals past the twenty a thread opens with.
|
2026-08-20 20:33:20 +00:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
if (i === 1) {
|
|
|
|
|
firstReply = reply
|
2026-08-20 20:33:20 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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"]},
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
const showEarlier = bob.getByRole("button", {name: "Show earlier replies"})
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
// Every assertion below is about which of the replies are in the window, so wait until they
|
|
|
|
|
// have all arrived.
|
|
|
|
|
await expect(bob.getByText("41 replies")).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
// The thread opens on its newest twenty replies, with the opening post above them.
|
|
|
|
|
await expect(bob.locator(`[data-event="${thread.id}"]`)).toBeVisible()
|
|
|
|
|
await expect(bob.getByText("Reply 41", {exact: true})).toBeVisible()
|
|
|
|
|
await expect(bob.getByText("Reply 22", {exact: true})).toBeVisible()
|
|
|
|
|
await expect(bob.getByText("Reply 21", {exact: true})).toHaveCount(0)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-14 21:11:21 +00:00
|
|
|
// The control says how many are still above it, so the reveal is not a blind click.
|
|
|
|
|
await expect(showEarlier).toHaveText("Show earlier replies (21)")
|
|
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
// Each reveal reaches twenty further back without leaving the page.
|
|
|
|
|
await showEarlier.click()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
await expect(bob.getByText("Reply 02", {exact: true})).toBeVisible()
|
|
|
|
|
await expect(bob.getByText("Reply 01", {exact: true})).toHaveCount(0)
|
2026-08-20 20:33:20 +00:00
|
|
|
await expect(bob.getByText("Reply 41", {exact: true})).toBeVisible()
|
2026-09-14 21:11:21 +00:00
|
|
|
await expect(showEarlier).toHaveText("Show earlier replies (1)")
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
await showEarlier.click()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
await expect(bob.getByText("Reply 01", {exact: true})).toBeVisible()
|
|
|
|
|
await expect(showEarlier).toHaveCount(0)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
const oldestPost = bob.locator(`[data-event="${firstReply.id}"]`)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
await oldestPost.getByRole("button", {name: "Permalink"}).click()
|
2026-08-20 20:33:20 +00:00
|
|
|
await expect(bob.getByRole("alert")).toContainText("Copied to clipboard!")
|
|
|
|
|
|
|
|
|
|
const permalink = await bob.evaluate(() => navigator.clipboard.readText())
|
|
|
|
|
const {pathname, hash} = new URL(permalink)
|
|
|
|
|
|
|
|
|
|
expect(pathname).toBe(threadPath)
|
2026-09-10 03:37:37 +00:00
|
|
|
expect(hash).toBe(`#${nip19.neventEncode({id: firstReply.id, relays: [url]})}`)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-10 03:37:37 +00:00
|
|
|
// A permalink reaches back as far as it has to on its own, so carol never sees the control.
|
2026-08-20 20:33:20 +00:00
|
|
|
const carol = await as(users.carol, pathname + hash)
|
2026-09-10 03:37:37 +00:00
|
|
|
const target = carol.locator(`[data-event="${firstReply.id}"]`)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await expect(target).toBeVisible()
|
|
|
|
|
await expect(target).toBeInViewport()
|
2026-09-10 03:37:37 +00:00
|
|
|
await expect(carol.getByRole("button", {name: "Show earlier replies"})).toHaveCount(0)
|
2026-08-20 20:33:20 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-09-10 13:54:16 +00:00
|
|
|
const composer = modalForm(page, "Create a Thread")
|
2026-08-20 20:33:20 +00:00
|
|
|
const nevent = nip19.neventEncode({id: promoted.id, kind: MESSAGE, relays: [url]})
|
|
|
|
|
|
2026-09-09 20:50:11 +00:00
|
|
|
// The seeded entity is parsed, so the composer shows the editor's chip for it rather than
|
|
|
|
|
// the raw uri — which is also what makes the thread carry a q tag for the message.
|
2026-09-10 13:54:16 +00:00
|
|
|
await expect(noteEditor(composer)).toContainText(`${nevent.slice(0, 16)}...`)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
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)
|
2026-09-09 08:32:14 +00:00
|
|
|
await expect(message.getByRole("link", {name: "Deploy failures"})).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await page.goto(`${spacePath(url)}/threads`)
|
|
|
|
|
|
|
|
|
|
const lounge = page.locator("section").filter({has: page.getByRole("heading", {name: "Lounge"})})
|
|
|
|
|
const row = lounge.getByRole("row").filter({hasText: "Deploy failures"})
|
|
|
|
|
|
|
|
|
|
await expect(row).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await row.click()
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("heading", {name: "Deploy failures"})).toBeVisible()
|
|
|
|
|
await expect(page.getByText("the deploy broke again")).toBeVisible()
|
|
|
|
|
})
|