581 lines
22 KiB
TypeScript
581 lines
22 KiB
TypeScript
import {neventEncode, npubEncode} from "nostr-tools/nip19"
|
|
import {HOUR, MINUTE} from "@welshman/lib"
|
|
import {displayRelayUrl} from "@welshman/util"
|
|
import {MessagingRelayList, RelayList, Thread} from "@welshman/domain"
|
|
import type {Locator, Page} from "@playwright/test"
|
|
import {expect, roomPath, spacePath, test, users} from "../harness"
|
|
import type {SeededSpace, TestUser} from "../harness"
|
|
|
|
// A handle to a seeded event. SeededEvent isn't exported from the harness, and only its id is ever
|
|
// read back here.
|
|
type Seeded = {readonly id: string}
|
|
|
|
// A literal as a pattern, for a url that carries a query string alongside the path being matched,
|
|
// or a host whose dots would otherwise be wildcards.
|
|
const pattern = (literal: string) => new RegExp(literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
|
|
// The unread indicator, which every surface renders the same way: a small primary-colored dot in
|
|
// the corner of the thing it belongs to. RelaySummary's "you're a member" check is the same shape
|
|
// at h-5 w-5, so the size is part of what says which one this is.
|
|
const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary")
|
|
|
|
// The bell SpaceMenuRoomItem hangs off a room whose notification setting differs from its space's.
|
|
// An icon is a css mask built from a data url, so which bell it is can't be read out of the class
|
|
// list — but it only renders on a difference, and while the space itself is still notifying the
|
|
// only difference a room can have is a mute.
|
|
const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50")
|
|
|
|
// The bell SpaceMenuHeader puts beside the space's name once the space itself is muted. The only
|
|
// other absolutely-positioned dot in that button is the admin action-items one, which is opacity-0.
|
|
const mutedSpaceBell = (header: Locator) => header.locator("div.opacity-50")
|
|
|
|
// 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.
|
|
const settingRow = (page: Page, label: string) =>
|
|
page.locator("div.items-center.justify-between").filter({hasText: label})
|
|
|
|
// PrimaryNavItemSpace carries the relay's name as a tooltip rather than as an accessible name — its
|
|
// icon is a masked svg with no alt text — and it has an onclick, so PrimaryNavItem renders it as a
|
|
// button rather than a link. The name comes from nip-11; until that document lands the tooltip is
|
|
// the relay's host instead, and each tenant's toml names it after itself, so both start the same.
|
|
const spaceNavItem = (page: Page, name: string) =>
|
|
page.locator(`.primary-nav [data-tip^="${name}"]`)
|
|
|
|
// The desktop rail and the phone's bottom bar each carry a link to the space list, and neither one
|
|
// has an accessible name — both icons are masked svgs. Below tailwind's md breakpoint the rail is
|
|
// display:none, so on a phone the visible one is the bar's.
|
|
const spacesNavItem = (page: Page) => page.locator('a[href="/spaces"]:visible')
|
|
|
|
// The space menu's header, the one button in the secondary nav carrying the relay's address.
|
|
const spaceMenu = (page: Page, url: string) =>
|
|
page.locator(".secondary-nav").getByRole("button", {name: pattern(displayRelayUrl(url))})
|
|
|
|
const roomLink = (page: Page, name: string) =>
|
|
page.locator(".space-menu__scroll").getByRole("link", {name})
|
|
|
|
// The room's page bar carries a back arrow (display:none at this viewport), a search button and the
|
|
// detail button, in that order.
|
|
const openRoomDetail = (page: Page) =>
|
|
page.locator('[data-component="PageBar"]').getByRole("button").last().click()
|
|
|
|
// `.dialog` is on both the backdrop wrapper and the panel inside it, so the last match is the panel.
|
|
const dialog = (page: Page, title: string) =>
|
|
page
|
|
.locator(".dialog")
|
|
.filter({has: page.getByRole("heading", {name: title, exact: true})})
|
|
.last()
|
|
|
|
const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]")
|
|
|
|
const message = (page: Page, text: string) => page.locator(".room__item").filter({hasText: text})
|
|
|
|
const send = async (page: Page, content: string) => {
|
|
await composer(page).pressSequentially(content)
|
|
await composer(page).press("Enter")
|
|
await expect(message(page, content)).toBeVisible()
|
|
}
|
|
|
|
// The page bar names the room, so waiting for it is what says the composer below now belongs to
|
|
// the room that was just opened rather than to the one being torn down.
|
|
const postTo = async (page: Page, room: string, content: string) => {
|
|
await roomLink(page, room).click()
|
|
await expect(page.locator('[data-component="PageBar"]')).toContainText(room)
|
|
await send(page, content)
|
|
}
|
|
|
|
// Outbox routing resolves everything about a person through their relay list: settings are
|
|
// published to the relays it names, and a gift wrap only reaches somebody who has said where their
|
|
// messages go.
|
|
const seedRelays = (space: SeededSpace, user: TestUser) => {
|
|
space.event(user, () =>
|
|
space
|
|
.kind(RelayList)
|
|
.writer()
|
|
.setReadUrls([space.url])
|
|
.setWriteUrls([space.url])
|
|
.renderTemplate(),
|
|
)
|
|
space.event(user, () =>
|
|
space.kind(MessagingRelayList).writer().setUrls([space.url]).renderTemplate(),
|
|
)
|
|
}
|
|
|
|
test("US-103 see and clear unread indicators", async ({seed, as}) => {
|
|
const scenario = 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", "random")
|
|
|
|
// History bob wrote himself, so the space has something in it while nothing in it is unread
|
|
// for him — his own messages never raise an indicator.
|
|
space.message(user.bob, "general", "anyone around?", at(2, HOUR))
|
|
})
|
|
|
|
const space = scenario.space("space")
|
|
const bob = await as(users.bob, "/spaces")
|
|
const alice = await as(users.alice, roomPath(space.url, "general"))
|
|
|
|
const spaceRow = bob.getByRole("listitem").filter({hasText: space.url})
|
|
const navItem = spaceNavItem(bob, space.name)
|
|
|
|
await expect(spaceRow).toBeVisible()
|
|
await expect(navItem).toBeVisible()
|
|
await expect(unreadDot(spaceRow)).toHaveCount(0)
|
|
await expect(unreadDot(navItem)).toHaveCount(0)
|
|
|
|
await send(alice, "the server is on fire")
|
|
|
|
// Bob is sitting on the space list the whole time, so both dots arrive without a navigation
|
|
await expect(unreadDot(spaceRow)).toBeVisible()
|
|
await expect(unreadDot(navItem)).toBeVisible()
|
|
|
|
// Inside the space, the dot points at the room the message landed in. A space's room list only
|
|
// exists in its own menu, so this is the one indicator the list above can't show.
|
|
await spaceRow.click()
|
|
|
|
const general = roomLink(bob, "General")
|
|
const random = roomLink(bob, "Random")
|
|
|
|
// Both rooms are on screen first, so a room with no dot is a room raising none rather than a
|
|
// row that never rendered
|
|
await expect(random).toBeVisible()
|
|
await expect(unreadDot(general)).toBeVisible()
|
|
await expect(unreadDot(random)).toHaveCount(0)
|
|
|
|
await general.click()
|
|
|
|
await expect(message(bob, "the server is on fire")).toBeVisible()
|
|
|
|
// Reading it clears the room's dot...
|
|
await bob.getByRole("link", {name: "Space Details"}).click()
|
|
|
|
await expect(general).toBeVisible()
|
|
await expect(unreadDot(general)).toHaveCount(0)
|
|
|
|
// ...and it stays cleared back on the space list he started from
|
|
await bob.locator('.primary-nav a[href="/spaces"]').click()
|
|
|
|
await expect(spaceRow).toBeVisible()
|
|
await expect(unreadDot(spaceRow)).toHaveCount(0)
|
|
await expect(unreadDot(navItem)).toHaveCount(0)
|
|
})
|
|
|
|
test("US-104 mute a room or a whole space", async ({seed, as}) => {
|
|
const scenario = await seed(({relay, user}) => {
|
|
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", "random")
|
|
|
|
seedRelays(space, user.alice)
|
|
})
|
|
|
|
const space = scenario.space("space")
|
|
|
|
// Silencing a whole space is only offered once push notifications are on, so that is where alice
|
|
// has to start.
|
|
const alice = await as(users.alice, "/settings/alerts", {
|
|
context: {permissions: ["notifications"]},
|
|
})
|
|
|
|
await settingRow(alice, "Enable push notifications").getByRole("checkbox").check()
|
|
await alice.getByRole("button", {name: "Save Changes"}).click()
|
|
|
|
await expect(alice.getByRole("alert")).toContainText("Your settings have been saved!")
|
|
|
|
await spaceNavItem(alice, space.name).click()
|
|
|
|
const general = roomLink(alice, "General")
|
|
const random = roomLink(alice, "Random")
|
|
|
|
await general.click()
|
|
|
|
await expect(alice).toHaveURL(pattern(roomPath(space.url, "general")))
|
|
|
|
// Silence this one room from its detail panel
|
|
await openRoomDetail(alice)
|
|
|
|
const roomNotifications = settingRow(alice, "Notifications").getByRole("checkbox")
|
|
|
|
await expect(roomNotifications).toBeChecked()
|
|
|
|
await roomNotifications.uncheck()
|
|
await alice.getByRole("button", {name: "Go back"}).click()
|
|
|
|
await expect(random).toBeVisible()
|
|
await expect(mutedRoomBell(general)).toBeVisible()
|
|
await expect(mutedRoomBell(random)).toHaveCount(0)
|
|
|
|
// Step out of both rooms, so either one is free to raise a dot
|
|
await alice.getByRole("link", {name: "Space Details"}).click()
|
|
|
|
const bob = await as(users.bob, roomPath(space.url, "general"))
|
|
|
|
await send(bob, "deploy is broken")
|
|
await postTo(bob, "Random", "lunch?")
|
|
|
|
// The sibling room's message raises a dot, the muted room's does not
|
|
await expect(unreadDot(random)).toBeVisible()
|
|
await expect(unreadDot(general)).toHaveCount(0)
|
|
|
|
// The muted room's message did arrive, it just raised nothing
|
|
await general.click()
|
|
|
|
await expect(message(alice, "deploy is broken")).toBeVisible()
|
|
|
|
// Turning the room back on restores its indicator for what comes next
|
|
await openRoomDetail(alice)
|
|
await roomNotifications.check()
|
|
await alice.getByRole("button", {name: "Go back"}).click()
|
|
await alice.getByRole("link", {name: "Space Details"}).click()
|
|
|
|
await expect(general).toBeVisible()
|
|
await expect(mutedRoomBell(general)).toHaveCount(0)
|
|
|
|
await postTo(bob, "General", "and now the build too")
|
|
|
|
// Both rooms are now showing a dot, so silencing the space is what takes them down
|
|
await expect(unreadDot(general)).toBeVisible()
|
|
await expect(unreadDot(random)).toBeVisible()
|
|
|
|
const menu = spaceMenu(alice, space.url)
|
|
|
|
await menu.click()
|
|
await alice.getByRole("button", {name: "Turn off notifications"}).click()
|
|
|
|
await expect(mutedSpaceBell(menu)).toBeVisible()
|
|
await expect(unreadDot(general)).toHaveCount(0)
|
|
await expect(unreadDot(random)).toHaveCount(0)
|
|
|
|
await postTo(bob, "General", "still broken")
|
|
await postTo(bob, "Random", "still hungry")
|
|
|
|
await expect(unreadDot(general)).toHaveCount(0)
|
|
await expect(unreadDot(random)).toHaveCount(0)
|
|
|
|
// Reopening the menu shows the label the mute flipped, and turning it back on brings the
|
|
// indicators back — which is also what proves those messages reached alice at all
|
|
await menu.click()
|
|
|
|
const turnOn = alice.getByRole("button", {name: "Turn on notifications"})
|
|
|
|
await expect(turnOn).toBeVisible()
|
|
await turnOn.click()
|
|
|
|
await expect(mutedSpaceBell(menu)).toHaveCount(0)
|
|
await expect(unreadDot(general)).toBeVisible()
|
|
await expect(unreadDot(random)).toBeVisible()
|
|
})
|
|
|
|
test("US-105 land on the home page", async ({seed, as}) => {
|
|
const scenario = await seed(({relay, user}) => {
|
|
const space = relay("space")
|
|
|
|
space.room("general", {name: "General"})
|
|
space.join(user.alice, "general")
|
|
})
|
|
|
|
const space = scenario.space("space")
|
|
|
|
// A build that names a platform space sends /home straight into it
|
|
const platform = await as(users.alice, "/home", {env: {VITE_PLATFORM_RELAYS: space.url}})
|
|
|
|
await expect(platform).toHaveURL(pattern(spacePath(space.url)))
|
|
|
|
// With none configured, /home is a welcome screen with two ways out
|
|
const page = await as(users.alice, "/home")
|
|
|
|
const addSpace = page.getByRole("link", {name: "Add a space"})
|
|
const startConversation = page.getByRole("button", {name: "Start a conversation"})
|
|
|
|
await expect(addSpace).toBeVisible()
|
|
await expect(startConversation).toBeVisible()
|
|
|
|
await addSpace.click()
|
|
|
|
await expect(page).toHaveURL(/\/spaces$/)
|
|
|
|
await page.goBack()
|
|
|
|
await expect(page).toHaveURL(/\/home$/)
|
|
|
|
await startConversation.click()
|
|
|
|
await expect(page).toHaveURL(/\/chat$/)
|
|
})
|
|
|
|
test("US-106 share text into the app", async ({seed, as}) => {
|
|
const shared = "the offsite is moving to the 14th"
|
|
|
|
const scenario = await seed(({relay, user, at}) => {
|
|
const space = relay("space")
|
|
|
|
space.room("general", {name: "General"})
|
|
space.room("watercooler", {name: "Watercooler"})
|
|
space.join(user.alice, "general", "watercooler")
|
|
space.join(user.bob, "general", "watercooler")
|
|
space.profile(user.bob, {name: "Bob Barker"})
|
|
|
|
seedRelays(space, user.alice)
|
|
seedRelays(space, user.bob)
|
|
|
|
space.dm(user.bob, [user.alice], "are you around?", at(20, MINUTE))
|
|
})
|
|
|
|
const space = scenario.space("space")
|
|
|
|
// A client only unwraps direct messages once its owner has opened chat, so a conversation the
|
|
// share dialog can offer is one alice has already seen.
|
|
const page = await as(users.alice, "/chat")
|
|
|
|
await expect(page.getByText("are you around?").first()).toBeVisible()
|
|
|
|
// The text arrives in the query string rather than through a native share intent, which is the
|
|
// same thing src/routes/share reads either way.
|
|
await page.goto(`/share?text=${encodeURIComponent(shared)}`)
|
|
|
|
const share = dialog(page, "Share")
|
|
|
|
await expect(page.getByText("Where would you like to share this?")).toBeVisible()
|
|
await expect(share.getByRole("button", {name: "General"})).toBeVisible()
|
|
await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible()
|
|
await expect(share.getByRole("button", {name: "Bob Barker"})).toBeVisible()
|
|
|
|
const search = page.getByPlaceholder("Search rooms and conversations...")
|
|
|
|
await search.fill("Watercooler")
|
|
|
|
await expect(share.getByRole("button", {name: "Watercooler"})).toBeVisible()
|
|
await expect(share.getByRole("button", {name: "General"})).toHaveCount(0)
|
|
await expect(share.getByRole("button", {name: "Bob Barker"})).toHaveCount(0)
|
|
|
|
await search.fill("")
|
|
await share.getByRole("button", {name: "General"}).click()
|
|
await share.getByRole("button", {name: "Share", exact: true}).click()
|
|
|
|
await expect(page).toHaveURL(pattern(roomPath(space.url, "general")))
|
|
await expect(composer(page)).toHaveText(shared)
|
|
})
|
|
|
|
test("US-107 open a nostr link", async ({seed, as}) => {
|
|
let posted!: Seeded
|
|
|
|
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 Barker"})
|
|
|
|
seedRelays(space, user.bob)
|
|
|
|
posted = space.message(user.bob, "general", "the meeting moved to friday", at(20, MINUTE))
|
|
})
|
|
|
|
const space = scenario.space("space")
|
|
const page = await as(users.alice, roomPath(space.url, "general"), {
|
|
context: {permissions: ["clipboard-read", "clipboard-write"]},
|
|
})
|
|
|
|
const item = page.locator(`[data-event="${posted.id}"]`)
|
|
|
|
await expect(item).toBeVisible()
|
|
await item.hover()
|
|
await item.locator(".room__item-actions button").last().click()
|
|
await page.getByRole("button", {name: "Message Details"}).click()
|
|
|
|
await expect(page.getByRole("heading", {name: "Event Details"})).toBeVisible()
|
|
|
|
// EventInfo renders the link on mount rather than during setup, so wait for it rather than
|
|
// copying an empty field
|
|
const eventLink = settingRow(page, "Event Link")
|
|
|
|
await expect(eventLink.getByRole("textbox")).toHaveValue(/^nostr:nevent1/)
|
|
await eventLink.getByRole("button").click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
|
|
|
|
const link = await page.evaluate(() => navigator.clipboard.readText())
|
|
|
|
expect(link).toMatch(/^nostr:nevent1/)
|
|
|
|
await page.goto("/" + link.replace(/^nostr:/, ""))
|
|
|
|
await expect(page).toHaveURL(pattern(roomPath(space.url, "general") + "?at="))
|
|
await expect(message(page, "the meeting moved to friday")).toBeVisible()
|
|
|
|
// A person's npub resolves the same way
|
|
const npub = npubEncode(users.bob.pubkey)
|
|
|
|
await page.goto(`/${npub}`)
|
|
|
|
await expect(page).toHaveURL(pattern(`/people/${npub}`))
|
|
await expect(page.getByRole("heading", {name: "Bob Barker"})).toBeVisible()
|
|
|
|
// Nothing holds this event, so the link falls back to the app's home rather than a dead page
|
|
await page.goto(`/${neventEncode({id: "f".repeat(64), relays: [space.url]})}`)
|
|
|
|
await expect(page).toHaveURL(/\/home$/)
|
|
})
|
|
|
|
test("US-110 see another space's unread activity from a phone", async ({seed, as}) => {
|
|
const scenario = await seed(({relay, user}) => {
|
|
const space = relay("space")
|
|
const other = relay("other")
|
|
|
|
space.room("general", {name: "General"})
|
|
space.room("random", {name: "Random"})
|
|
other.room("general", {name: "General"})
|
|
|
|
space.join(user.alice, "general", "random")
|
|
space.join(user.bob, "general", "random")
|
|
other.join(user.alice, "general")
|
|
other.join(user.bob, "general")
|
|
})
|
|
|
|
const space = scenario.space("space")
|
|
const other = scenario.space("other")
|
|
|
|
// A phone has no room list on screen while a room is open, so the bottom bar is the only place
|
|
// activity elsewhere can surface
|
|
const bob = await as(users.bob, roomPath(space.url, "general"), {
|
|
context: {viewport: {width: 390, height: 844}, hasTouch: true},
|
|
})
|
|
|
|
const spacesButton = spacesNavItem(bob)
|
|
|
|
await expect(spacesButton).toBeVisible()
|
|
await expect(unreadDot(spacesButton)).toHaveCount(0)
|
|
|
|
const inOther = await as(users.alice, roomPath(other.url, "general"))
|
|
|
|
await send(inOther, "the server is on fire")
|
|
|
|
await expect(unreadDot(spacesButton)).toBeVisible()
|
|
|
|
// Meanwhile the space bob is sitting in gets a message too, in a room he isn't reading
|
|
const inSpace = await as(users.alice, roomPath(space.url, "random"))
|
|
|
|
await send(inSpace, "anyone seen the sextant?")
|
|
|
|
await bob.goto(roomPath(other.url, "general"))
|
|
|
|
await expect(message(bob, "the server is on fire")).toBeVisible()
|
|
|
|
// Reading the other space empties the bar, even though bob's own space still has an unread room
|
|
// — that one is the space's business, and its own indicators carry it
|
|
await bob.goto(spacePath(space.url))
|
|
|
|
await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible()
|
|
await expect(unreadDot(spacesButton)).toHaveCount(0)
|
|
})
|
|
|
|
// SpaceMenuNavItems hides a content type until the space has an event of that kind, so this link
|
|
// appearing at all is what says the seeded threads loaded.
|
|
const contentNavItem = (page: Page, name: string) =>
|
|
page.locator(".secondary-nav").getByRole("link", {name})
|
|
|
|
const seedThread = (space: SeededSpace, user: TestUser, title: string, createdAt: number) =>
|
|
space.event(
|
|
user,
|
|
() =>
|
|
space
|
|
.kind(Thread)
|
|
.writer()
|
|
.setRoom(space.url, "general")
|
|
.setTitle(title)
|
|
.setContent("worth talking about")
|
|
.renderTemplate(),
|
|
createdAt,
|
|
)
|
|
|
|
test("US-112 see which threads are unread", 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")
|
|
|
|
// A thread bob wrote raises no indicator of its own, so it is the control for a row with no dot
|
|
seedThread(space, user.bob, "where is the sextant", at(3, HOUR))
|
|
seedThread(space, user.alice, "the server is on fire", at(2, HOUR))
|
|
})
|
|
|
|
const space = scenario.space("space")
|
|
const bob = await as(users.bob, roomPath(space.url, "general"))
|
|
|
|
const threadsNav = contentNavItem(bob, "Threads")
|
|
|
|
await expect(threadsNav).toBeVisible()
|
|
await expect(unreadDot(threadsNav)).toBeVisible()
|
|
|
|
await threadsNav.click()
|
|
|
|
const hers = bob.getByRole("row").filter({hasText: "the server is on fire"})
|
|
const his = bob.getByRole("row").filter({hasText: "where is the sextant"})
|
|
|
|
await expect(his).toBeVisible()
|
|
|
|
// syncChecked marks the landed-on page read 300ms later and latestActivityByPath is throttled to
|
|
// a second, so a dot the list is about to clear stays up well past the click. Nothing on screen
|
|
// reports the tick — the nav dot goes down on the route change either way — so wait it out.
|
|
await bob.waitForTimeout(1500)
|
|
|
|
await expect(unreadDot(hers)).toBeVisible()
|
|
await expect(unreadDot(his)).toHaveCount(0)
|
|
|
|
await hers.click()
|
|
|
|
await expect(bob.locator('[data-component="PageBar"]')).toContainText("the server is on fire")
|
|
|
|
// Leaving the list is what marks its threads read, so the dot is gone on the way back
|
|
await bob.goBack()
|
|
|
|
await expect(hers).toBeVisible()
|
|
await expect(unreadDot(hers)).toHaveCount(0)
|
|
|
|
await roomLink(bob, "General").click()
|
|
|
|
await expect(threadsNav).toBeVisible()
|
|
await expect(unreadDot(threadsNav)).toHaveCount(0)
|
|
})
|
|
|
|
test("US-113 see which threads are unread on a phone", 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")
|
|
|
|
seedThread(space, user.bob, "where is the sextant", at(3, HOUR))
|
|
seedThread(space, user.alice, "the server is on fire", at(2, HOUR))
|
|
})
|
|
|
|
const space = scenario.space("space")
|
|
|
|
// ThreadBoard swaps its table for a list of links below tailwind's md breakpoint, and the two
|
|
// branches render the thread separately, so a dot on one says nothing about the other.
|
|
const bob = await as(users.bob, `${spacePath(space.url)}/threads`, {
|
|
context: {viewport: {width: 390, height: 844}, hasTouch: true},
|
|
})
|
|
|
|
const hers = bob.getByRole("link").filter({hasText: "the server is on fire"})
|
|
const his = bob.getByRole("link").filter({hasText: "where is the sextant"})
|
|
|
|
await expect(his).toBeVisible()
|
|
|
|
// Same tick and throttle as US-112: a dot read before both have run is one the list may still be
|
|
// about to clear.
|
|
await bob.waitForTimeout(1500)
|
|
|
|
await expect(unreadDot(hers)).toBeVisible()
|
|
await expect(unreadDot(his)).toHaveCount(0)
|
|
})
|