flotilla/e2e/specs/spaces.spec.ts

598 lines
23 KiB
TypeScript
Raw Normal View History

2026-09-09 01:07:48 +00:00
import {DAY, HOUR, MINUTE, WEEK, sortBy} from "@welshman/lib"
import {ROOMS, THREAD, makeEvent} from "@welshman/util"
import type {Page} from "@playwright/test"
import {expect, readCachedEvents, roomPath, spacePath, test, users} from "../harness"
// The space this user's room list names first, read from the copy on disk the app restores itself
// from. Room lists reach indexeddb in three-second batches with nothing in the ui to say when one
// has landed, so a spec about what survives a reload waits on this before it reloads.
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
test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
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")
// The spaces page discovers unjoined spaces by pulling the room lists of the pubkeys it
// bootstraps from, so pointing that at bob is what puts his other space in front of alice.
const page = await as(users.alice, "/spaces", {env: {VITE_DEFAULT_PUBKEYS: users.bob.pubkey}})
await expect(page.getByText("Your spaces")).toBeVisible()
await expect(page.getByText(space.url)).toBeVisible()
await expect(page.getByText(other.url)).toBeVisible()
// ...and the rest of them in a section of their own
await expect(page.getByText("Browse Spaces")).toBeVisible()
await expect(page.getByText(unsigned.url)).toBeVisible()
const term = page.getByPlaceholder("Search for spaces...")
await term.fill("unsigned")
await expect(page.getByText(unsigned.url)).toBeVisible()
await expect(page.getByText(space.url)).toHaveCount(0)
await expect(page.getByText(other.url)).toHaveCount(0)
await term.fill("")
await expect(page.getByText(space.url)).toBeVisible()
// A space she's joined opens
await page.getByRole("listitem").filter({hasText: space.url}).click()
await expect(page).toHaveURL(/\/spaces\/space\.test\//)
// A space she hasn't asks her to join first
await page.goto("/spaces")
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()
// Reordering by dragging, which lives in her room list and so outlives the page
await page.goto("/spaces")
const joined = page.getByRole("listitem")
await expect(joined.first()).toContainText(space.url)
2026-09-09 01:07:48 +00:00
// Html5 drag and drop, dispatched rather than mimed with the mouse: chromium's synthetic drag
// starts the drag and moves it, but never delivers the drop the reorder is committed in, so the
// row would snap back to where it came from.
const dataTransfer = await page.evaluateHandle(() => new DataTransfer())
// The sidebar rail is the same list and reorders the same way. It shows icons and no text, so
// its rows are named by position and the list on the page is where the result is read.
const rail = page.locator(".primary-nav [draggable=true]")
await rail.nth(0).dispatchEvent("dragstart", {dataTransfer})
await rail.nth(1).dispatchEvent("drop", {dataTransfer})
await expect(joined.first()).toContainText(other.url)
await rail.nth(1).dispatchEvent("dragstart", {dataTransfer})
await rail.nth(0).dispatchEvent("drop", {dataTransfer})
await expect(joined.first()).toContainText(space.url)
// Dragging in the page's own list moves the same room list. It goes last because it shows the new
// order the moment it is dropped, ahead of the room list it publishes, so a drag in the rail
// straight afterwards would be working from the order it replaced.
2026-09-09 01:07:48 +00:00
const source = joined.filter({hasText: other.url})
const target = joined.filter({hasText: space.url})
await source.dispatchEvent("dragstart", {dataTransfer})
await target.dispatchEvent("drop", {dataTransfer})
2026-08-20 20:33:20 +00:00
await expect(joined.first()).toContainText(other.url)
2026-09-09 01:07:48 +00:00
// The reload restores the list from disk, so wait for the new order to land there rather than
// racing it — a page that read the old copy back keeps the order it started with.
await expect.poll(() => cachedFirstSpace(page, users.alice.pubkey)).toBe(other.url)
2026-08-20 20:33:20 +00:00
await page.reload()
await expect(page.getByRole("listitem").first()).toContainText(other.url)
})
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)
// The link makeInviteLink builds, typed rather than followed — an absolute platform url is an
// off-origin navigation, and parseInviteLink only ever reads its query params.
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/)
})
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")
// This relay refuses a join that carries no claim. A claim is not an event a scenario can
// publish — the relay only registers one through the nip-86 method the invite dialog calls —
// so the code alice needs comes out of that dialog below.
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")
// Administering a relay is not the same as belonging to it, so the space asks admin to join
// like anyone else — and issues him an invite code all the same.
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")
await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible()
})
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-09 01:07:48 +00:00
// In-app rather than a fresh load: leaving the space is published in the background, and a
// page that reloads before it reaches disk reads the list he had a moment ago.
await bob.getByRole("link", {name: "All Spaces"}).click()
2026-08-20 20:33:20 +00:00
await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible()
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-09 01:07:48 +00:00
// In-app rather than a fresh load: the updated list is published in the background, and a page
// that reloads before it reaches disk reads the old address back.
await alice.getByRole("link", {name: "All Spaces"}).click()
2026-08-20 20:33:20 +00:00
const aliceSpaces = alice.getByRole("listitem")
await expect(aliceSpaces).toHaveCount(1)
await expect(aliceSpaces.first()).toContainText(other.url)
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")
const bobSpaces = bob.getByRole("listitem")
await expect(bobSpaces).toHaveCount(1)
await expect(bobSpaces.first()).toContainText(space.url)
})
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-09 01:07:48 +00:00
// In-app rather than a fresh load: leaving is published in the background, and a page that
// reloads before it reaches disk reads the list he had a moment ago.
await page.getByRole("link", {name: "All Spaces"}).click()
2026-08-20 20:33:20 +00:00
await expect(page.getByText("You haven't joined any spaces yet.")).toBeVisible()
// 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}) => {
const scenario = await seed(({relay, user, at}) => {
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"})
space.message(user.alice, "general", "the tide is high", at(1, HOUR))
})
const space = scenario.space("space")
// Contact, terms, privacy and the limitation warnings are nip-11 fields zooid doesn't publish
// on its own, so the scenario merges them over the relay's real document.
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()
// Nothing is featured yet, so recent activity stands in for it
await expect(carol.getByRole("heading", {name: "Recent Activity"})).toBeVisible()
await expect(carol.getByText("the tide is high")).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()
// What admin featured reaches every visitor, and takes the top slot from recent activity
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-016 catch up on a space's recent activity", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => {
const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"})
space.room("quiet", {name: "Quiet Corner"})
space.join(user.alice, "general", "quiet")
space.join(user.bob, "general", "quiet")
space.message(user.alice, "general", "the tide is high", at(30, MINUTE))
space.message(user.alice, "quiet", "anyone still here?", at(3, HOUR))
space.event(
user.alice,
makeEvent(THREAD, {
created_at: at(2, HOUR),
content: "Where should we sail next?",
tags: [
["h", "general"],
["title", "Next voyage"],
],
}),
)
// More items than the page renders at once, so the oldest is only reachable by scrolling
for (let day = 1; day <= 20; day++) {
space.event(
user.alice,
makeEvent(THREAD, {
created_at: at(day, DAY),
content: `Log entry ${day}`,
tags: [
["h", "general"],
["title", `Old voyage ${day}`],
],
}),
)
}
// A space with a member and nothing else
other.join(user.bob)
})
const space = scenario.space("space")
const other = scenario.space("other")
const bob = await as(users.bob, spacePath(space.url) + "/recent")
const items = bob.locator(".cv")
await expect(bob.getByText("the tide is high")).toBeVisible()
await expect(bob.getByText("anyone still here?")).toBeVisible()
await expect(bob.getByText("Next voyage")).toBeVisible()
// Newest first: the latest message in each room alongside the thread, in one feed
await expect(items.first()).toContainText("the tide is high")
await bob.mouse.move(640, 400)
await bob.mouse.wheel(0, 8000)
await expect(bob.getByText("Old voyage 20")).toBeVisible()
// A message in the room that had gone quiet pulls it back to the top
const alice = await as(users.alice, roomPath(space.url, "quiet"))
await alice.locator(".chat-editor [contenteditable=true]").pressSequentially("still here!")
await alice.locator(".chat-editor [contenteditable=true]").press("Enter")
await expect(alice.getByText("still here!")).toBeVisible()
2026-09-09 01:07:48 +00:00
// The feed is assembled when the page loads rather than kept up to date behind the reader, so
// this is the order bob finds when he comes back to it.
await bob.reload()
2026-08-20 20:33:20 +00:00
await expect(items.first()).toContainText("Quiet Corner")
await bob.goto(spacePath(other.url) + "/recent")
await expect(bob.getByText("No recent activity found!")).toBeVisible()
})
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")
// The directory rather than a feed: search is reachable from every page in a space, and this is
// one where a result's own text can't also be on the page behind the dialog.
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=/)
})