497 lines
18 KiB
TypeScript
497 lines
18 KiB
TypeScript
|
|
import {createHash} from "node:crypto"
|
||
|
|
import type {BrowserContext, Locator, Page} from "@playwright/test"
|
||
|
|
import {HOUR, now} from "@welshman/lib"
|
||
|
|
import {MessagingRelayList, RelayList} from "@welshman/domain"
|
||
|
|
import {expect, makeTestUser, roomPath, test, users} from "../harness"
|
||
|
|
import type {SeededSpace, TestUser} from "../harness"
|
||
|
|
|
||
|
|
// The path the app builds for a conversation — see makeChatId in src/app/chats.ts.
|
||
|
|
const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
|
||
|
|
|
||
|
|
// Where an upload lands: the space's own origin is probed first and blossom is off in every
|
||
|
|
// tenant's toml, so what is left is VITE_DEFAULT_BLOSSOM_SERVERS.
|
||
|
|
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
|
||
|
|
|
||
|
|
// A one pixel gif. Gif rather than png because compressFileForUpload passes it through untouched
|
||
|
|
// rather than re-encoding it through a canvas, so the mock stores the bytes the browser was handed.
|
||
|
|
const GIF = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||
|
|
|
||
|
|
const gifFile = (name: string) => ({
|
||
|
|
name,
|
||
|
|
mimeType: "image/gif",
|
||
|
|
buffer: Buffer.from(GIF, "base64"),
|
||
|
|
})
|
||
|
|
|
||
|
|
// A blossom server whose blobs are shared by every context in a test, and whose next upload can be
|
||
|
|
// held open. The harness's own `mockBlossom` keeps blobs per browser context, which a conversation's
|
||
|
|
// image cannot use: it is uploaded encrypted and decrypted by the recipient, so the bytes alice puts
|
||
|
|
// in have to be the bytes bob reads back. Holding the upload makes the in-flight state a fact rather
|
||
|
|
// than a race against a mock that answers in a microtask.
|
||
|
|
const serveBlossom = () => {
|
||
|
|
const blobs = new Map<string, {body: Buffer; type: string}>()
|
||
|
|
|
||
|
|
let held = Promise.resolve()
|
||
|
|
let open = () => {}
|
||
|
|
|
||
|
|
return {
|
||
|
|
hold: () => {
|
||
|
|
held = new Promise<void>(resolve => {
|
||
|
|
open = resolve
|
||
|
|
})
|
||
|
|
},
|
||
|
|
release: () => open(),
|
||
|
|
install: (context: BrowserContext) =>
|
||
|
|
context.route(`${BLOSSOM_ORIGIN}/**`, async route => {
|
||
|
|
const request = route.request()
|
||
|
|
const method = request.method()
|
||
|
|
const {pathname} = new URL(request.url())
|
||
|
|
|
||
|
|
if (pathname === "/upload" && method === "PUT") {
|
||
|
|
await held
|
||
|
|
|
||
|
|
const body = request.postDataBuffer() ?? Buffer.alloc(0)
|
||
|
|
const type = request.headers()["content-type"] ?? "application/octet-stream"
|
||
|
|
const sha256 = createHash("sha256").update(body).digest("hex")
|
||
|
|
|
||
|
|
blobs.set(sha256, {body, type})
|
||
|
|
|
||
|
|
const url = `${BLOSSOM_ORIGIN}/${sha256}`
|
||
|
|
|
||
|
|
return route.fulfill({json: {sha256, type, url, size: body.length, uploaded: now()}})
|
||
|
|
}
|
||
|
|
|
||
|
|
const blob = blobs.get(pathname.slice(1).split(".")[0])
|
||
|
|
|
||
|
|
if (blob) {
|
||
|
|
return route.fulfill({contentType: blob.type, body: method === "HEAD" ? "" : blob.body})
|
||
|
|
}
|
||
|
|
|
||
|
|
return route.fallback()
|
||
|
|
}),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Outbox routing resolves everything about a person through their relay list, so a seeded profile
|
||
|
|
// is only loadable by somebody else once its author has one.
|
||
|
|
const seedRelayList = (space: SeededSpace, user: TestUser) =>
|
||
|
|
space.event(user, () =>
|
||
|
|
space
|
||
|
|
.kind(RelayList)
|
||
|
|
.writer()
|
||
|
|
.setReadUrls([space.url])
|
||
|
|
.setWriteUrls([space.url])
|
||
|
|
.renderTemplate(),
|
||
|
|
)
|
||
|
|
|
||
|
|
// The kind-10050 a conversation needs at both ends: the recipient's is what enables the composer,
|
||
|
|
// the sender's is what routes their own copy of the wrap back to them.
|
||
|
|
const seedMessagingRelays = (space: SeededSpace, user: TestUser) =>
|
||
|
|
space.event(user, () =>
|
||
|
|
space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(),
|
||
|
|
)
|
||
|
|
|
||
|
|
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
|
||
|
|
|
||
|
|
// The send button carries the shortcut it advertises, which differs by platform.
|
||
|
|
const sendButton = (page: Page) => page.locator("button[data-tip$='enter to send']")
|
||
|
|
|
||
|
|
const suggestions = (page: Page) => page.locator(".tiptap-suggestions__item")
|
||
|
|
|
||
|
|
const timeline = (page: Page) => page.locator(".room__content")
|
||
|
|
|
||
|
|
// RoomCompose leads with a two button group: upload, then the create menu.
|
||
|
|
const roomUploadButton = (page: Page) => page.locator(".room__compose-inner .join-item").first()
|
||
|
|
|
||
|
|
const chatUploadButton = (page: Page) => page.locator("button[data-tip='Add an image']")
|
||
|
|
|
||
|
|
// Both things the composer puts above itself — the message being replied to and the editing
|
||
|
|
// indicator — are the same bordered strip, each with a single button in it: its X.
|
||
|
|
const banner = (page: Page, text: string) =>
|
||
|
|
page.locator(".room__compose .border-l-2").filter({hasText: text})
|
||
|
|
|
||
|
|
// RoomItem gives its hover actions no accessible names — every one is an icon. Their order is
|
||
|
|
// fixed by the component: zap, emoji, reply, edit (only on your own recent message), menu.
|
||
|
|
const messageActions = (page: Page, text: string) =>
|
||
|
|
page.locator(".room__item").filter({hasText: text}).locator(".room__item-actions button")
|
||
|
|
|
||
|
|
const chooseFile = async (
|
||
|
|
page: Page,
|
||
|
|
button: Locator,
|
||
|
|
file: {name: string; mimeType: string; buffer: Buffer},
|
||
|
|
) => {
|
||
|
|
const chooser = page.waitForEvent("filechooser")
|
||
|
|
|
||
|
|
await button.click()
|
||
|
|
await (await chooser).setFiles(file)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Both of these are DataTransfer dispatches rather than anything native: prosemirror reads the file
|
||
|
|
// straight off the event, and playwright's own dispatchEvent builds a plain Event, which would drop
|
||
|
|
// the dataTransfer the handler needs.
|
||
|
|
const dropImage = (editor: Locator, name: string) =>
|
||
|
|
editor.evaluate(
|
||
|
|
(node, {name, data}) => {
|
||
|
|
const transfer = new DataTransfer()
|
||
|
|
const {left, top, width, height} = node.getBoundingClientRect()
|
||
|
|
|
||
|
|
transfer.items.add(
|
||
|
|
new File([Uint8Array.from(atob(data), c => c.charCodeAt(0))], name, {type: "image/gif"}),
|
||
|
|
)
|
||
|
|
|
||
|
|
node.dispatchEvent(
|
||
|
|
new DragEvent("drop", {
|
||
|
|
bubbles: true,
|
||
|
|
cancelable: true,
|
||
|
|
dataTransfer: transfer,
|
||
|
|
clientX: left + width / 2,
|
||
|
|
clientY: top + height / 2,
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
},
|
||
|
|
{name, data: GIF},
|
||
|
|
)
|
||
|
|
|
||
|
|
const pasteImage = (editor: Locator, name: string) =>
|
||
|
|
editor.evaluate(
|
||
|
|
(node, {name, data}) => {
|
||
|
|
const transfer = new DataTransfer()
|
||
|
|
|
||
|
|
transfer.items.add(
|
||
|
|
new File([Uint8Array.from(atob(data), c => c.charCodeAt(0))], name, {type: "image/gif"}),
|
||
|
|
)
|
||
|
|
|
||
|
|
node.dispatchEvent(
|
||
|
|
new ClipboardEvent("paste", {bubbles: true, cancelable: true, clipboardData: transfer}),
|
||
|
|
)
|
||
|
|
},
|
||
|
|
{name, data: GIF},
|
||
|
|
)
|
||
|
|
|
||
|
|
test("US-056 autocomplete a mention or a room reference", async ({seed, as}) => {
|
||
|
|
const outsider = makeTestUser("bobbin")
|
||
|
|
|
||
|
|
const scenario = await seed(({relay, user, at}) => {
|
||
|
|
const space = relay("space")
|
||
|
|
const other = relay("other")
|
||
|
|
|
||
|
|
space.room("general", {name: "General"})
|
||
|
|
space.room("random", {name: "Random"})
|
||
|
|
space.join(user.alice, "general", "random")
|
||
|
|
space.join(user.bob, "general")
|
||
|
|
space.profile(user.bob, {name: "Bob Roberts", picture: "https://images.test/bob.png"})
|
||
|
|
seedRelayList(space, user.bob)
|
||
|
|
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
||
|
|
|
||
|
|
// Someone whose name matches the same term but who belongs to a different space, so the
|
||
|
|
// dropdown has a non-member to rank below this space's own members.
|
||
|
|
other.room("lounge", {name: "Lounge"})
|
||
|
|
other.join(user.alice, "lounge")
|
||
|
|
other.join(outsider, "lounge")
|
||
|
|
other.profile(outsider, {name: "Bobbin Amaranth"})
|
||
|
|
seedRelayList(other, outsider)
|
||
|
|
other.message(outsider, "lounge", "hello from the lounge", at(2, HOUR))
|
||
|
|
})
|
||
|
|
|
||
|
|
const space = scenario.space("space")
|
||
|
|
const other = scenario.space("other")
|
||
|
|
|
||
|
|
// Alice arrives through the other space so that the outsider's profile is in her client before
|
||
|
|
// she composes: a profile reaches her by being rendered, and his never renders in the space she
|
||
|
|
// is about to type in.
|
||
|
|
const page = await as(users.alice, roomPath(other.url, "lounge"))
|
||
|
|
|
||
|
|
await expect(timeline(page).getByText("Bobbin Amaranth")).toBeVisible()
|
||
|
|
|
||
|
|
// A space's nav item is labeled with the name its nip-11 document reports, and the two tenants
|
||
|
|
// report "space" and "other".
|
||
|
|
await page.locator('.primary-nav [data-tip="space"]').click()
|
||
|
|
await page.locator(".secondary-nav").getByRole("link", {name: "General"}).click()
|
||
|
|
|
||
|
|
await expect(timeline(page).getByText("morning all")).toBeVisible()
|
||
|
|
|
||
|
|
const editor = composer(page)
|
||
|
|
|
||
|
|
await editor.click()
|
||
|
|
await editor.pressSequentially("@bob")
|
||
|
|
|
||
|
|
// This space's own member first, the outsider from the other space under him.
|
||
|
|
await expect(suggestions(page)).toContainText(["Bob Roberts", "Bobbin Amaranth"])
|
||
|
|
|
||
|
|
await editor.pressSequentially("bin")
|
||
|
|
|
||
|
|
await expect(suggestions(page)).toContainText(["Bobbin Amaranth"])
|
||
|
|
|
||
|
|
// Back to "@bob", where both of them are on offer again.
|
||
|
|
await editor.press("Backspace")
|
||
|
|
await editor.press("Backspace")
|
||
|
|
await editor.press("Backspace")
|
||
|
|
|
||
|
|
await expect(suggestions(page)).toContainText(["Bob Roberts", "Bobbin Amaranth"])
|
||
|
|
|
||
|
|
await suggestions(page).filter({hasText: "Bob Roberts"}).click()
|
||
|
|
|
||
|
|
const mention = editor.locator(".tiptap-object")
|
||
|
|
|
||
|
|
await expect(mention).toHaveText("@Bob Roberts")
|
||
|
|
await expect(mention.locator("img")).toBeVisible()
|
||
|
|
|
||
|
|
await editor.pressSequentially("are you around?")
|
||
|
|
await editor.press("Enter")
|
||
|
|
|
||
|
|
const sent = page.locator(".room__item").filter({hasText: "are you around?"})
|
||
|
|
const sentMention = sent.getByRole("button", {name: "@Bob Roberts"})
|
||
|
|
|
||
|
|
await expect(sentMention).toBeVisible()
|
||
|
|
await expect(sentMention.locator("img")).toBeVisible()
|
||
|
|
|
||
|
|
await editor.pressSequentially("~gene")
|
||
|
|
|
||
|
|
await expect(suggestions(page)).toContainText(["General"])
|
||
|
|
|
||
|
|
await suggestions(page).filter({hasText: "General"}).click()
|
||
|
|
|
||
|
|
await expect(editor.locator(".tiptap-object")).toContainText("General")
|
||
|
|
|
||
|
|
await editor.press("Enter")
|
||
|
|
|
||
|
|
await expect(timeline(page).getByRole("link", {name: "#General"})).toHaveAttribute(
|
||
|
|
"href",
|
||
|
|
roomPath(space.url, "general"),
|
||
|
|
)
|
||
|
|
})
|
||
|
|
|
||
|
|
test("US-057 attach and send an image", 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.message(user.bob, "general", "morning all", at(2, HOUR))
|
||
|
|
|
||
|
|
for (const person of [user.alice, user.bob]) {
|
||
|
|
space.profile(person, {name: person.name})
|
||
|
|
seedRelayList(space, person)
|
||
|
|
seedMessagingRelays(space, person)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
const path = roomPath(scenario.space("space").url, "general")
|
||
|
|
const blossom = serveBlossom()
|
||
|
|
|
||
|
|
const alice = await as(users.alice, path)
|
||
|
|
const bob = await as(users.bob, path)
|
||
|
|
|
||
|
|
await blossom.install(alice.context())
|
||
|
|
await blossom.install(bob.context())
|
||
|
|
|
||
|
|
await expect(timeline(alice).getByText("morning all")).toBeVisible()
|
||
|
|
|
||
|
|
blossom.hold()
|
||
|
|
|
||
|
|
await chooseFile(alice, roomUploadButton(alice), gifFile("photo.gif"))
|
||
|
|
|
||
|
|
await expect(alice.locator(".room__compose-inner .spinner")).toBeVisible()
|
||
|
|
await expect(sendButton(alice)).toBeDisabled()
|
||
|
|
|
||
|
|
blossom.release()
|
||
|
|
|
||
|
|
await expect(sendButton(alice)).toBeEnabled()
|
||
|
|
await expect(composer(alice)).toContainText("photo.gif")
|
||
|
|
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
|
||
|
|
|
||
|
|
await composer(alice).press("Enter")
|
||
|
|
|
||
|
|
await expect(timeline(alice).locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
||
|
|
await expect(timeline(bob).locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
||
|
|
|
||
|
|
await dropImage(composer(alice), "dropped.gif")
|
||
|
|
|
||
|
|
await expect(composer(alice)).toContainText("dropped.gif")
|
||
|
|
|
||
|
|
await pasteImage(composer(alice), "pasted.gif")
|
||
|
|
|
||
|
|
await expect(composer(alice)).toContainText("pasted.gif")
|
||
|
|
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
|
||
|
|
|
||
|
|
// The same thing in a conversation. Its composer stays disabled until the recipient's messaging
|
||
|
|
// relays have been read, which is what waiting on the send button waits out.
|
||
|
|
await alice.goto(chatPath(users.bob.pubkey))
|
||
|
|
await bob.goto(chatPath(users.alice.pubkey))
|
||
|
|
|
||
|
|
await expect(sendButton(alice)).toBeEnabled()
|
||
|
|
|
||
|
|
await chooseFile(alice, chatUploadButton(alice), gifFile("selfie.gif"))
|
||
|
|
|
||
|
|
await expect(composer(alice)).toContainText("selfie.gif")
|
||
|
|
|
||
|
|
// The file node appears the moment it is attached, which is also the moment `uploading` goes
|
||
|
|
// true — and submit returns without a word while it is. Encryption makes that window wide enough
|
||
|
|
// to press enter into, so wait the upload out rather than losing the message to it.
|
||
|
|
await expect(sendButton(alice)).toBeEnabled()
|
||
|
|
|
||
|
|
await composer(alice).press("Enter")
|
||
|
|
|
||
|
|
// A conversation's image is uploaded encrypted, so the recipient fetches the ciphertext and
|
||
|
|
// decrypts it into a blob url rather than pointing an <img> at the server.
|
||
|
|
await expect(bob.locator('.chat-bubble img[src^="blob:"]')).toBeVisible()
|
||
|
|
|
||
|
|
// Back to the room on a fresh page, so no toast the conversation raised is still standing and the
|
||
|
|
// one asserted below can only be the rejected upload's own.
|
||
|
|
await alice.goto(path)
|
||
|
|
|
||
|
|
await expect(timeline(alice).getByText("morning all")).toBeVisible()
|
||
|
|
await expect(alice.getByRole("alert")).toHaveCount(0)
|
||
|
|
|
||
|
|
await chooseFile(alice, roomUploadButton(alice), {
|
||
|
|
name: "notes.txt",
|
||
|
|
mimeType: "text/plain",
|
||
|
|
buffer: Buffer.from("not an image"),
|
||
|
|
})
|
||
|
|
|
||
|
|
await expect(alice.getByRole("alert")).toBeVisible()
|
||
|
|
await expect(composer(alice)).not.toContainText("notes.txt")
|
||
|
|
})
|
||
|
|
|
||
|
|
test("US-058 drafts survive navigating away", async ({seed, as}) => {
|
||
|
|
await seed(({relay, user, at}) => {
|
||
|
|
const space = relay("space")
|
||
|
|
|
||
|
|
space.room("general", {name: "General"})
|
||
|
|
space.room("random", {name: "Random"})
|
||
|
|
space.join(user.alice, "general", "random")
|
||
|
|
space.join(user.bob, "general")
|
||
|
|
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
||
|
|
|
||
|
|
// Alice's own kind-10050 is what lets the Messages nav item navigate rather than stop to ask
|
||
|
|
// her to enable chat; bob's is what enables the conversation's composer.
|
||
|
|
for (const person of [user.alice, user.bob]) {
|
||
|
|
space.profile(person, {name: person.name})
|
||
|
|
seedRelayList(space, person)
|
||
|
|
seedMessagingRelays(space, person)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
// Drafts live in memory, so every move here is an in-app navigation — a reload would clear them
|
||
|
|
// whether or not they were kept.
|
||
|
|
const page = await as(users.alice, chatPath(users.bob.pubkey))
|
||
|
|
const editor = composer(page)
|
||
|
|
const rooms = page.locator(".secondary-nav")
|
||
|
|
|
||
|
|
await expect(sendButton(page)).toBeEnabled()
|
||
|
|
|
||
|
|
await editor.click()
|
||
|
|
await editor.pressSequentially("still thinking about this")
|
||
|
|
|
||
|
|
await page.locator('.primary-nav [data-tip="space"]').click()
|
||
|
|
await rooms.getByRole("link", {name: "General"}).click()
|
||
|
|
|
||
|
|
await expect(timeline(page).getByText("morning all")).toBeVisible()
|
||
|
|
await expect(editor).toHaveText("")
|
||
|
|
|
||
|
|
await editor.click()
|
||
|
|
await editor.pressSequentially("half a thought")
|
||
|
|
|
||
|
|
await rooms.getByRole("link", {name: "Random"}).click()
|
||
|
|
|
||
|
|
await expect(editor).toHaveText("")
|
||
|
|
|
||
|
|
await rooms.getByRole("link", {name: "General"}).click()
|
||
|
|
|
||
|
|
await expect(editor).toHaveText("half a thought")
|
||
|
|
|
||
|
|
await page.locator('.primary-nav [data-tip="Messages"]').click()
|
||
|
|
|
||
|
|
await expect(editor).toHaveText("still thinking about this")
|
||
|
|
|
||
|
|
await page.locator('.primary-nav [data-tip="space"]').click()
|
||
|
|
await rooms.getByRole("link", {name: "General"}).click()
|
||
|
|
|
||
|
|
await expect(editor).toHaveText("half a thought")
|
||
|
|
|
||
|
|
await editor.press("Enter")
|
||
|
|
|
||
|
|
await expect(timeline(page).getByText("half a thought")).toBeVisible()
|
||
|
|
await expect(editor).toHaveText("")
|
||
|
|
|
||
|
|
await rooms.getByRole("link", {name: "Random"}).click()
|
||
|
|
await rooms.getByRole("link", {name: "General"}).click()
|
||
|
|
|
||
|
|
await expect(editor).toHaveText("")
|
||
|
|
})
|
||
|
|
|
||
|
|
test("US-059 cancel a reply or an edit in progress", 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.bob, {name: "Bob Roberts"})
|
||
|
|
seedRelayList(space, user.bob)
|
||
|
|
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
||
|
|
})
|
||
|
|
|
||
|
|
const page = await as(users.alice, roomPath(scenario.space("space").url, "general"))
|
||
|
|
const editor = composer(page)
|
||
|
|
const replyBanner = banner(page, "Replying to @Bob Roberts")
|
||
|
|
const editBanner = banner(page, "Editing message")
|
||
|
|
|
||
|
|
await expect(timeline(page).getByText("morning all")).toBeVisible()
|
||
|
|
|
||
|
|
// Only a message alice sent herself in the last five minutes offers an edit, so she sends one.
|
||
|
|
await editor.click()
|
||
|
|
await editor.pressSequentially("my first message")
|
||
|
|
await editor.press("Enter")
|
||
|
|
|
||
|
|
await expect(timeline(page).getByText("my first message")).toBeVisible()
|
||
|
|
|
||
|
|
await editor.pressSequentially("half-written thought")
|
||
|
|
|
||
|
|
// Bob's message offers zap, emoji, reply and the menu; alice's own also offers the edit.
|
||
|
|
await expect(messageActions(page, "morning all")).toHaveCount(4)
|
||
|
|
await expect(messageActions(page, "my first message")).toHaveCount(5)
|
||
|
|
|
||
|
|
await messageActions(page, "morning all").nth(2).click()
|
||
|
|
|
||
|
|
await expect(replyBanner).toContainText("morning all")
|
||
|
|
|
||
|
|
await editor.press("Escape")
|
||
|
|
|
||
|
|
await expect(replyBanner).toHaveCount(0)
|
||
|
|
await expect(editor).toHaveText("half-written thought")
|
||
|
|
|
||
|
|
await messageActions(page, "morning all").nth(2).click()
|
||
|
|
|
||
|
|
await expect(replyBanner).toBeVisible()
|
||
|
|
|
||
|
|
await replyBanner.getByRole("button").click()
|
||
|
|
|
||
|
|
await expect(replyBanner).toHaveCount(0)
|
||
|
|
await expect(editor).toHaveText("half-written thought")
|
||
|
|
|
||
|
|
await messageActions(page, "my first message").nth(3).click()
|
||
|
|
|
||
|
|
await expect(editBanner).toBeVisible()
|
||
|
|
await expect(editor).toHaveText("my first message")
|
||
|
|
|
||
|
|
await editor.press("Escape")
|
||
|
|
|
||
|
|
await expect(editBanner).toHaveCount(0)
|
||
|
|
await expect(editor).toHaveText("half-written thought")
|
||
|
|
|
||
|
|
await messageActions(page, "my first message").nth(3).click()
|
||
|
|
|
||
|
|
await expect(editBanner).toBeVisible()
|
||
|
|
|
||
|
|
await editBanner.getByRole("button").click()
|
||
|
|
|
||
|
|
await expect(editBanner).toHaveCount(0)
|
||
|
|
await expect(editor).toHaveText("half-written thought")
|
||
|
|
|
||
|
|
// Neither message was touched, and the draft never left the composer.
|
||
|
|
await expect(timeline(page).getByText("morning all")).toHaveCount(1)
|
||
|
|
await expect(timeline(page).getByText("my first message")).toHaveCount(1)
|
||
|
|
await expect(timeline(page).getByText("half-written thought")).toHaveCount(0)
|
||
|
|
})
|