2026-09-10 13:54:16 +00:00
|
|
|
import {expect} from "@playwright/test"
|
|
|
|
|
import type {Locator, Page} from "@playwright/test"
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* What a spec names on screen. A locator more than one spec reaches for belongs here, so a class or
|
|
|
|
|
* a label the app renames is one edit rather than six — and so the comment explaining why a
|
|
|
|
|
* locator is shaped the way it is has one copy to keep true.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export const dialog = (page: Page, title: string) =>
|
2026-09-12 17:03:24 +00:00
|
|
|
page.getByRole("dialog", {name: title, exact: true})
|
2026-09-10 13:54:16 +00:00
|
|
|
|
|
|
|
|
// The modal on top, for one with no heading of its own or one pushed over another rather than
|
|
|
|
|
// alongside it.
|
2026-09-12 17:03:24 +00:00
|
|
|
export const topDialog = (page: Page) => page.getByRole("dialog").last()
|
2026-09-10 13:54:16 +00:00
|
|
|
|
|
|
|
|
// A modal is mounted alongside the page it covers, so a page's own "Create" and the modal's submit
|
|
|
|
|
// are both in the dom at once. Anything said about the form is scoped to the modal's own to say
|
|
|
|
|
// which one is meant.
|
|
|
|
|
export const modalForm = (page: Page, title: string) =>
|
|
|
|
|
page.locator("form").filter({has: page.getByRole("heading", {name: title})})
|
|
|
|
|
|
2026-09-15 18:52:11 +00:00
|
|
|
// Named rather than counted into the join EventActions renders, since a card is free to put a join
|
|
|
|
|
// of its own above it — a calendar event's rsvp buttons are one.
|
|
|
|
|
export const emojiButton = (scope: Locator) => scope.getByRole("button", {name: "Add a reaction"})
|
2026-09-10 13:54:16 +00:00
|
|
|
|
2026-09-15 18:52:11 +00:00
|
|
|
// The menu is the one action with no accessible name, and the last thing in the last join a card
|
|
|
|
|
// has.
|
2026-09-10 13:54:16 +00:00
|
|
|
export const menuButton = (scope: Locator) => scope.locator(".join").getByRole("button").last()
|
|
|
|
|
|
|
|
|
|
// The picker is a web component with an open shadow root, so its search field and its results are
|
|
|
|
|
// reachable through it. Searching rather than browsing avoids depending on which category tab an
|
|
|
|
|
// emoji happens to live under.
|
|
|
|
|
export const pickEmoji = async (page: Page, opener: Locator, annotation: string) => {
|
|
|
|
|
await opener.click()
|
|
|
|
|
|
|
|
|
|
const picker = page.locator("emoji-picker").filter({visible: true})
|
|
|
|
|
|
|
|
|
|
// Tippy keeps a hidden popover mounted through its fade — a quarter of a second during which the
|
|
|
|
|
// picker from the last card is still visible alongside this one — so wait for there to be one
|
|
|
|
|
// rather than reaching into whichever resolves first.
|
|
|
|
|
await expect(picker).toHaveCount(1)
|
|
|
|
|
|
|
|
|
|
// A result's label is the emoji's name, its annotation and every shortcode joined together, so
|
|
|
|
|
// the annotation is matched rather than the whole of it.
|
|
|
|
|
await picker.locator("input.search").fill(annotation)
|
|
|
|
|
await picker
|
|
|
|
|
.getByRole("option", {name: new RegExp(annotation)})
|
|
|
|
|
.first()
|
|
|
|
|
.click()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
|
|
|
|
|
|
2026-09-12 17:03:24 +00:00
|
|
|
export const openRoomDetail = (page: Page) =>
|
|
|
|
|
pageBar(page).getByRole("button", {name: "Room details"}).click()
|
2026-09-10 13:54:16 +00:00
|
|
|
|
|
|
|
|
export const roomLink = (page: Page, name: string) =>
|
|
|
|
|
page.locator(".space-menu__scroll").getByRole("link", {name})
|
|
|
|
|
|
|
|
|
|
// One toast at a time — src/app/toast.ts holds a single writable — so this is the toast.
|
|
|
|
|
export const toast = (page: Page) => page.getByRole("alert")
|
|
|
|
|
|
|
|
|
|
// FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row, with the
|
|
|
|
|
// label at one end and the control at the other.
|
|
|
|
|
export const settingRow = (page: Page, label: string) =>
|
|
|
|
|
page.locator("div.items-center.justify-between").filter({hasText: label})
|
|
|
|
|
|
|
|
|
|
export const settingToggle = (page: Page, label: string) =>
|
|
|
|
|
settingRow(page, label).getByRole("checkbox")
|
|
|
|
|
|
|
|
|
|
export const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
|
|
|
|
|
|
|
|
|
|
// The editor is where the composer says whether it is ready. The send button is not there to ask
|
|
|
|
|
// while the composer is empty, since a dictation button stands in its place. Only the conversation
|
|
|
|
|
// composer has a disabled state; a room's is usable as soon as it renders.
|
|
|
|
|
export const composerEnabled = (page: Page) =>
|
|
|
|
|
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "false")
|
|
|
|
|
|
|
|
|
|
export const composerDisabled = (page: Page) =>
|
|
|
|
|
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "true")
|
|
|
|
|
|
|
|
|
|
// The send button carries the shortcut it advertises, which differs by platform.
|
|
|
|
|
export const sendButton = (page: Page) => page.locator("button[data-tip$='enter to send']")
|
|
|
|
|
|
|
|
|
|
export const timeline = (page: Page) => page.locator(".room__content")
|
|
|
|
|
|
|
|
|
|
// .room__content is column-reverse, so the message at the bottom of the room is the first one in
|
|
|
|
|
// the dom.
|
|
|
|
|
export const messages = (page: Page) => page.locator(".room__item")
|
|
|
|
|
|
|
|
|
|
export const message = (page: Page, text: string) => messages(page).filter({hasText: text})
|
|
|
|
|
|
|
|
|
|
export const messageActions = (page: Page, text: string) =>
|
|
|
|
|
message(page, text).locator(".room__item-actions button")
|
|
|
|
|
|
|
|
|
|
export const openMessageMenu = (page: Page, text: string) =>
|
2026-09-12 17:03:24 +00:00
|
|
|
message(page, text).getByRole("button", {name: "More options"}).click()
|
2026-09-10 13:54:16 +00:00
|
|
|
|
|
|
|
|
export const bubble = (page: Page, text: string) =>
|
|
|
|
|
page.locator(".chat-bubble").filter({hasText: text})
|
|
|
|
|
|
|
|
|
|
// Typing into a room's composer and sending it. The composer is waited for rather than assumed,
|
|
|
|
|
// since a room still rendering has none, and clicked into when the caret is somewhere else, since
|
|
|
|
|
// typing goes wherever it is. One that already holds the caret is typed into as it stands: clicking
|
|
|
|
|
// would collapse the selection, and selecting the whole of it is how an edit types over the message
|
|
|
|
|
// it replaces.
|
|
|
|
|
export const send = async (page: Page, content: string) => {
|
|
|
|
|
const editor = composer(page)
|
|
|
|
|
|
|
|
|
|
await expect(editor).toBeVisible()
|
|
|
|
|
|
|
|
|
|
const elsewhere = await editor.evaluate(el => el !== document.activeElement)
|
|
|
|
|
|
|
|
|
|
if (elsewhere) {
|
|
|
|
|
await editor.click()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await editor.pressSequentially(content)
|
|
|
|
|
await editor.press("Enter")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const chatList = (page: Page) => page.locator(".secondary-nav .overflow-auto")
|
|
|
|
|
|
|
|
|
|
// One conversation in the sidebar list is one button; nothing inside it is one.
|
|
|
|
|
export const chatItems = (page: Page) => chatList(page).locator("button")
|
|
|
|
|
|
|
|
|
|
// The comment and thread composers are rich text rather than chat editors, so they carry a
|
|
|
|
|
// different one.
|
|
|
|
|
export const noteEditor = (scope: Locator | Page) =>
|
|
|
|
|
scope.locator(".note-editor [contenteditable=true]")
|
|
|
|
|
|
|
|
|
|
// A date as the browser formatted it rather than as node would, so the locale and the timezone are
|
|
|
|
|
// the ones the app rendered with. The options mirror dateFormatter in @welshman/lib.
|
|
|
|
|
export const longDate = (page: Page, seconds: number) =>
|
|
|
|
|
page.evaluate(
|
|
|
|
|
ts =>
|
|
|
|
|
new Intl.DateTimeFormat(undefined, {year: "numeric", month: "long", day: "numeric"}).format(
|
|
|
|
|
new Date(ts * 1000),
|
|
|
|
|
),
|
|
|
|
|
seconds,
|
|
|
|
|
)
|