537 lines
19 KiB
TypeScript
537 lines
19 KiB
TypeScript
import {HOUR} from "@welshman/lib"
|
|
import {LONG_FORM, MESSAGE} from "@welshman/util"
|
|
import {Article, Comment, Reaction} 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"
|
|
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.
|
|
const seedPerson = (space: SeededSpace, user: TestUser, name: string) => {
|
|
space.profile(user, {name})
|
|
space.relayList(user)
|
|
}
|
|
|
|
// A tippy is appended to the layout's own target and keeps its content mounted after it hides.
|
|
const detail = (page: Page) => page.locator(".tippy-target .card").filter({visible: true})
|
|
|
|
// 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})
|
|
|
|
// 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="ArticleActions"]')
|
|
|
|
// Which relays a given event was sent to, oldest first — one entry per attempt.
|
|
const publishedTo = (page: Page, id: string) =>
|
|
getPublished(page.context())
|
|
.filter(({event}) => event.id === id)
|
|
.map(({url}) => url)
|
|
|
|
// The composer is a page of its own, and it holds the reader there until publishing resolves.
|
|
const writeArticle = async (page: Page, title: string, body: string) => {
|
|
await pageBar(page).getByRole("button", {name: "Write"}).click()
|
|
await page.getByPlaceholder("Title", {exact: true}).fill(title)
|
|
await noteEditor(page).pressSequentially(body)
|
|
await pageBar(page).getByRole("button", {name: "Publish"}).click()
|
|
}
|
|
|
|
const writeComment = async (page: Page, body: string) => {
|
|
await page.getByRole("button", {name: "Add a comment"}).click()
|
|
|
|
const form = page.locator("form").filter({has: page.locator(".note-editor")})
|
|
|
|
// 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()
|
|
}
|
|
|
|
// The slider is initialised from the settings store on mount, so a fresh page says the client took it.
|
|
const setSendDelay = async (page: Page, seconds: number) => {
|
|
const slider = page.locator("input[type=range]")
|
|
|
|
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()
|
|
|
|
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)
|
|
}
|
|
|
|
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(alice).getByText("morning all")).toBeVisible()
|
|
await expect(timeline(bob).getByText("morning all")).toBeVisible()
|
|
|
|
// The delay defaults to zero, so this one leaves as soon as it is written and says nothing.
|
|
await send(alice, "no delay here")
|
|
|
|
await expect(message(alice, "no delay here")).toBeVisible()
|
|
await expect(message(bob, "no delay here")).toBeVisible()
|
|
await expect(toast(alice)).toHaveCount(0)
|
|
|
|
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.
|
|
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)
|
|
|
|
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])
|
|
|
|
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.
|
|
const ghost = roomPath(url, "archive")
|
|
|
|
const alice = await as(users.alice, ghost)
|
|
const bob = await as(users.bob, ghost)
|
|
|
|
await send(alice, "anyone here?")
|
|
|
|
const failure = message(alice, "anyone here?").getByText("Failed to send!")
|
|
|
|
await expect(failure).toBeVisible()
|
|
|
|
// The text is still hers to see, and the publish is finished and was refused.
|
|
await expect(message(alice, "anyone here?")).toContainText("anyone here?")
|
|
await expect(message(bob, "anyone here?")).toHaveCount(0)
|
|
|
|
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:")
|
|
await expect(detail(alice).locator(".text-success")).toHaveCount(1)
|
|
})
|
|
|
|
test("US-070 retry a failed relay", async ({seed, as}) => {
|
|
const scenario = await seed(({relay, user}) => {
|
|
const space = relay("space")
|
|
|
|
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")
|
|
})
|
|
|
|
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.
|
|
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.
|
|
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()
|
|
|
|
await failure.click()
|
|
await detail(alice).getByRole("button", {name: "Retry"}).click()
|
|
|
|
await expect(toast(alice)).toContainText("Sending...")
|
|
|
|
// The room still does not exist, so this attempt is refused too.
|
|
await expect(toast(alice)).toHaveCount(0)
|
|
await expect(failure).toBeVisible()
|
|
|
|
// Seeding again is the only way the relay changes its mind about something it has already refused.
|
|
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!")
|
|
|
|
const [sent] = getPublishedEvents(alice.context(), MESSAGE)
|
|
|
|
// One attempt and two retries, every one of them to the relay that failed and to nothing else.
|
|
expect(publishedTo(alice, sent.id)).toEqual([url, url, url])
|
|
|
|
await bob.goto(roomPath(url, "archive"))
|
|
|
|
await expect(message(bob, "is anyone here?")).toBeVisible()
|
|
})
|
|
|
|
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.
|
|
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`)
|
|
|
|
await writeArticle(alice, "Signals in the Noise", "Everything worth hearing is quiet.")
|
|
|
|
// 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()
|
|
await expect(articleActions(alice).getByText("Sending...")).toHaveCount(0)
|
|
await expect(articleActions(alice).getByText("Failed to send!")).toHaveCount(0)
|
|
await expect(toast(alice)).toHaveCount(0)
|
|
|
|
await writeComment(alice, "Worth saying twice.")
|
|
|
|
const comment = commentCard(alice, "Worth saying twice.")
|
|
|
|
await expect(menuButton(comment)).toBeVisible()
|
|
await expect(comment.getByText("Sending...")).toHaveCount(0)
|
|
await expect(comment.getByText("Failed to send!")).toHaveCount(0)
|
|
await expect(toast(alice)).toHaveCount(0)
|
|
|
|
// 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.
|
|
const stuck = articleActions(alice)
|
|
|
|
await expect(stuck.getByText("Failed to send!")).toBeVisible()
|
|
await expect(toast(alice)).toHaveCount(0)
|
|
|
|
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.")
|
|
|
|
const footnote = commentCard(alice, "A footnote nobody asked for.")
|
|
|
|
await expect(footnote.getByText("Sending...")).toBeVisible()
|
|
await expect(footnote.getByRole("button", {name: "Cancel"})).toBeVisible()
|
|
|
|
await footnote.getByRole("button", {name: "Cancel"}).click()
|
|
|
|
await expect(commentCard(alice, "A footnote nobody asked for.")).toHaveCount(0)
|
|
})
|
|
|
|
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),
|
|
)
|
|
|
|
const comment = 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),
|
|
)
|
|
|
|
// Somebody else's reaction, so the summary the pill is meant to replace has something in it.
|
|
space.event(
|
|
user.bob,
|
|
() => space.kind(Reaction).writer().setEvent(comment.event).setContent("🎉").renderTemplate(),
|
|
at(2, 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.")
|
|
|
|
// Publishing waits the relay out and lands on the article anyway, still marked failed.
|
|
const stuck = articleActions(alice)
|
|
|
|
await expect(stuck.getByText("Failed to send!")).toBeVisible()
|
|
|
|
await alice.getByRole("button", {name: "Article options"}).click()
|
|
await alice.getByRole("button", {name: "Delete Article"}).click()
|
|
await alice.getByRole("button", {name: "Confirm"}).click()
|
|
|
|
// 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)
|
|
|
|
await alice.goto(articlePath(url, `${LONG_FORM}:${users.alice.pubkey}:tending-the-garden`))
|
|
|
|
const comment = commentCard(alice, "A note about the soil chapter.")
|
|
const article = articleActions(alice)
|
|
|
|
await expect(comment).toBeVisible()
|
|
await expect(comment.getByRole("button", {name: /🎉/})).toBeVisible()
|
|
|
|
await menuButton(comment).click()
|
|
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)
|
|
|
|
await alice.getByRole("button", {name: "Article options"}).click()
|
|
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.
|
|
await expect(article.getByText("Deleted", {exact: true})).toBeVisible()
|
|
await expect(article.getByRole("button", {name: "Add a reaction"})).toHaveCount(0)
|
|
})
|
|
|
|
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])
|
|
})
|
|
|
|
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})
|
|
|
|
await composerEnabled(alice)
|
|
|
|
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"))
|
|
|
|
// The editor names the file the moment it is attached, and a submit while it uploads is dropped.
|
|
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(toast(alice)).toHaveCount(1)
|
|
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)
|
|
|
|
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"))
|
|
|
|
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)
|
|
})
|