import {HOUR, spec} from "@welshman/lib" import type {TrustedEvent} from "@welshman/util" import {RelayMessageType} from "@welshman/net" import {Article} from "@welshman/domain" import type {Page} from "@playwright/test" import {expect, getTranscript, pathPattern, roomPath, spacePath, test, users} from "../harness" // 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)) 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, and out of one it has a page for", 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) // The space she started in has a page behind it now, so picking it needs no second tap and the // menu has nothing left to ask. await page.getByRole("button", {name: "Open space menu"}).click() await drawer.locator('.primary-nav [data-tip^="space"]').click() await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`)) 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") }) 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) })