flotilla/e2e/specs/delivery.spec.ts

528 lines
18 KiB
TypeScript
Raw Normal View History

2026-08-20 20:33:20 +00:00
import {HOUR} from "@welshman/lib"
import {LONG_FORM, MESSAGE} from "@welshman/util"
2026-09-25 21:16:50 +00:00
import {Article, Comment} from "@welshman/domain"
import type {Page} from "@playwright/test"
import {
DEFAULT_BLOSSOM_ORIGIN,
bubble,
chatItems,
chatPath,
chooseFile,
composer,
composerEnabled,
expect,
getPublished,
getPublishedEvents,
gifFile,
menuButton,
message,
mockBlossom,
noteEditor,
pageBar,
roomPath,
send,
sendButton,
spacePath,
test,
timeline,
toast,
users,
} from "../harness"
2026-08-20 20:33:20 +00:00
import type {SeededSpace, TestUser} from "../harness"
// makeSpacePath percent-encodes each segment it is given, and an address is full of colons.
const articlePath = (url: string, address: string) =>
`${spacePath(url)}/articles/${encodeURIComponent(address)}`
// Outbox routing resolves a person through their relay list, so a person here has a kind-10002.
2026-08-20 20:33:20 +00:00
const seedPerson = (space: SeededSpace, user: TestUser, name: string) => {
space.profile(user, {name})
space.relayList(user)
2026-08-20 20:33:20 +00:00
}
// A tippy is appended to the layout's own target and keeps its content mounted after it hides.
2026-08-20 20:33:20 +00:00
const detail = (page: Page) => page.locator(".tippy-target .card").filter({visible: true})
2026-09-09 01:07:48 +00:00
// A comment is a flat block in the comment tree rather than a card, named by its text.
const commentCard = (page: Page, text: string) =>
page.locator('[data-component="Comment"]').filter({hasText: text})
2026-08-20 20:33:20 +00:00
2026-09-09 01:07:48 +00:00
// The action bar under an article on its own page, which is where that article's status shows.
const articleActions = (page: Page) => page.locator('[data-component="ArticleActionBar"]')
2026-08-20 20:33:20 +00:00
// Which relays a given event was sent to, oldest first — one entry per attempt.
const publishedTo = (page: Page, id: string) =>
getPublished(page.context())
2026-08-20 20:33:20 +00:00
.filter(({event}) => event.id === id)
.map(({url}) => url)
2026-09-09 01:07:48 +00:00
// The composer is a page of its own, and it holds the reader there until publishing resolves.
2026-08-20 20:33:20 +00:00
const writeArticle = async (page: Page, title: string, body: string) => {
2026-09-09 01:07:48 +00:00
await pageBar(page).getByRole("button", {name: "Write"}).click()
await page.getByPlaceholder("Title", {exact: true}).fill(title)
await noteEditor(page).pressSequentially(body)
2026-09-09 01:07:48 +00:00
await pageBar(page).getByRole("button", {name: "Publish"}).click()
2026-08-20 20:33:20 +00:00
}
// The comment composer, which is the only form on a content page carrying an editor.
const commentComposer = (page: Page) =>
page.locator("form").filter({has: page.locator(".note-editor")})
2026-08-20 20:33:20 +00:00
const writeComment = async (page: Page, body: string) => {
await page.getByRole("button", {name: "Add a comment"}).click()
const form = commentComposer(page)
2026-08-20 20:33:20 +00:00
// The editor takes focus itself once it has mounted, and typing before that puts the caret back.
await expect(noteEditor(form)).toBeFocused()
await noteEditor(form).pressSequentially(body)
await form.getByRole("button", {name: "Comment"}).click()
2026-08-20 20:33:20 +00:00
}
// The slider is initialised from the settings store on mount, so a fresh page says the client took it.
2026-08-20 20:33:20 +00:00
const setSendDelay = async (page: Page, seconds: number) => {
const slider = page.getByRole("slider", {name: "Send delay"})
2026-08-20 20:33:20 +00:00
await page.goto("/settings/content")
await slider.fill(String(seconds * 1000))
const unit = seconds === 1 ? "second" : "seconds"
await expect(
page.getByText(`Delay sending messages and comments for ${seconds} ${unit}.`),
).toBeVisible()
2026-08-20 20:33:20 +00:00
await page.getByRole("button", {name: "Save Changes"}).click()
await expect(toast(page)).toContainText("Your settings have been saved!")
await page.goto("/settings/content")
await expect(slider).toHaveValue(String(seconds * 1000))
}
test("US-068 watch a delayed send, and cancel it", 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")
for (const person of [user.alice, user.bob]) {
seedPerson(space, person, person.name)
space.messagingRelayList(person)
2026-08-20 20:33:20 +00:00
}
space.message(user.bob, "general", "morning all", at(2, HOUR))
})
const {url} = scenario.space("space")
const path = roomPath(url, "general")
const alice = await as(users.alice, path)
const bob = await as(users.bob, path)
await expect(timeline(bob).getByText("morning all")).toBeVisible()
await setSendDelay(alice, 5)
await alice.goto(path)
await expect(timeline(alice).getByText("morning all")).toBeVisible()
await send(alice, "second thoughts")
// The bubble is hers straight away; the delay is only about when it leaves.
await expect(message(alice, "second thoughts")).toBeVisible()
await expect(toast(alice)).toContainText("Sending...")
await expect(toast(alice).getByRole("button", {name: "Cancel"})).toBeVisible()
await expect(toast(alice)).toContainText("Message sent!")
await expect(message(bob, "second thoughts")).toBeVisible()
await expect(toast(alice)).toHaveCount(0)
await send(alice, "wrong room, sorry")
await expect(message(alice, "wrong room, sorry")).toBeVisible()
await toast(alice).getByRole("button", {name: "Cancel"}).click()
await expect(message(alice, "wrong room, sorry")).toHaveCount(0)
await expect(toast(alice)).toHaveCount(0)
// Sent after the cancelled one and delayed by as long, so its window has been and gone.
2026-08-20 20:33:20 +00:00
await send(alice, "still here")
await expect(message(bob, "still here")).toBeVisible()
await expect(message(bob, "wrong room, sorry")).toHaveCount(0)
// The same cancel in a conversation.
await alice.goto(chatPath(users.bob.pubkey))
await bob.goto(chatPath(users.alice.pubkey))
await send(alice, "ignore this one")
await expect(bubble(alice, "ignore this one")).toBeVisible()
await toast(alice).getByRole("button", {name: "Cancel"}).click()
// That was her only message to him, so the conversation goes with it.
await expect(chatItems(alice)).toHaveCount(0)
2026-08-20 20:33:20 +00:00
await send(alice, "actually, hi")
await expect(bubble(bob, "actually, hi")).toBeVisible()
await expect(bubble(bob, "ignore this one")).toHaveCount(0)
await expect(bubble(alice, "ignore this one")).toHaveCount(0)
})
test("US-069 see why a message failed to deliver", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
// Alice belongs to both relays; bob deliberately belongs only to the first.
other.join(user.alice)
for (const person of [user.alice, user.bob]) {
seedPerson(space, person, person.name)
}
space.messagingRelayList(user.alice)
// Bob's client names both relays, but he is a member of one, so the other refuses a wrap for him.
space.messagingRelayList(user.bob, [space.url, other.url])
2026-08-20 20:33:20 +00:00
space.message(user.bob, "general", "morning all", at(2, HOUR))
})
const {url} = scenario.space("space")
// A room the relay never created, which is where a link to a room that has since been deleted lands.
2026-08-20 20:33:20 +00:00
const ghost = roomPath(url, "archive")
const alice = await as(users.alice, ghost)
await send(alice, "anyone here?")
const failure = message(alice, "anyone here?").getByText("Failed to send!")
await expect(failure).toBeVisible()
await failure.click()
await expect(detail(alice)).toContainText("Failed to send!")
await expect(detail(alice)).toContainText("space.test")
await expect(detail(alice)).toContainText("invalid: group not found.")
await expect(detail(alice).getByRole("button", {name: "Retry"})).toBeVisible()
// A conversation with someone whose two messaging relays do not both take his mail.
await alice.goto(chatPath(users.bob.pubkey))
await send(alice, "half a message")
const partial = alice.getByText("Failed to send!")
await expect(partial).toBeVisible()
await partial.click()
await expect(detail(alice)).toContainText("Partial delivery 1/2 relays")
await expect(detail(alice)).toContainText("space.test")
await expect(detail(alice)).toContainText("other.test")
await expect(detail(alice)).toContainText("restricted:")
})
test("US-070 retry a failed relay", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
2026-08-20 20:33:20 +00:00
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
other.join(user.alice)
2026-08-20 20:33:20 +00:00
seedPerson(space, user.alice, "Alice Anderson")
seedPerson(space, user.bob, "Bob Barker")
space.messagingRelayList(user.alice)
// Bob names both relays and belongs to one, so the other refuses a wrap addressed to him.
space.messagingRelayList(user.bob, [space.url, other.url])
2026-08-20 20:33:20 +00:00
})
const {url} = scenario.space("space")
const ghost = roomPath(url, "archive")
const alice = await as(users.alice, ghost)
// Seeding again replaces the scenario, and a page opened from that one carries no room list.
2026-08-20 20:33:20 +00:00
const bob = await as(users.bob, roomPath(url, "general"))
// A delay is what makes the retry's own "Sending..." a state rather than an instant.
2026-08-20 20:33:20 +00:00
await setSendDelay(alice, 5)
await alice.goto(ghost)
await send(alice, "is anyone here?")
const failure = message(alice, "is anyone here?").getByText("Failed to send!")
await expect(failure).toBeVisible()
// Seeding again is the only way the relay changes its mind about something it has already refused.
2026-08-20 20:33:20 +00:00
await seed(({relay}) => {
relay("space").room("archive", {name: "Archive"})
})
await failure.click()
await detail(alice).getByRole("button", {name: "Retry"}).click()
await expect(toast(alice)).toContainText("Sending...")
await expect(toast(alice)).toContainText("Message sent!")
// The message went out, so it no longer says it did not.
await expect(failure).toHaveCount(0)
const [sent] = getPublishedEvents(alice.context(), MESSAGE)
2026-08-20 20:33:20 +00:00
2026-09-25 21:16:50 +00:00
// The attempt and the retry, both to the relay that failed and to nothing else.
expect(publishedTo(alice, sent.id)).toEqual([url, url])
2026-08-20 20:33:20 +00:00
await bob.goto(roomPath(url, "archive"))
await expect(message(bob, "is anyone here?")).toBeVisible()
// The same retry in a conversation, where one of his two messaging relays turns her wrap away.
await alice.goto(chatPath(users.bob.pubkey))
await send(alice, "did that one arrive?")
const partial = alice.getByText("Failed to send!")
await expect(partial).toBeVisible()
await seed(({relay, user}) => {
relay("other").join(user.bob)
})
await partial.click()
await detail(alice).getByRole("button", {name: "Retry"}).click()
await expect(toast(alice)).toContainText("Message sent!")
await expect(partial).toHaveCount(0)
2026-08-20 20:33:20 +00:00
})
test("US-071 content posts show delivery status in place", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
seedPerson(space, user.alice, "Alice Anderson")
seedPerson(space, user.bob, "Bob Barker")
// A second space in her list, so that opening it prompts her for nothing.
other.join(user.alice)
})
const {url} = scenario.space("space")
const quiet = scenario.space("other").url
// A space whose relay this browser will not open a socket to, so a post is sent and nothing returns.
2026-08-20 20:33:20 +00:00
const alice = await as(users.alice, `${spacePath(url)}/articles`, {
env: {VITE_BLOCKED_RELAYS: quiet},
})
// A comment leaves after the send delay the way a chat message does.
await setSendDelay(alice, 1)
await alice.goto(`${spacePath(url)}/articles`)
2026-08-20 20:33:20 +00:00
await writeArticle(alice, "Signals in the Noise", "Everything worth hearing is quiet.")
2026-09-09 01:07:48 +00:00
// Publishing lands on the article's own page, where its status sits under the article itself.
await expect(alice.getByRole("heading", {name: "Signals in the Noise"}).first()).toBeVisible()
2026-08-20 20:33:20 +00:00
// The same post into the space whose relay says nothing back.
await alice.goto(`${spacePath(quiet)}/articles`)
await writeArticle(alice, "Into the Void", "Nobody is listening.")
// Nothing was refused, so there is no toast; the action bar says the relay never answered.
2026-09-09 01:07:48 +00:00
const stuck = articleActions(alice)
2026-08-20 20:33:20 +00:00
await expect(stuck.getByText("Failed to send!")).toBeVisible()
2026-09-09 01:07:48 +00:00
await expect(toast(alice)).toHaveCount(0)
2026-08-20 20:33:20 +00:00
await stuck.getByText("Failed to send!").click()
await expect(detail(alice)).toContainText("Failed to send!")
await expect(detail(alice)).toContainText("other.test")
await expect(detail(alice)).toContainText("request timed out.")
await writeComment(alice, "A footnote nobody asked for.")
2026-09-09 01:07:48 +00:00
const footnote = commentCard(alice, "A footnote nobody asked for.")
2026-08-20 20:33:20 +00:00
await expect(footnote.getByText("Sending...")).toBeVisible()
// The comment is still in flight to a relay that says nothing, and the composer has already gone.
await expect(commentComposer(alice)).toHaveCount(0, {timeout: 2000})
2026-08-20 20:33:20 +00:00
await expect(footnote.getByRole("button", {name: "Cancel"})).toBeVisible()
await footnote.getByRole("button", {name: "Cancel"}).click()
2026-09-09 01:07:48 +00:00
await expect(commentCard(alice, "A footnote nobody asked for.")).toHaveCount(0)
2026-08-20 20:33:20 +00:00
})
test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.join(user.alice, "general")
space.join(user.bob, "general")
seedPerson(space, user.alice, "Alice Anderson")
seedPerson(space, user.bob, "Bob Barker")
other.join(user.alice)
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),
)
2026-09-25 21:16:50 +00:00
space.event(
2026-08-20 20:33:20 +00:00
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 quiet = scenario.space("other").url
const alice = await as(users.alice, `${spacePath(quiet)}/articles`, {
env: {VITE_BLOCKED_RELAYS: quiet},
})
// A post whose relay never answered, deleted while it is still marked failed.
await writeArticle(alice, "Into the Void", "Nobody is listening.")
2026-09-09 01:07:48 +00:00
// Publishing waits the relay out and lands on the article anyway, still marked failed.
const stuck = articleActions(alice)
2026-08-20 20:33:20 +00:00
await expect(stuck.getByText("Failed to send!")).toBeVisible()
2026-09-09 01:07:48 +00:00
await alice.getByRole("button", {name: "Article options"}).click()
2026-08-20 20:33:20 +00:00
await alice.getByRole("button", {name: "Delete Article"}).click()
await alice.getByRole("button", {name: "Confirm"}).click()
2026-09-09 01:07:48 +00:00
// Deleted takes over from the failure the article was carrying.
await expect(stuck.getByText("Deleted", {exact: true})).toBeVisible()
await expect(stuck.getByText("Failed to send!")).toHaveCount(0)
2026-08-20 20:33:20 +00:00
await alice.goto(articlePath(url, `${LONG_FORM}:${users.alice.pubkey}:tending-the-garden`))
2026-09-09 01:07:48 +00:00
const comment = commentCard(alice, "A note about the soil chapter.")
const article = articleActions(alice)
2026-08-20 20:33:20 +00:00
await expect(comment).toBeVisible()
await menuButton(comment).click()
2026-08-20 20:33:20 +00:00
await alice.getByRole("button", {name: "Delete Comment"}).click()
await alice.getByRole("button", {name: "Confirm"}).click()
// The comment list is a feed too, so a deleted comment drops out of it.
await expect(comment).toHaveCount(0)
2026-08-20 20:33:20 +00:00
2026-09-09 01:07:48 +00:00
await alice.getByRole("button", {name: "Article options"}).click()
2026-08-20 20:33:20 +00:00
await alice.getByRole("button", {name: "Delete Article"}).click()
await alice.getByRole("button", {name: "Confirm"}).click()
// This page is the article's own view, so it stays and is marked deleted rather than vanishing.
2026-08-20 20:33:20 +00:00
await expect(article.getByText("Deleted", {exact: true})).toBeVisible()
})
test("US-073 a multi-part message reports one status", async ({seed, as}) => {
await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
for (const person of [user.alice, user.bob, user.carol]) {
space.join(person, "general")
seedPerson(space, person, person.name)
}
space.messagingRelayList(user.alice)
space.messagingRelayList(user.bob)
// Carol's client names a relay she does not belong to, so one of the two refuses each part.
space.messagingRelayList(user.carol, [space.url, other.url])
2026-08-20 20:33:20 +00:00
})
const alice = await as(users.alice, chatPath(users.bob.pubkey))
const bob = await as(users.bob, chatPath(users.alice.pubkey))
await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
await mockBlossom(bob.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
2026-08-20 20:33:20 +00:00
await composerEnabled(alice)
2026-08-20 20:33:20 +00:00
await composer(alice).click()
await composer(alice).pressSequentially("here is the harbour")
await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), gifFile("harbour.gif"))
2026-08-20 20:33:20 +00:00
// The editor names the file the moment it is attached, and a submit while it uploads is dropped.
2026-08-20 20:33:20 +00:00
await expect(composer(alice)).toContainText("harbour.gif")
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
await expect(sendButton(alice)).toBeEnabled()
await composer(alice).press("Enter")
// Two bubbles, one status for the pair of them.
await expect(toast(alice)).toHaveCount(1)
await expect(toast(alice)).toContainText("Sending...")
await expect(alice.locator(".chat-bubble")).toHaveCount(2)
await expect(bubble(alice, "here is the harbour")).toBeVisible()
// Which only becomes a success once both parts have landed everywhere they were sent.
await expect(toast(alice)).toContainText("Message sent!")
await expect(bob.locator(".chat-bubble")).toHaveCount(2)
await expect(toast(alice)).toHaveCount(0)
// The same message to someone one of whose relays refuses it: each part says so for itself.
await alice.goto(chatPath(users.carol.pubkey))
await composerEnabled(alice)
2026-08-20 20:33:20 +00:00
await composer(alice).click()
await composer(alice).pressSequentially("and one for you")
await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), gifFile("harbour.gif"))
2026-08-20 20:33:20 +00:00
await expect(composer(alice)).toContainText("harbour.gif")
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
await expect(sendButton(alice)).toBeEnabled()
await composer(alice).press("Enter")
await expect(alice.locator(".chat-bubble")).toHaveCount(2)
await expect(alice.getByText("Failed to send!")).toHaveCount(2)
})