2026-09-21 19:48:16 +00:00
|
|
|
import {DAY, HOUR, WEEK, sleep, sortBy} from "@welshman/lib"
|
2026-09-09 03:50:46 +00:00
|
|
|
import {ROOMS} from "@welshman/util"
|
2026-09-09 01:07:48 +00:00
|
|
|
import type {Page} from "@playwright/test"
|
2026-09-17 18:26:01 +00:00
|
|
|
import type {SeededSpace} from "../harness"
|
2026-09-21 19:48:16 +00:00
|
|
|
import {expect, pathPattern, readCachedEvents, roomPath, spacePath, test, users} from "../harness"
|
2026-09-09 01:07:48 +00:00
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Room lists reach indexeddb in three-second batches, so a spec about a reload waits on this first.
|
2026-09-09 01:07:48 +00:00
|
|
|
const cachedFirstSpace = async (page: Page, pubkey: string) => {
|
|
|
|
|
const events = (await readCachedEvents(page, pubkey)).filter(event => event.kind === ROOMS)
|
|
|
|
|
const newest = sortBy(event => -event.created_at, events)[0]
|
|
|
|
|
|
|
|
|
|
return newest?.tags.find(tag => tag[0] === "r")?.[1]
|
|
|
|
|
}
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// The rail shows icons and no text, so a row is read by the tooltip naming its relay.
|
2026-09-17 18:26:01 +00:00
|
|
|
const railSpaces = (page: Page) => page.locator(".primary-nav [draggable=true]")
|
|
|
|
|
|
|
|
|
|
const expectRailFirst = (page: Page, name: string) =>
|
|
|
|
|
expect(railSpaces(page).first().locator("[data-tip]")).toHaveAttribute(
|
|
|
|
|
"data-tip",
|
|
|
|
|
new RegExp(`^${name}`),
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A drop reorders the list in place and publishes behind it, so the copy on disk is the round trip.
|
2026-09-17 18:26:01 +00:00
|
|
|
const expectReordered = async (page: Page, pubkey: string, space: SeededSpace) => {
|
|
|
|
|
await expectRailFirst(page, space.name)
|
|
|
|
|
await expect.poll(() => cachedFirstSpace(page, pubkey)).toBe(space.url)
|
2026-09-15 17:58:48 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
test("US-009 browse and search spaces, and reorder your own", async ({seed, as}) => {
|
2026-08-20 20:33:20 +00:00
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
const other = relay("other")
|
|
|
|
|
const unsigned = relay("unsigned")
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
other.room("lounge", {name: "Lounge"})
|
|
|
|
|
unsigned.room("hall", {name: "Hall"})
|
|
|
|
|
|
|
|
|
|
// Alice's own two spaces, declared in the order her room list should start out in
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
other.join(user.alice, "lounge")
|
|
|
|
|
|
|
|
|
|
// A space she hasn't joined only becomes browsable through somebody else's room list
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
unsigned.join(user.bob, "hall")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const space = scenario.space("space")
|
|
|
|
|
const other = scenario.space("other")
|
|
|
|
|
const unsigned = scenario.space("unsigned")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// The spaces page discovers unjoined spaces by pulling the room lists of its bootstrap pubkeys.
|
2026-08-20 20:33:20 +00:00
|
|
|
const page = await as(users.alice, "/spaces", {env: {VITE_DEFAULT_PUBKEYS: users.bob.pubkey}})
|
|
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
// The page is for spaces she hasn't joined. The ones she has are in the rail, all of them.
|
2026-08-20 20:33:20 +00:00
|
|
|
await expect(page.getByText("Browse Spaces")).toBeVisible()
|
|
|
|
|
await expect(page.getByText(unsigned.url)).toBeVisible()
|
2026-09-17 18:26:01 +00:00
|
|
|
await expect(page.getByText(space.url)).toHaveCount(0)
|
|
|
|
|
await expect(page.getByText(other.url)).toHaveCount(0)
|
|
|
|
|
await expect(railSpaces(page)).toHaveCount(2)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
const term = page.getByPlaceholder("Search for spaces...")
|
|
|
|
|
|
|
|
|
|
await term.fill("unsigned")
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText(unsigned.url)).toBeVisible()
|
|
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
await term.fill("nothing by that name")
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
await expect(page.getByText(unsigned.url)).toHaveCount(0)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
await term.fill("")
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
// A space she hasn't joined asks her to join first
|
2026-08-20 20:33:20 +00:00
|
|
|
await page.getByRole("button").filter({hasText: unsigned.url}).click()
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible()
|
|
|
|
|
await expect(page.getByRole("button", {name: "Go back"})).toBeEnabled()
|
|
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
// A space she has joined opens from the rail
|
2026-08-20 20:33:20 +00:00
|
|
|
await page.goto("/spaces")
|
2026-09-17 18:26:01 +00:00
|
|
|
await railSpaces(page).first().click()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
await expect(page).toHaveURL(/\/spaces\/space\.test\//)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
// Reordering by dragging, which lives in her room list and so outlives the page
|
|
|
|
|
await page.goto("/spaces")
|
|
|
|
|
|
|
|
|
|
await expectRailFirst(page, space.name)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Chromium's synthetic drag starts and moves it but never delivers the drop the reorder needs.
|
2026-09-09 01:07:48 +00:00
|
|
|
const dataTransfer = await page.evaluateHandle(() => new DataTransfer())
|
2026-09-17 18:26:01 +00:00
|
|
|
const rail = railSpaces(page)
|
2026-09-09 21:06:59 +00:00
|
|
|
|
|
|
|
|
await rail.nth(0).dispatchEvent("dragstart", {dataTransfer})
|
|
|
|
|
await rail.nth(1).dispatchEvent("drop", {dataTransfer})
|
|
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
await expectReordered(page, users.alice.pubkey, other)
|
2026-09-09 01:07:48 +00:00
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
await page.reload()
|
|
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
await expectRailFirst(page, other.name)
|
2026-08-20 20:33:20 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("US-010 join a space from an invite link", async ({seed, as}) => {
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
const other = relay("other")
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
other.room("lounge", {name: "Lounge"})
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const other = scenario.space("other")
|
|
|
|
|
const page = await as(users.alice, spacePath(other.url))
|
|
|
|
|
|
|
|
|
|
// Arriving at a space she hasn't joined raises the join prompt on its own
|
|
|
|
|
await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled()
|
|
|
|
|
await expect(page.getByText(other.url)).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Go back"}).click()
|
|
|
|
|
|
|
|
|
|
// Going back left her un-joined, so arriving again asks again
|
|
|
|
|
await page.goto(spacePath(other.url))
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled()
|
|
|
|
|
|
|
|
|
|
await page.goto("/spaces")
|
|
|
|
|
await page.getByRole("button", {name: "Add Space"}).click()
|
|
|
|
|
await page.getByRole("button", {name: /Join a space/}).click()
|
|
|
|
|
|
|
|
|
|
const invite = page.locator("form").filter({hasText: "Invite Link*"}).getByRole("textbox")
|
|
|
|
|
|
|
|
|
|
await invite.fill("not an invite")
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("button", {name: "Join Space"})).toBeDisabled()
|
|
|
|
|
await expect(page.getByText("You're about to join:")).toHaveCount(0)
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// An absolute platform url is an off-origin navigation, and parseInviteLink reads query params.
|
2026-08-20 20:33:20 +00:00
|
|
|
await invite.fill("https://app.flotilla.social/join?r=other.test&c=")
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("You're about to join:")).toBeVisible()
|
|
|
|
|
await expect(page.getByText(other.url)).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Join Space"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("Welcome to the space!")).toBeVisible()
|
|
|
|
|
await expect(page).toHaveURL(/\/spaces\/other\.test/)
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-21 19:48:16 +00:00
|
|
|
test("US-010 a direct link's join prompt outlives the entry redirect", async ({seed, as}) => {
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
const other = relay("other")
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
other.room("lounge", {name: "Lounge"})
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const other = scenario.space("other")
|
|
|
|
|
const page = await as(users.alice, "/")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// A space's own path redirects, and a modal raised while that navigation is in flight is lost.
|
2026-09-21 19:48:16 +00:00
|
|
|
let held = 0
|
|
|
|
|
|
|
|
|
|
await page.context().route(
|
|
|
|
|
url => url.pathname.endsWith("/about/+page.svelte"),
|
|
|
|
|
async route => {
|
|
|
|
|
held++
|
|
|
|
|
|
|
|
|
|
await sleep(15000)
|
|
|
|
|
await route.continue()
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await page.goto(spacePath(other.url))
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("button", {name: "Join Space"})).toBeEnabled({timeout: 60000})
|
|
|
|
|
await expect(page).toHaveURL(pathPattern(spacePath(other.url) + "/about"))
|
|
|
|
|
await expect(page.getByRole("button", {name: "Join Space"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
// A page that stops asking for that module is a spec that no longer covers the race.
|
|
|
|
|
expect(held).toBeGreaterThan(0)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
test("US-011 request access when a space turns you away", async ({seed, as}) => {
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
const closed = relay("closed")
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// The relay only registers a claim through the nip-86 method the invite dialog calls.
|
2026-08-20 20:33:20 +00:00
|
|
|
closed.room("lobby", {name: "Lobby"})
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
space.join(user.admin, "general")
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
space.profile(user.bob, {name: "Bob Barnacle"})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const closed = scenario.space("closed")
|
|
|
|
|
const space = scenario.space("space")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Administering a relay is not the same as belonging to it.
|
2026-08-20 20:33:20 +00:00
|
|
|
const admin = await as(users.admin, spacePath(closed.url) + "/about")
|
|
|
|
|
|
|
|
|
|
await admin.locator("form").getByRole("button", {name: "Go back"}).click()
|
|
|
|
|
await admin.getByRole("button", {name: /closed\.test/}).click()
|
|
|
|
|
await admin.getByRole("button", {name: "Create Invite"}).click()
|
|
|
|
|
|
|
|
|
|
const inviteLink = admin.locator("input[readonly]")
|
|
|
|
|
|
|
|
|
|
await expect(inviteLink).toHaveValue(/[?&]c=\w/)
|
|
|
|
|
|
|
|
|
|
const [, claim = ""] = (await inviteLink.inputValue()).match(/[?&]c=([^&]+)/) ?? []
|
|
|
|
|
|
|
|
|
|
const alice = await as(users.alice, spacePath(closed.url))
|
|
|
|
|
|
|
|
|
|
// Turned away rather than joined, so the prompt becomes a request
|
|
|
|
|
await expect(alice.getByRole("button", {name: "Request Access"})).toBeEnabled()
|
|
|
|
|
|
|
|
|
|
await alice.getByRole("button", {name: "Request Access"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByRole("heading", {name: "Request Access"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
const code = alice.locator("form").filter({hasText: "Invite code*"}).getByRole("textbox")
|
|
|
|
|
|
|
|
|
|
await code.fill("NOTTHECODE")
|
|
|
|
|
await alice.getByRole("button", {name: "Join Space"}).click()
|
|
|
|
|
|
|
|
|
|
// The relay refused the code, so she is told and left asking
|
|
|
|
|
await expect(alice.getByRole("alert")).toContainText("join request rejected")
|
|
|
|
|
await expect(alice.getByRole("heading", {name: "Request Access"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await code.fill(claim)
|
|
|
|
|
await alice.getByRole("button", {name: "Join Space"}).click()
|
|
|
|
|
|
|
|
|
|
// The correct code granted her access, so the space stops turning her away
|
|
|
|
|
await expect(alice.getByRole("heading", {name: "Request Access"})).toHaveCount(0)
|
|
|
|
|
await expect(alice.getByRole("button", {name: "Join Space"})).toBeEnabled()
|
|
|
|
|
|
|
|
|
|
await alice.getByRole("button", {name: "Join Space"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByText("Welcome to the space!")).toBeVisible()
|
|
|
|
|
await expect(alice).toHaveURL(/\/spaces\/closed\.test/)
|
|
|
|
|
|
|
|
|
|
// A member the admin bans is told why by the relay itself
|
|
|
|
|
await admin.goto(spacePath(space.url) + "/directory")
|
|
|
|
|
|
|
|
|
|
const bobCard = admin
|
|
|
|
|
.getByRole("button", {name: "View Bob Barnacle's profile"})
|
|
|
|
|
.locator("xpath=..")
|
|
|
|
|
|
|
|
|
|
await bobCard.getByRole("button").last().click()
|
|
|
|
|
await admin.getByRole("button", {name: "Ban member"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByText("Ban @Bob Barnacle from the space?")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await admin.getByRole("button", {name: "Confirm"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByText("Member has successfully been banned!")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
const bob = await as(users.bob, spacePath(space.url))
|
|
|
|
|
|
|
|
|
|
await expect(bob.getByRole("heading", {name: "Access Error"})).toBeVisible()
|
|
|
|
|
await expect(bob.getByText(/not a member of this relay/i)).toBeVisible()
|
|
|
|
|
|
|
|
|
|
// From here he can ask for a code back in
|
|
|
|
|
await bob.getByRole("button", {name: "Request Access"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(bob.getByRole("heading", {name: "Request Access"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await bob.getByRole("button", {name: "Go back"}).click()
|
|
|
|
|
|
|
|
|
|
// ...or give up on the space, which takes it off his list
|
|
|
|
|
await bob.getByRole("button", {name: "Leave Space"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(bob).toHaveURL(/\/home/)
|
|
|
|
|
|
|
|
|
|
await bob.goto("/spaces")
|
|
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
await expect(railSpaces(bob)).toHaveCount(0)
|
2026-08-20 20:33:20 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => {
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
const unsigned = relay("unsigned")
|
|
|
|
|
|
|
|
|
|
unsigned.room("general", {name: "General"})
|
|
|
|
|
unsigned.join(user.alice, "general")
|
|
|
|
|
unsigned.join(user.bob, "general")
|
|
|
|
|
unsigned.message(user.alice, "general", "the hull is patched")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const unsigned = scenario.space("unsigned")
|
|
|
|
|
|
|
|
|
|
// This relay serves events with their signatures stripped, so it can forge messages
|
|
|
|
|
const bob = await as(users.bob, roomPath(unsigned.url, "general"))
|
|
|
|
|
|
|
|
|
|
await expect(bob.getByRole("heading", {name: "Do you trust this space?"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await bob.getByRole("button", {name: "I don't trust this space"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(bob).toHaveURL(/\/home/)
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// In-app rather than a fresh load: leaving is published in the background and reaches disk late.
|
2026-09-09 01:07:48 +00:00
|
|
|
await bob.getByRole("link", {name: "All Spaces"}).click()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
await expect(railSpaces(bob)).toHaveCount(0)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
const alice = await as(users.alice, roomPath(unsigned.url, "general"))
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByRole("heading", {name: "Do you trust this space?"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await alice.getByRole("button", {name: "I trust this space, continue"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByRole("heading", {name: "Do you trust this space?"})).toHaveCount(0)
|
|
|
|
|
await expect(alice.getByText("the hull is patched")).toBeVisible()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("US-013 follow a space that has moved", async ({seed, as}) => {
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
const other = relay("other")
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
other.room("general", {name: "General"})
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
|
|
|
|
|
// The new address has to let her in once her list points at it
|
|
|
|
|
other.member(user.alice)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const space = scenario.space("space")
|
|
|
|
|
const other = scenario.space("other")
|
|
|
|
|
|
|
|
|
|
// The relay's own nip-11 document is what reports the move
|
|
|
|
|
const relayInfo = {[space.url]: {redirect_to: "https://other.test/"}}
|
|
|
|
|
|
|
|
|
|
const alice = await as(users.alice, spacePath(space.url) + "/about", {relayInfo})
|
|
|
|
|
const movedDialog = alice.locator("form")
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByRole("heading", {name: "This space has moved"})).toBeVisible()
|
|
|
|
|
await expect(movedDialog.getByText("space.test", {exact: true})).toBeVisible()
|
|
|
|
|
await expect(movedDialog.getByText("other.test", {exact: true})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await alice.getByRole("button", {name: "Update and go"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/)
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// In-app rather than a fresh load: the updated list is published in the background.
|
2026-09-09 01:07:48 +00:00
|
|
|
await alice.getByRole("link", {name: "All Spaces"}).click()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
await expect(railSpaces(alice)).toHaveCount(1)
|
|
|
|
|
await expectRailFirst(alice, other.name)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
const bob = await as(users.bob, spacePath(space.url) + "/about", {relayInfo})
|
|
|
|
|
|
|
|
|
|
await expect(bob.getByRole("heading", {name: "This space has moved"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await bob.getByRole("button", {name: "Not now"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(bob).toHaveURL(/\/spaces\/space\.test\/about/)
|
|
|
|
|
|
|
|
|
|
await bob.goto("/spaces")
|
|
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
await expect(railSpaces(bob)).toHaveCount(1)
|
|
|
|
|
await expectRailFirst(bob, space.name)
|
2026-08-20 20:33:20 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("US-014 leave a space", async ({seed, as}) => {
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
space.message(user.bob, "general", "signing off for a while")
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const space = scenario.space("space")
|
|
|
|
|
const page = await as(users.bob, spacePath(space.url) + "/about")
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: /space\.test/}).click()
|
|
|
|
|
await page.getByRole("button", {name: "Leave Space"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("Are you sure you want to leave?")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Confirm"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(page).toHaveURL(/\/home/)
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// In-app rather than a fresh load: leaving is published in the background.
|
2026-09-09 01:07:48 +00:00
|
|
|
await page.getByRole("link", {name: "All Spaces"}).click()
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-17 18:26:01 +00:00
|
|
|
await expect(railSpaces(page)).toHaveCount(0)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
// Nothing stops him coming back
|
|
|
|
|
await page.getByRole("button", {name: "Add Space"}).click()
|
|
|
|
|
await page.getByRole("button", {name: /Join a space/}).click()
|
|
|
|
|
await page
|
|
|
|
|
.locator("form")
|
|
|
|
|
.filter({hasText: "Invite Link*"})
|
|
|
|
|
.getByRole("textbox")
|
|
|
|
|
.fill("https://app.flotilla.social/join?r=space.test&c=")
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("You're about to join:")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Join Space"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("Welcome to the space!")).toBeVisible()
|
|
|
|
|
await expect(page).toHaveURL(/\/spaces\/space\.test/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("US-015 view a space's details", async ({seed, as}) => {
|
2026-09-09 03:50:46 +00:00
|
|
|
const scenario = await seed(({relay, user}) => {
|
2026-08-20 20:33:20 +00:00
|
|
|
const space = relay("space")
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
space.join(user.admin, "general")
|
|
|
|
|
space.join(user.carol, "general")
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
space.profile(user.alice, {name: "Alice Anchor"})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const space = scenario.space("space")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// Contact, terms, privacy and the limitation warnings are nip-11 fields zooid doesn't publish.
|
2026-08-20 20:33:20 +00:00
|
|
|
const relayInfo = {
|
|
|
|
|
[space.url]: {
|
|
|
|
|
icon: "https://space.test/icon.png",
|
|
|
|
|
contact: "harbormaster@space.test",
|
|
|
|
|
terms_of_service: "https://space.test/terms",
|
|
|
|
|
privacy_policy: "https://space.test/privacy",
|
|
|
|
|
limitation: {auth_required: true, payment_required: true, min_pow_difficulty: 20},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const carol = await as(users.carol, spacePath(space.url) + "/about", {relayInfo})
|
|
|
|
|
|
|
|
|
|
await expect(carol.locator('img[src="https://space.test/icon.png"]').first()).toBeVisible()
|
|
|
|
|
await expect(carol.getByRole("heading", {name: "space"})).toBeVisible()
|
|
|
|
|
await expect(carol.getByText("space.test", {exact: true}).first()).toBeVisible()
|
|
|
|
|
await expect(carol.getByText(/Throwaway relay/)).toBeVisible()
|
|
|
|
|
await expect(carol.getByRole("link", {name: "Terms of Service"})).toBeVisible()
|
|
|
|
|
await expect(carol.getByRole("link", {name: "Privacy Policy"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await expect(carol.getByText(/Administrator:/)).toBeVisible()
|
|
|
|
|
await expect(carol.getByText("Contact: harbormaster@space.test")).toBeVisible()
|
|
|
|
|
await expect(carol.getByText(/Software:/)).toBeVisible()
|
|
|
|
|
await expect(carol.getByText(/Version:/)).toBeVisible()
|
|
|
|
|
await expect(carol.getByText("Auth Required")).toBeVisible()
|
|
|
|
|
await expect(carol.getByText("Payment Required")).toBeVisible()
|
|
|
|
|
await expect(carol.getByText("Min PoW: 20")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await expect(carol.getByRole("heading", {name: "Members"})).toBeVisible()
|
|
|
|
|
await expect(carol.getByText("Admins")).toBeVisible()
|
|
|
|
|
await expect(carol.getByText("New members")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
const admin = await as(users.admin, spacePath(space.url) + "/about", {relayInfo})
|
|
|
|
|
const featuredHeader = admin.getByRole("heading", {name: "Featured"}).locator("xpath=..")
|
|
|
|
|
|
|
|
|
|
await featuredHeader.getByRole("button").click()
|
|
|
|
|
await admin.getByRole("button", {name: "Add content"}).click()
|
|
|
|
|
await admin.getByPlaceholder("URL or nevent...").fill("Start with the harbor rules")
|
|
|
|
|
await admin.getByRole("button", {name: "Save changes"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByText("Featured content updated!")).toBeVisible()
|
|
|
|
|
|
2026-09-09 03:50:46 +00:00
|
|
|
// What admin featured reaches every visitor
|
2026-08-20 20:33:20 +00:00
|
|
|
await expect(carol.getByRole("heading", {name: "Featured"})).toBeVisible()
|
|
|
|
|
await expect(carol.getByText("Start with the harbor rules")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await carol.getByRole("link", {name: "View all members"}).click()
|
|
|
|
|
|
|
|
|
|
await expect(carol).toHaveURL(/\/spaces\/space\.test\/directory/)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("US-017 search across a space", 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.message(user.alice, "general", "the kraken surfaced at dawn", at(2, HOUR))
|
|
|
|
|
space.message(user.alice, "random", "kraken jokes only in here", at(3, DAY))
|
|
|
|
|
space.message(user.alice, "general", "ancient kraken lore, volume one", at(3, WEEK))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const space = scenario.space("space")
|
|
|
|
|
|
2026-09-24 18:23:21 +00:00
|
|
|
// The directory rather than a feed, since a result's own text can't be on the page behind it.
|
2026-08-20 20:33:20 +00:00
|
|
|
const page = await as(users.alice, spacePath(space.url) + "/directory")
|
|
|
|
|
|
|
|
|
|
await page.locator(".secondary-nav").getByRole("button", {name: "Search"}).click()
|
|
|
|
|
|
|
|
|
|
const term = page.getByPlaceholder("Search this space...")
|
|
|
|
|
|
|
|
|
|
await term.fill("narwhal")
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("No results found.")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await term.fill("kraken")
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("Last 24 Hours")).toBeVisible()
|
|
|
|
|
await expect(page.getByText("Last 7 Days")).toBeVisible()
|
|
|
|
|
await expect(page.getByText("Older")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("the kraken surfaced at dawn")).toBeVisible()
|
|
|
|
|
await expect(page.getByText("ancient kraken lore, volume one")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
const fromRandom = page.getByRole("button").filter({hasText: "kraken jokes only in here"})
|
|
|
|
|
|
|
|
|
|
await expect(fromRandom).toContainText("Random")
|
|
|
|
|
|
|
|
|
|
await fromRandom.click()
|
|
|
|
|
|
|
|
|
|
await expect(page).toHaveURL(/\/spaces\/space\.test\/random\?at=/)
|
|
|
|
|
})
|