2026-09-10 03:24:06 +00:00
|
|
|
import type {Locator, Page} from "@playwright/test"
|
|
|
|
|
import {HOUR} from "@welshman/lib"
|
2026-09-10 13:54:16 +00:00
|
|
|
import {
|
|
|
|
|
DEFAULT_BLOSSOM_ORIGIN,
|
|
|
|
|
GIF_BASE64,
|
|
|
|
|
chatPath,
|
|
|
|
|
chooseFile,
|
|
|
|
|
composer,
|
|
|
|
|
composerEnabled,
|
2026-09-15 17:59:36 +00:00
|
|
|
dialog,
|
2026-09-10 13:54:16 +00:00
|
|
|
expect,
|
|
|
|
|
gifFile,
|
|
|
|
|
makeTestUser,
|
|
|
|
|
messageActions,
|
|
|
|
|
mockBlossom,
|
2026-09-15 17:59:36 +00:00
|
|
|
mockOpenRouterTranscription,
|
|
|
|
|
roomLink,
|
2026-09-10 13:54:16 +00:00
|
|
|
roomPath,
|
|
|
|
|
sendButton,
|
|
|
|
|
test,
|
|
|
|
|
timeline,
|
|
|
|
|
users,
|
|
|
|
|
} from "../harness"
|
2026-09-08 18:17:30 +00:00
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
const suggestions = (page: Page) => page.locator(".tiptap-suggestions__item")
|
|
|
|
|
|
|
|
|
|
// 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']")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// The reply preview and the editing indicator are the same bordered strip, each with its own X.
|
2026-08-20 20:33:20 +00:00
|
|
|
const banner = (page: Page, text: string) =>
|
|
|
|
|
page.locator(".room__compose .border-l-2").filter({hasText: text})
|
|
|
|
|
|
|
|
|
|
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}),
|
|
|
|
|
)
|
|
|
|
|
},
|
2026-09-10 13:54:16 +00:00
|
|
|
{name, data: GIF_BASE64},
|
2026-08-20 20:33:20 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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"})
|
2026-09-10 13:54:16 +00:00
|
|
|
space.relayList(user.bob)
|
2026-08-20 20:33:20 +00:00
|
|
|
space.message(user.bob, "general", "morning all", at(2, HOUR))
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Someone matching the same term from a different space, to rank below this space's own members.
|
2026-08-20 20:33:20 +00:00
|
|
|
other.room("lounge", {name: "Lounge"})
|
|
|
|
|
other.join(user.alice, "lounge")
|
|
|
|
|
other.join(outsider, "lounge")
|
|
|
|
|
other.profile(outsider, {name: "Bobbin Amaranth"})
|
2026-09-10 13:54:16 +00:00
|
|
|
other.relayList(outsider)
|
2026-08-20 20:33:20 +00:00
|
|
|
other.message(outsider, "lounge", "hello from the lounge", at(2, HOUR))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const space = scenario.space("space")
|
|
|
|
|
const other = scenario.space("other")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A profile reaches her by being rendered, and his never renders in the space she is typing in.
|
2026-08-20 20:33:20 +00:00
|
|
|
const page = await as(users.alice, roomPath(other.url, "lounge"))
|
|
|
|
|
|
2026-08-22 20:12:11 +00:00
|
|
|
// Exactly, since the join notice above his message carries his name too, as "@Bobbin Amaranth".
|
|
|
|
|
await expect(
|
|
|
|
|
timeline(page).getByRole("button", {name: "Bobbin Amaranth", exact: true}),
|
|
|
|
|
).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A space's nav item is labeled with the name its nip-11 document reports.
|
2026-08-20 20:33:20 +00:00
|
|
|
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 suggestions(page).filter({hasText: "Bob Roberts"}).click()
|
|
|
|
|
|
|
|
|
|
const mention = editor.locator(".tiptap-object")
|
|
|
|
|
|
|
|
|
|
await expect(mention).toHaveText("@Bob Roberts")
|
|
|
|
|
|
|
|
|
|
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 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")
|
|
|
|
|
|
2026-08-22 17:55:06 +00:00
|
|
|
await expect(timeline(page).getByRole("link", {name: /#\s*General/})).toHaveAttribute(
|
2026-08-20 20:33:20 +00:00
|
|
|
"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})
|
2026-09-10 13:54:16 +00:00
|
|
|
space.relayList(person)
|
|
|
|
|
space.messagingRelayList(person)
|
2026-08-20 20:33:20 +00:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const path = roomPath(scenario.space("space").url, "general")
|
|
|
|
|
|
|
|
|
|
const alice = await as(users.alice, path)
|
|
|
|
|
const bob = await as(users.bob, path)
|
|
|
|
|
|
2026-09-10 13:54:16 +00:00
|
|
|
const blossom = await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
|
2026-09-10 03:24:06 +00:00
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
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(sendButton(alice)).toBeDisabled()
|
|
|
|
|
|
|
|
|
|
blossom.release()
|
|
|
|
|
|
|
|
|
|
await expect(sendButton(alice)).toBeEnabled()
|
|
|
|
|
await expect(composer(alice)).toContainText("photo.gif")
|
|
|
|
|
|
|
|
|
|
await composer(alice).press("Enter")
|
|
|
|
|
|
2026-09-10 13:54:16 +00:00
|
|
|
await expect(timeline(alice).locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
|
|
|
|
await expect(timeline(bob).locator(`img[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await pasteImage(composer(alice), "pasted.gif")
|
|
|
|
|
|
|
|
|
|
await expect(composer(alice)).toContainText("pasted.gif")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Its composer stays disabled until the recipient's messaging relays have been read.
|
2026-08-20 20:33:20 +00:00
|
|
|
await alice.goto(chatPath(users.bob.pubkey))
|
|
|
|
|
await bob.goto(chatPath(users.alice.pubkey))
|
|
|
|
|
|
2026-09-08 18:17:30 +00:00
|
|
|
await composerEnabled(alice)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await chooseFile(alice, chatUploadButton(alice), gifFile("selfie.gif"))
|
|
|
|
|
|
|
|
|
|
await expect(composer(alice)).toContainText("selfie.gif")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Submit returns without a word while `uploading` is true, and encryption makes that window wide.
|
2026-08-20 20:33:20 +00:00
|
|
|
await expect(sendButton(alice)).toBeEnabled()
|
|
|
|
|
|
|
|
|
|
await composer(alice).press("Enter")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A conversation's image is uploaded encrypted, so the recipient decrypts it into a blob url.
|
2026-08-20 20:33:20 +00:00
|
|
|
await expect(bob.locator('.chat-bubble img[src^="blob:"]')).toBeVisible()
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Back to the room on a fresh page, so no toast the conversation raised is still standing.
|
2026-08-20 20:33:20 +00:00
|
|
|
await alice.goto(path)
|
|
|
|
|
|
|
|
|
|
await expect(timeline(alice).getByText("morning all")).toBeVisible()
|
|
|
|
|
await expect(alice.getByRole("alert")).toHaveCount(0)
|
|
|
|
|
|
2026-09-10 01:35:59 +00:00
|
|
|
// What the server refuses is what the composer refuses, in the words the server used.
|
|
|
|
|
blossom.refuse("text files are not allowed here")
|
|
|
|
|
|
|
|
|
|
await chooseFile(alice, roomUploadButton(alice), {
|
2026-09-25 21:16:50 +00:00
|
|
|
name: "notes.txt",
|
2026-09-10 01:35:59 +00:00
|
|
|
mimeType: "text/plain",
|
2026-09-25 21:16:50 +00:00
|
|
|
buffer: Buffer.from("not an image"),
|
2026-09-10 01:35:59 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByRole("alert")).toContainText("text files are not allowed here")
|
2026-08-20 20:33:20 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Her kind-10050 lets the Messages nav navigate; his enables the conversation's composer.
|
2026-08-20 20:33:20 +00:00
|
|
|
for (const person of [user.alice, user.bob]) {
|
|
|
|
|
space.profile(person, {name: person.name})
|
2026-09-10 13:54:16 +00:00
|
|
|
space.relayList(person)
|
|
|
|
|
space.messagingRelayList(person)
|
2026-08-20 20:33:20 +00:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Drafts live in memory, so every move here is an in-app navigation.
|
2026-08-20 20:33:20 +00:00
|
|
|
const page = await as(users.alice, chatPath(users.bob.pubkey))
|
|
|
|
|
const editor = composer(page)
|
|
|
|
|
const rooms = page.locator(".secondary-nav")
|
|
|
|
|
|
2026-09-08 18:17:30 +00:00
|
|
|
await composerEnabled(page)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// The composer is remounted around a restored draft, so put the caret in it before sending.
|
2026-09-09 01:07:48 +00:00
|
|
|
await editor.click()
|
2026-08-20 20:33:20 +00:00
|
|
|
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"})
|
2026-09-10 13:54:16 +00:00
|
|
|
space.relayList(user.bob)
|
2026-08-20 20:33:20 +00:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
})
|
2026-09-15 17:59:36 +00:00
|
|
|
|
|
|
|
|
// Dictation's two buttons are one button in two states, so the label is what says which.
|
|
|
|
|
const dictateButton = (page: Page) => page.getByRole("button", {name: "Start dictation"})
|
|
|
|
|
|
|
|
|
|
const stopButton = (page: Page) => page.getByRole("button", {name: "Stop recording"})
|
|
|
|
|
|
2026-09-16 00:27:54 +00:00
|
|
|
// Every recording ends at the same question, so each spec starts from the answer it is about.
|
|
|
|
|
const record = async (page: Page) => {
|
|
|
|
|
await dictateButton(page).click()
|
|
|
|
|
await expect(stopButton(page)).toBeVisible()
|
|
|
|
|
await stopButton(page).click()
|
|
|
|
|
|
|
|
|
|
return dialog(page, "Transcribe or send?")
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-15 17:59:36 +00:00
|
|
|
test("US-125 dictate a message", async ({seed, as}) => {
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
space.room("lounge", {name: "Lounge"})
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
space.join(user.alice, "lounge")
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
const alice = await as(users.alice, roomPath(url, "general"))
|
|
|
|
|
const transcription = await mockOpenRouterTranscription(alice.context(), "the tide turns at six")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Recording asks for nothing. Asking for a transcript with no key saved asks for one.
|
2026-09-16 00:27:54 +00:00
|
|
|
const action = await record(alice)
|
|
|
|
|
|
|
|
|
|
await action.getByRole("button", {name: "Transcribe it"}).click()
|
2026-09-15 17:59:36 +00:00
|
|
|
|
|
|
|
|
const enable = dialog(alice, "Enable voice input?")
|
|
|
|
|
|
|
|
|
|
await enable.locator('input[name="flotilla-openrouter-key"]').fill("sk-or-test")
|
|
|
|
|
await enable.getByRole("button", {name: "Enable voice input"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByRole("alert")).toContainText("Voice input is ready to use!")
|
|
|
|
|
|
2026-09-16 00:27:54 +00:00
|
|
|
// The recording is still waiting behind that prompt for the answer it asked for.
|
|
|
|
|
await action.getByRole("button", {name: "Transcribe it"}).click()
|
2026-09-15 17:59:36 +00:00
|
|
|
|
|
|
|
|
await expect(composer(alice)).toContainText("the tide turns at six")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// OpenRouter picks its decoder off the extension, so the upload has to name the format.
|
2026-09-15 17:59:36 +00:00
|
|
|
expect(transcription.uploads).toEqual([expect.stringMatching(/^dictation\.\w+$/)])
|
|
|
|
|
|
|
|
|
|
await composer(alice).press("Enter")
|
|
|
|
|
|
|
|
|
|
await expect(timeline(alice)).toContainText("the tide turns at six")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A transcription outlives the composer that asked for it, and waits for whichever comes next.
|
2026-09-15 17:59:36 +00:00
|
|
|
transcription.hold()
|
|
|
|
|
|
2026-09-16 00:27:54 +00:00
|
|
|
await (await record(alice)).getByRole("button", {name: "Transcribe it"}).click()
|
2026-09-15 17:59:36 +00:00
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A dictation is held by the app rather than by the composer, so reloading the page is losing it.
|
2026-09-15 17:59:36 +00:00
|
|
|
await roomLink(alice, "Lounge").click()
|
|
|
|
|
|
|
|
|
|
await expect(composer(alice)).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await roomLink(alice, "General").click()
|
|
|
|
|
|
|
|
|
|
transcription.release()
|
|
|
|
|
|
|
|
|
|
await expect(composer(alice)).toContainText("the tide turns at six")
|
|
|
|
|
})
|
2026-09-16 00:27:54 +00:00
|
|
|
|
|
|
|
|
test("US-126 send a voice note", async ({seed, as}) => {
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
const alice = await as(users.alice, roomPath(url, "general"))
|
|
|
|
|
|
|
|
|
|
await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Leaving the question unanswered throws the recording away.
|
2026-09-16 00:27:54 +00:00
|
|
|
await (await record(alice)).getByRole("button", {name: "Discard"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(dialog(alice, "Transcribe or send?")).toHaveCount(0)
|
|
|
|
|
await expect(composer(alice)).toHaveText("")
|
|
|
|
|
|
|
|
|
|
// Sending the recording as it is needs no OpenRouter key, only somewhere to upload it.
|
|
|
|
|
await (await record(alice)).getByRole("button", {name: "Send a voice note"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(composer(alice)).toContainText(DEFAULT_BLOSSOM_ORIGIN)
|
|
|
|
|
|
|
|
|
|
await composer(alice).press("Enter")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// The imeta on the message says the upload is audio, which gives it a player rather than a link.
|
2026-09-16 00:27:54 +00:00
|
|
|
await expect(timeline(alice).locator(`audio[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
|
|
|
|
})
|