flotilla/e2e/specs/routing.spec.ts

291 lines
11 KiB
TypeScript
Raw Normal View History

2026-09-16 16:19:43 +00:00
import {HOUR, spec} from "@welshman/lib"
2026-08-06 15:43:57 +00:00
import type {TrustedEvent} from "@welshman/util"
import {RelayMessageType} from "@welshman/net"
2026-09-16 16:19:43 +00:00
import {Article} from "@welshman/domain"
import type {Page} from "@playwright/test"
import {expect, getTranscript, pathPattern, roomPath, spacePath, test, users} from "../harness"
2026-08-06 15:43:57 +00:00
2026-09-16 16:19:43 +00:00
// Every page in a space renders one PageContent, so the number added to the document is the number
// of times the page was built.
const watchPageBuilds = (page: Page) =>
page.evaluate(() => {
const selector = "[data-component='PageContent']"
let builds = 0
document.documentElement.dataset.pageBuilds = "0"
new MutationObserver(records => {
for (const record of records) {
for (const node of record.addedNodes) {
if (node instanceof Element && (node.matches(selector) || node.querySelector(selector))) {
document.documentElement.dataset.pageBuilds = String(++builds)
}
}
}
}).observe(document.body, {childList: true, subtree: true})
})
const expectPageBuilds = (page: Page, builds: number) =>
expect(page.locator("html")).toHaveAttribute("data-page-builds", String(builds))
2026-08-06 15:43:57 +00:00
test("keeps two spaces' contents on their own relays", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
// Same room id on both relays, so anything that keys rooms by id alone conflates them.
space.room("lounge", {name: "Space Lounge"})
other.room("lounge", {name: "Other Lounge"})
space.join(user.alice, "lounge")
other.join(user.alice, "lounge")
space.message(user.alice, "lounge", "only in space")
other.message(user.alice, "lounge", "only in other")
})
const space = scenario.space("space")
const other = scenario.space("other")
const page = await as(users.alice, roomPath(space.url, "lounge"))
const deliveredByOther = (match: (event: TrustedEvent) => boolean) =>
getTranscript(page.context()).some(
({url, direction, message}) =>
url === other.url &&
direction === "toClient" &&
message[0] === RelayMessageType.Event &&
match(message[2]),
)
await expect(page.getByText("only in space")).toBeVisible()
await expect(page.getByRole("link", {name: "Space Lounge"})).toBeVisible()
// Alice belongs to both spaces, so the client is talking to the other relay at the same time —
// its room and its messages just don't belong in this one. It syncs independently, so wait until
// both have actually reached this page: `toHaveCount(0)` is equally satisfied by an element that
// has not loaded yet, which would pass against a client that does conflate them.
await expect.poll(() => deliveredByOther(event => event.content === "only in other")).toBe(true)
await expect
.poll(() => deliveredByOther(event => event.tags.some(spec(["name", "Other Lounge"]))))
.toBe(true)
await expect(page.getByText("only in other")).toHaveCount(0)
await expect(page.getByRole("link", {name: "Other Lounge"})).toHaveCount(0)
const strays = getTranscript(page.context()).filter(
({url, direction, message}) =>
direction === "toClient" &&
message[0] === RelayMessageType.Event &&
message[2].content === "only in space" &&
url !== space.url,
)
expect(strays).toEqual([])
})
test("goes back to the room you left when you switch spaces", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("lounge", {name: "Space Lounge"})
other.room("lounge", {name: "Other Lounge"})
space.join(user.alice, "lounge")
other.join(user.alice, "lounge")
})
const space = scenario.space("space")
const other = scenario.space("other")
const page = await as(users.alice, roomPath(space.url, "lounge"))
await expect(page.getByRole("link", {name: "Space Lounge"})).toBeVisible()
await page.locator('.primary-nav [data-tip^="other"]').click()
await expect(page).toHaveURL(new RegExp(spacePath(other.url)))
await page.getByRole("link", {name: "Other Lounge"}).click()
await expect(page).toHaveURL(new RegExp(`${roomPath(other.url, "lounge")}$`))
// Two entries back: the other space's landing page, then the room this started on. The switch
// used to replace that room's entry rather than push one, so the second step overshot it.
await page.goBack()
await page.goBack()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`))
})
test("does not stack a history entry for the space you are already in", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("lounge", {name: "Space Lounge"})
space.room("garden", {name: "Space Garden"})
space.join(user.alice, "lounge")
space.join(user.alice, "garden")
})
const space = scenario.space("space")
const page = await as(users.alice, roomPath(space.url, "lounge"))
await page.getByRole("link", {name: "Space Garden"}).click()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`))
// The space's entry page is the room you are on, so this navigates nowhere and should replace
// rather than push. One step back is the room this started on, not the one it never left.
await page.locator('.primary-nav [data-tip^="space"]').click()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`))
await page.goBack()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`))
})
test("takes over the space menu's history entry when you navigate out of it", async ({
seed,
as,
}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("lounge", {name: "Space Lounge"})
space.room("garden", {name: "Space Garden"})
space.join(user.alice, "lounge")
space.join(user.alice, "garden")
})
const space = scenario.space("space")
const page = await as(users.alice, roomPath(space.url, "lounge"), {
context: {viewport: {width: 390, height: 844}, hasTouch: true},
})
const drawer = page.locator(".drawer")
await page.getByRole("button", {name: "Open space menu"}).click()
await drawer.getByRole("link", {name: "Space Garden"}).click()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`))
await expect(drawer).toHaveCount(0)
// The menu held the entry the room it opened now holds, so one step back is the room it opened
// over. Stacked instead, this lands on the menu again.
await page.goBack()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`))
await expect(drawer).toHaveCount(0)
})
test("switches spaces inside the space menu without closing it", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
const other = relay("other")
space.room("lounge", {name: "Space Lounge"})
other.room("garden", {name: "Other Garden"})
space.join(user.alice, "lounge")
other.join(user.alice, "garden")
})
const space = scenario.space("space")
const other = scenario.space("other")
const page = await as(users.alice, roomPath(space.url, "lounge"), {
context: {viewport: {width: 390, height: 844}, hasTouch: true},
})
const drawer = page.locator(".drawer")
await page.getByRole("button", {name: "Open space menu"}).click()
await expect(drawer.getByRole("link", {name: "Space Lounge"})).toBeVisible()
// The rail is in the menu on a phone, so a space is picked with the menu still open and the room
// is picked after it. Navigating out from under the menu would close it on the first tap.
await drawer.locator('.primary-nav [data-tip^="other"]').click()
await expect(page).toHaveURL(new RegExp(spacePath(other.url)))
await expect(drawer.getByRole("link", {name: "Other Garden"})).toBeVisible()
await drawer.getByRole("link", {name: "Other Garden"}).click()
await expect(page).toHaveURL(new RegExp(`${roomPath(other.url, "garden")}$`))
await expect(drawer).toHaveCount(0)
})
test("enters a space on its details page whatever its relay advertises", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("lounge", {name: "Space Lounge"})
space.join(user.alice, "lounge")
})
const space = scenario.space("space")
// A relay whose document claims no nip-29. The entry path used to read that as "open the chat
// page", which a cold load could never get right: the document arrives after the first
// navigation, so a space opened on chat and corrected itself to about a moment later.
const relayInfo = {[space.url]: {supported_nips: [1, 11, 42]}}
const page = await as(users.alice, spacePath(space.url), {relayInfo})
await expect(page).toHaveURL(pathPattern(spacePath(space.url) + "/about"))
await expect(page.locator('[data-component="PageBar"]')).toContainText("Space Details")
})
2026-09-16 16:19:43 +00:00
test("builds a page once when it opens and again when its params change", 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.alice, {name: "Alice Anderson"})
space.relayList(user.alice)
const article = (identifier: string, title: string, hours: number) =>
space.event(
user.alice,
() =>
space
.kind(Article)
.writer()
.setIdentifier(identifier)
.setTitle(title)
.setPublishedAt(at(hours, HOUR))
.setContent("Gardens are worth the trouble.")
.renderTemplate(),
at(hours, HOUR),
)
article("tending-the-garden", "Tending the Garden", 4)
article("repotting-in-winter", "Repotting in Winter", 3)
})
const {url} = scenario.space("space")
const page = await as(users.bob, `${spacePath(url)}/articles`)
await watchPageBuilds(page)
await page
.locator('[data-component="ArticleItem"]')
.filter({hasText: "Tending the Garden"})
.getByRole("link", {name: "Tending the Garden", exact: true})
.click({position: {x: 20, y: 20}})
await expect(
page.locator("article header").getByRole("heading", {name: "Tending the Garden"}),
).toBeVisible()
// The rebuild this guards against landed 20ms after the first build, which is before the article
// itself is on screen on a slower run.
await page.waitForTimeout(250)
await expectPageBuilds(page, 1)
// Another article is the same route with different params, which SvelteKit answers by keeping the
// page it has. The page reads the address it renders once, so this one does have to be rebuilt.
await page.getByRole("link", {name: "Repotting in Winter"}).click()
await expect(
page.locator("article header").getByRole("heading", {name: "Repotting in Winter"}),
).toBeVisible()
await expectPageBuilds(page, 2)
})