2026-08-20 20:33:20 +00:00
|
|
|
|
import type {Locator, Page} from "@playwright/test"
|
|
|
|
|
|
import {DAY, HOUR, MINUTE, MONTH, now} from "@welshman/lib"
|
|
|
|
|
|
import {Article} from "@welshman/domain"
|
|
|
|
|
|
import {
|
|
|
|
|
|
expect,
|
|
|
|
|
|
getHosting,
|
|
|
|
|
|
makeTestUser,
|
|
|
|
|
|
mockBlossom,
|
|
|
|
|
|
roomPath,
|
|
|
|
|
|
spacePath,
|
|
|
|
|
|
test,
|
|
|
|
|
|
users,
|
|
|
|
|
|
} from "../harness"
|
|
|
|
|
|
|
|
|
|
|
|
// Where an icon upload lands. SpaceEdit and RelayForm both call uploadFileOrFallback with no relay
|
|
|
|
|
|
// of their own, so the space's blossom probe never runs and VITE_DEFAULT_BLOSSOM_SERVERS is what's
|
|
|
|
|
|
// left.
|
|
|
|
|
|
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
|
|
|
|
|
|
|
|
|
|
|
|
// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched
|
|
|
|
|
|
// instead of re-encoding it through a canvas, so what the mock hashes is what was picked.
|
|
|
|
|
|
const ICON = {
|
|
|
|
|
|
name: "icon.gif",
|
|
|
|
|
|
mimeType: "image/gif",
|
|
|
|
|
|
buffer: Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The plans the hosting backend offers. `free` is what RelayForm starts on, and `basic` is the paid
|
|
|
|
|
|
// one every upgrade story moves to. PricingTable names a plan by its member limit, which is the
|
|
|
|
|
|
// only part of a card that differs between the two.
|
|
|
|
|
|
const PLANS = [
|
|
|
|
|
|
{id: "free", name: "Free", amount: 0, hidden: false, members: 50, blossom: false, livekit: false},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "basic",
|
|
|
|
|
|
name: "Basic",
|
|
|
|
|
|
amount: 500,
|
|
|
|
|
|
hidden: false,
|
|
|
|
|
|
// Plan["members"] is number | null upstream; null is how the hosting API spells "unlimited".
|
|
|
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
|
|
|
|
members: null,
|
|
|
|
|
|
blossom: true,
|
|
|
|
|
|
livekit: true,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
// One relay as the hosting api serves it. Every field RelayDetailCard reads is present, since it
|
|
|
|
|
|
// calls .trim() and .replace() on several of them.
|
|
|
|
|
|
const hostedRelay = (overrides: Record<string, unknown> = {}) => ({
|
|
|
|
|
|
id: "relay-1",
|
|
|
|
|
|
tenant_pubkey: users.alice.pubkey,
|
|
|
|
|
|
subdomain: "space",
|
|
|
|
|
|
zooid_domain: "test",
|
|
|
|
|
|
plan_id: "free",
|
|
|
|
|
|
status: "active",
|
|
|
|
|
|
sync_error: "",
|
|
|
|
|
|
synced: now(),
|
|
|
|
|
|
custom_domain: "",
|
|
|
|
|
|
custom_domain_verified: 0,
|
|
|
|
|
|
info_name: "Space",
|
|
|
|
|
|
info_icon: "",
|
|
|
|
|
|
info_description: "",
|
|
|
|
|
|
policy_public_read: 0,
|
|
|
|
|
|
policy_public_write: 0,
|
|
|
|
|
|
policy_public_join: 1,
|
|
|
|
|
|
policy_strip_signatures: 0,
|
|
|
|
|
|
groups_enabled: 1,
|
|
|
|
|
|
management_enabled: 1,
|
|
|
|
|
|
blossom_enabled: 0,
|
|
|
|
|
|
livekit_enabled: 0,
|
|
|
|
|
|
push_enabled: 1,
|
|
|
|
|
|
...overrides,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// The panel of the modal carrying a given title. `.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()
|
|
|
|
|
|
|
|
|
|
|
|
// Tippy mounts a menu the first time it is opened and leaves it in the dom when it hides, so a page
|
|
|
|
|
|
// that has opened two of them holds both — only the one on screen is visible. Exact, because
|
|
|
|
|
|
// "Edit role" and "Edit roles" are two different menus in the same directory.
|
|
|
|
|
|
const menuItem = (page: Page, name: string) =>
|
|
|
|
|
|
page.getByRole("button", {name, exact: true}).filter({visible: true})
|
|
|
|
|
|
|
|
|
|
|
|
// Every space-level admin action hangs off the space menu, which opens from the header button in
|
|
|
|
|
|
// the secondary nav. That button is labeled with the space's name and its host, and only the host
|
|
|
|
|
|
// survives a rename.
|
|
|
|
|
|
const openSpaceMenu = (page: Page) => page.getByRole("button", {name: /space\.test/}).click()
|
|
|
|
|
|
|
|
|
|
|
|
// SpaceMember covers its card with a button whose aria-label is baked from the profile display at
|
|
|
|
|
|
// first render — before the profile has loaded — so the card is found by the name it shows rather
|
|
|
|
|
|
// than by that label. It is the only interactive card in the directory, and the member's own menu
|
|
|
|
|
|
// is the last button inside it.
|
|
|
|
|
|
const memberCard = (page: Page, name: string) =>
|
|
|
|
|
|
page.locator(".card-interactive").filter({hasText: name})
|
|
|
|
|
|
|
|
|
|
|
|
// RoomItem gives its hover actions no accessible names — every one is an icon — and the menu is
|
|
|
|
|
|
// the last of them.
|
|
|
|
|
|
const openMessageMenu = (page: Page, text: string) =>
|
|
|
|
|
|
page
|
|
|
|
|
|
.locator(".room__item")
|
|
|
|
|
|
.filter({hasText: text})
|
|
|
|
|
|
.locator(".room__item-actions button")
|
|
|
|
|
|
.last()
|
|
|
|
|
|
.click()
|
|
|
|
|
|
|
|
|
|
|
|
// EventActions renders zap, emoji and menu into one join, so the menu is the last button in it.
|
|
|
|
|
|
const openEventMenu = (card: Locator) => card.locator(".join").getByRole("button").last().click()
|
|
|
|
|
|
|
|
|
|
|
|
const articleCard = (page: Page, title: string) =>
|
|
|
|
|
|
page.getByRole("link", {name: /Written by/}).filter({hasText: title})
|
|
|
|
|
|
|
|
|
|
|
|
// A FieldInline puts its control in the div immediately after its label, which is how one row of
|
|
|
|
|
|
// the hosting card is told apart from the others in the same grid.
|
|
|
|
|
|
const setting = (page: Page, label: string) =>
|
|
|
|
|
|
page.locator("label").filter({hasText: label}).locator("xpath=following-sibling::div")
|
|
|
|
|
|
|
|
|
|
|
|
const settingToggle = (page: Page, label: string) => setting(page, label).getByRole("checkbox")
|
|
|
|
|
|
|
|
|
|
|
|
// A hosted relay's status and its plan each render as a badge beside its name.
|
|
|
|
|
|
const badge = (page: Page, text: string) => page.locator(".badge").filter({hasText: text})
|
|
|
|
|
|
|
|
|
|
|
|
test("US-092 edit a space's profile and featured content", async ({seed, as}) => {
|
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
|
space.join(user.admin, "general")
|
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
|
const admin = await as(users.admin, spacePath(url) + "/about")
|
|
|
|
|
|
|
|
|
|
|
|
await mockBlossom(admin.context(), {server: BLOSSOM_ORIGIN})
|
|
|
|
|
|
|
|
|
|
|
|
await openSpaceMenu(admin)
|
|
|
|
|
|
await menuItem(admin, "Edit Space").click()
|
|
|
|
|
|
|
|
|
|
|
|
const editor = dialog(admin, "Edit this Space")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(editor).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
// The icon picker's file input is hidden inside its own label, so the chooser is opened by
|
|
|
|
|
|
// clicking the label rather than by writing to the input.
|
|
|
|
|
|
const chooser = admin.waitForEvent("filechooser")
|
|
|
|
|
|
|
|
|
|
|
|
await editor.locator('label:has(input[type="file"])').click()
|
|
|
|
|
|
await (await chooser).setFiles(ICON)
|
|
|
|
|
|
|
|
|
|
|
|
await expect(editor.getByText("Selected:")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await editor.locator('input[type="text"]').fill("Harbor")
|
|
|
|
|
|
await editor.locator("textarea").fill("Where the fleet ties up.")
|
|
|
|
|
|
await editor.getByRole("button", {name: "Save Changes"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Your changes have been saved!")
|
|
|
|
|
|
|
|
|
|
|
|
// All three come back off the relay's own nip-11 document
|
|
|
|
|
|
await expect(admin.getByRole("heading", {name: "Harbor"})).toBeVisible()
|
|
|
|
|
|
await expect(admin.getByText("Where the fleet ties up.")).toBeVisible()
|
|
|
|
|
|
await expect(admin.locator(`img[src^="${BLOSSOM_ORIGIN}"]`).first()).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
const featured = admin
|
|
|
|
|
|
.locator(".card")
|
|
|
|
|
|
.filter({has: admin.getByRole("heading", {name: "Featured"})})
|
|
|
|
|
|
|
|
|
|
|
|
await expect(featured.getByText("No featured content yet.")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await featured.getByRole("button").first().click()
|
|
|
|
|
|
|
|
|
|
|
|
const featuredEditor = dialog(admin, "Featured Content")
|
|
|
|
|
|
|
|
|
|
|
|
await featuredEditor.getByRole("button", {name: "Add content"}).click()
|
|
|
|
|
|
await featuredEditor.getByPlaceholder("URL or nevent...").fill("Read the harbor rules first")
|
|
|
|
|
|
await featuredEditor.getByRole("button", {name: "Save changes"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Featured content updated!")
|
|
|
|
|
|
await expect(featured.getByText("Read the harbor rules first")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
// What admin published reaches every visitor, without any of the controls that produced it
|
|
|
|
|
|
const bob = await as(users.bob, spacePath(url) + "/about")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(bob.getByRole("heading", {name: "Harbor"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
const bobsFeatured = bob
|
|
|
|
|
|
.locator(".card")
|
|
|
|
|
|
.filter({has: bob.getByRole("heading", {name: "Featured"})})
|
|
|
|
|
|
|
|
|
|
|
|
await expect(bobsFeatured.getByText("Read the harbor rules first")).toBeVisible()
|
|
|
|
|
|
await expect(bobsFeatured.getByRole("button")).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
await openSpaceMenu(bob)
|
|
|
|
|
|
|
|
|
|
|
|
await expect(menuItem(bob, "Create Invite")).toBeVisible()
|
|
|
|
|
|
await expect(bob.getByRole("button", {name: "Edit Space", exact: true})).toHaveCount(0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test("US-093 create roles and assign them", async ({seed, as}) => {
|
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
|
space.join(user.admin, "general")
|
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
|
space.join(user.carol, "general")
|
|
|
|
|
|
space.profile(user.bob, {name: "Bob Barnacle"})
|
|
|
|
|
|
space.profile(user.carol, {name: "Carol Cutter"})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
|
const admin = await as(users.admin, spacePath(url) + "/directory")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(memberCard(admin, "Bob Barnacle")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await admin.getByRole("button", {name: "More options"}).click()
|
|
|
|
|
|
await menuItem(admin, "Manage Roles").click()
|
|
|
|
|
|
|
|
|
|
|
|
const roles = dialog(admin, "Manage Roles")
|
|
|
|
|
|
const emptyState = roles.getByText("No roles yet. Create one to start organizing members.")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(emptyState).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await roles.getByRole("button", {name: "Create Role"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
const roleForm = dialog(admin, "Create Role")
|
|
|
|
|
|
|
|
|
|
|
|
await roleForm.getByPlaceholder("Moderator").fill("Deckhand")
|
|
|
|
|
|
await roleForm.locator("textarea").fill("Keeps the decks in order.")
|
|
|
|
|
|
await roleForm.getByRole("slider").fill("200")
|
|
|
|
|
|
await roleForm.getByRole("button", {name: "Save changes"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Role created!")
|
|
|
|
|
|
await expect(roles.locator(".role-badge")).toHaveText("Deckhand")
|
|
|
|
|
|
|
|
|
|
|
|
// Editing the label rewrites the role rather than adding a second one
|
|
|
|
|
|
await roles.locator(".card").filter({hasText: "Deckhand"}).getByRole("button").last().click()
|
|
|
|
|
|
await menuItem(admin, "Edit role").click()
|
|
|
|
|
|
|
|
|
|
|
|
const roleEditor = dialog(admin, "Edit Role")
|
|
|
|
|
|
|
|
|
|
|
|
await roleEditor.getByPlaceholder("Moderator").fill("Bosun")
|
|
|
|
|
|
await roleEditor.getByRole("button", {name: "Save changes"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Role updated!")
|
|
|
|
|
|
await expect(roles.locator(".role-badge")).toHaveText("Bosun")
|
|
|
|
|
|
|
|
|
|
|
|
// Handed out from the role's own menu...
|
|
|
|
|
|
await roles.locator(".card").filter({hasText: "Bosun"}).getByRole("button").last().click()
|
|
|
|
|
|
await menuItem(admin, "Add members").click()
|
|
|
|
|
|
|
|
|
|
|
|
const addMembers = dialog(admin, "Add to Bosun")
|
|
|
|
|
|
|
|
|
|
|
|
await addMembers.getByPlaceholder("Search for profiles...").fill("Bob")
|
|
|
|
|
|
await admin.locator(".tiptap-suggestions").getByRole("button", {name: users.bob.pubkey}).click()
|
|
|
|
|
|
await addMembers.getByRole("button", {name: "Save changes"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Members assigned!")
|
|
|
|
|
|
|
|
|
|
|
|
await roles.getByRole("button", {name: "Go back"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
const bobsBadge = memberCard(admin, "Bob Barnacle").locator(".role-badge")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(bobsBadge).toHaveText("Bosun")
|
|
|
|
|
|
await expect(bobsBadge).toHaveAttribute("style", /--role-color: hsl\(200/)
|
|
|
|
|
|
|
|
|
|
|
|
// ...or from a member's own card, which is also how it comes back off
|
|
|
|
|
|
await memberCard(admin, "Carol Cutter").getByRole("button").last().click()
|
|
|
|
|
|
await menuItem(admin, "Edit roles").click()
|
|
|
|
|
|
|
|
|
|
|
|
const carolsRoles = dialog(admin, "Edit Member")
|
|
|
|
|
|
|
|
|
|
|
|
await carolsRoles.getByRole("checkbox").check()
|
|
|
|
|
|
await carolsRoles.getByRole("button", {name: "Save changes"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Roles updated!")
|
|
|
|
|
|
await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveText("Bosun")
|
|
|
|
|
|
|
|
|
|
|
|
// The directory's search covers the roles a member holds as well as their name
|
|
|
|
|
|
const term = admin.getByPlaceholder("Search people or roles...")
|
|
|
|
|
|
|
|
|
|
|
|
await term.fill("Bosun")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.locator(".card-interactive")).toHaveCount(2)
|
|
|
|
|
|
await expect(memberCard(admin, "Bob Barnacle")).toBeVisible()
|
|
|
|
|
|
await expect(memberCard(admin, "Carol Cutter")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await term.fill("")
|
|
|
|
|
|
|
|
|
|
|
|
await memberCard(admin, "Carol Cutter").getByRole("button").last().click()
|
|
|
|
|
|
await menuItem(admin, "Edit roles").click()
|
|
|
|
|
|
await carolsRoles.getByRole("checkbox").uncheck()
|
|
|
|
|
|
await carolsRoles.getByRole("button", {name: "Save changes"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Roles updated!")
|
|
|
|
|
|
await expect(memberCard(admin, "Carol Cutter")).toBeVisible()
|
|
|
|
|
|
await expect(memberCard(admin, "Carol Cutter").locator(".role-badge")).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
await admin.getByRole("button", {name: "More options"}).click()
|
|
|
|
|
|
await menuItem(admin, "Manage Roles").click()
|
|
|
|
|
|
await roles.locator(".card").filter({hasText: "Bosun"}).getByRole("button").last().click()
|
|
|
|
|
|
await menuItem(admin, "Delete role").click()
|
|
|
|
|
|
|
|
|
|
|
|
const confirm = dialog(admin, "Delete Role")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
confirm.getByText('Delete the "Bosun" role? Members will keep their space membership.'),
|
|
|
|
|
|
).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await confirm.getByRole("button", {name: "Confirm"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Role deleted!")
|
|
|
|
|
|
await expect(emptyState).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await roles.getByRole("button", {name: "Go back"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(memberCard(admin, "Bob Barnacle")).toBeVisible()
|
|
|
|
|
|
await expect(memberCard(admin, "Bob Barnacle").locator(".role-badge")).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
const bob = await as(users.bob, spacePath(url) + "/directory")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(memberCard(bob, "Carol Cutter")).toBeVisible()
|
|
|
|
|
|
await expect(bob.getByRole("button", {name: "More options"})).toHaveCount(0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test("US-094 invite people to a space", async ({seed, as}) => {
|
|
|
|
|
|
const newcomer = makeTestUser("newcomer")
|
|
|
|
|
|
|
|
|
|
|
|
const scenario = await seed(({relay, user, at}) => {
|
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
const other = relay("other")
|
|
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
|
space.join(user.admin, "general")
|
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
|
space.profile(user.bob, {name: "Bob Barnacle"})
|
|
|
|
|
|
|
|
|
|
|
|
// Somebody who belongs to a different space, so adding her here is a change to this space's
|
|
|
|
|
|
// member list rather than something the scenario already seeded.
|
|
|
|
|
|
other.room("lounge", {name: "Lounge"})
|
|
|
|
|
|
other.join(user.admin, "lounge")
|
|
|
|
|
|
other.join(newcomer, "lounge")
|
|
|
|
|
|
other.profile(newcomer, {name: "Nadia Newcomer"})
|
|
|
|
|
|
other.message(newcomer, "lounge", "hello from the lounge", at(2, HOUR))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const other = scenario.space("other")
|
|
|
|
|
|
|
|
|
|
|
|
// Arriving through the other space is what loads Nadia's profile: the invite dialog's search
|
|
|
|
|
|
// reads the profiles this client already holds.
|
|
|
|
|
|
const page = await as(users.admin, roomPath(other.url, "lounge"), {
|
|
|
|
|
|
context: {permissions: ["clipboard-read", "clipboard-write"]},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("Nadia Newcomer").first()).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
// In-app navigation rather than a reload, which would empty the repository she was loaded into
|
|
|
|
|
|
await page.locator('.primary-nav [data-tip^="space"]').click()
|
|
|
|
|
|
await openSpaceMenu(page)
|
|
|
|
|
|
await menuItem(page, "Create Invite").click()
|
|
|
|
|
|
|
|
|
|
|
|
const invite = dialog(page, "Create an Invite")
|
|
|
|
|
|
const link = invite.locator("input[readonly]")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(invite.locator("canvas")).toBeVisible()
|
|
|
|
|
|
await expect(link).toHaveValue(/\/join\?r=space\.test&c=.+/)
|
|
|
|
|
|
|
|
|
|
|
|
await invite.locator("label:has(input[readonly])").getByRole("button").click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
|
|
|
|
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(await link.inputValue())
|
|
|
|
|
|
|
|
|
|
|
|
await invite.getByPlaceholder("Search for profiles...").fill("Nadia")
|
|
|
|
|
|
await page.locator(".tiptap-suggestions").getByRole("button", {name: newcomer.pubkey}).click()
|
|
|
|
|
|
await invite.getByRole("button", {name: "Save", exact: true}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Members have successfully been added!")
|
|
|
|
|
|
|
|
|
|
|
|
await page.locator(".secondary-nav").getByRole("link", {name: "Directory"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(memberCard(page, "Nadia Newcomer")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
// A room's own invite carries the space's claim and that room's join code alongside it
|
|
|
|
|
|
await page.locator(".space-menu__scroll").getByRole("link", {name: "General"}).click()
|
|
|
|
|
|
await page.locator('[data-component="PageBar"]').getByRole("button").last().click()
|
|
|
|
|
|
await page.getByRole("button", {name: "Create invite"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
const roomInvite = dialog(page, "Create a Room Invite")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(roomInvite.locator("canvas")).toBeVisible()
|
|
|
|
|
|
await expect(roomInvite.locator("input[readonly]")).toHaveValue(
|
|
|
|
|
|
/\/join\?r=space\.test&c=[^&]+&h=general&code=.+/,
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test("US-095 remove, ban, and restore members", async ({seed, as}) => {
|
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
|
space.join(user.admin, "general")
|
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
|
space.join(user.carol, "general")
|
|
|
|
|
|
space.profile(user.bob, {name: "Bob Barnacle"})
|
|
|
|
|
|
space.profile(user.carol, {name: "Carol Cutter"})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
|
|
|
|
|
|
|
// An ordinary member reads the same directory without any of the controls that manage it
|
|
|
|
|
|
const bob = await as(users.bob, spacePath(url) + "/directory")
|
|
|
|
|
|
const carolsCard = memberCard(bob, "Carol Cutter")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(carolsCard).toBeVisible()
|
|
|
|
|
|
await expect(carolsCard.getByRole("button")).toHaveCount(1)
|
|
|
|
|
|
await expect(bob.getByRole("button", {name: "More options"})).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
const admin = await as(users.admin, spacePath(url) + "/directory")
|
|
|
|
|
|
|
|
|
|
|
|
await memberCard(admin, "Bob Barnacle").getByRole("button").last().click()
|
|
|
|
|
|
await menuItem(admin, "Remove member").click()
|
|
|
|
|
|
|
|
|
|
|
|
const removal = dialog(admin, "Remove Member")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(removal.getByText("Remove @Bob Barnacle from the space?")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await removal.getByRole("button", {name: "Confirm"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Member has successfully been removed!")
|
|
|
|
|
|
await expect(memberCard(admin, "Bob Barnacle")).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
await memberCard(admin, "Carol Cutter").getByRole("button").last().click()
|
|
|
|
|
|
await menuItem(admin, "Ban member").click()
|
|
|
|
|
|
|
|
|
|
|
|
const ban = dialog(admin, "Ban Member")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(ban.getByText("Ban @Carol Cutter from the space?")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await ban.getByRole("button", {name: "Confirm"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Member has successfully been banned!")
|
|
|
|
|
|
await expect(memberCard(admin, "Carol Cutter")).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
await admin.getByRole("button", {name: "More options"}).click()
|
|
|
|
|
|
await menuItem(admin, "Banned Members").click()
|
|
|
|
|
|
|
|
|
|
|
|
const banned = dialog(admin, "Banned users")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(banned.getByText("Carol Cutter")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await banned.locator(".card").filter({hasText: "Carol Cutter"}).getByRole("button").last().click()
|
|
|
|
|
|
await menuItem(admin, "Restore User").click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText(
|
|
|
|
|
|
"User has successfully been restored to membership!",
|
|
|
|
|
|
)
|
|
|
|
|
|
await expect(memberCard(admin, "Carol Cutter")).toBeVisible()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test("US-096 moderate messages and posts", 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.alice, "general")
|
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
|
space.join(user.carol, "general")
|
|
|
|
|
|
space.profile(user.alice, {name: "Alice Anchor"})
|
|
|
|
|
|
space.profile(user.bob, {name: "Bob Barnacle"})
|
|
|
|
|
|
space.message(user.alice, "general", "the hull is patched", at(2, HOUR))
|
|
|
|
|
|
space.message(user.bob, "general", "aye captain", at(90, MINUTE))
|
|
|
|
|
|
|
|
|
|
|
|
space.event(
|
|
|
|
|
|
user.bob,
|
|
|
|
|
|
() =>
|
|
|
|
|
|
space
|
|
|
|
|
|
.kind(Article)
|
|
|
|
|
|
.writer()
|
|
|
|
|
|
.setIdentifier("charting-the-reef")
|
|
|
|
|
|
.setTitle("Charting the Reef")
|
|
|
|
|
|
.setSummary("Where the shallows are.")
|
|
|
|
|
|
.setPublishedAt(at(3, HOUR))
|
|
|
|
|
|
.setContent("Mind the coral.")
|
|
|
|
|
|
.renderTemplate(),
|
|
|
|
|
|
at(3, HOUR),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// A second article, so a page with the deleted one missing is still a page that loaded
|
|
|
|
|
|
space.event(
|
|
|
|
|
|
user.alice,
|
|
|
|
|
|
() =>
|
|
|
|
|
|
space
|
|
|
|
|
|
.kind(Article)
|
|
|
|
|
|
.writer()
|
|
|
|
|
|
.setIdentifier("tides-and-timetables")
|
|
|
|
|
|
.setTitle("Tides and Timetables")
|
|
|
|
|
|
.setSummary("When to sail.")
|
|
|
|
|
|
.setPublishedAt(at(4, HOUR))
|
|
|
|
|
|
.setContent("Slack water is best.")
|
|
|
|
|
|
.renderTemplate(),
|
|
|
|
|
|
at(4, HOUR),
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
|
|
|
|
|
|
|
// An author gets a delete on their own content, and nothing to report
|
|
|
|
|
|
const alice = await as(users.alice, roomPath(url, "general"))
|
|
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByText("the hull is patched")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await openMessageMenu(alice, "the hull is patched")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(menuItem(alice, "Delete Message")).toBeVisible()
|
|
|
|
|
|
await expect(alice.getByRole("button", {name: "Report Content", exact: true})).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
await alice.goto(spacePath(url) + "/articles")
|
|
|
|
|
|
await openEventMenu(articleCard(alice, "Tides and Timetables"))
|
|
|
|
|
|
|
|
|
|
|
|
await expect(menuItem(alice, "Delete Article")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
// Everybody else gets a report instead, and the report needs a reason
|
|
|
|
|
|
const bob = await as(users.bob, roomPath(url, "general"))
|
|
|
|
|
|
|
|
|
|
|
|
await openMessageMenu(bob, "the hull is patched")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(menuItem(bob, "Report Content")).toBeVisible()
|
|
|
|
|
|
await expect(bob.getByRole("button", {name: "Delete Message", exact: true})).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
await menuItem(bob, "Report Content").click()
|
|
|
|
|
|
|
|
|
|
|
|
const report = dialog(bob, "Report Content")
|
|
|
|
|
|
|
|
|
|
|
|
await report.getByRole("button", {name: "Send Report"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(bob.getByRole("alert")).toContainText("Please select a reason for your report.")
|
|
|
|
|
|
await expect(report).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await report.getByRole("combobox").selectOption("Spam")
|
|
|
|
|
|
await report.getByRole("button", {name: "Send Report"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(bob.getByRole("alert")).toContainText("Your report has been sent!")
|
|
|
|
|
|
|
|
|
|
|
|
// An admin gets a delete on anything, authored by them or not
|
|
|
|
|
|
const admin = await as(users.admin, roomPath(url, "general"))
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByText("aye captain")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await openMessageMenu(admin, "aye captain")
|
|
|
|
|
|
await menuItem(admin, "Delete Message").click()
|
|
|
|
|
|
|
|
|
|
|
|
const messageConfirm = dialog(admin, "Delete Message")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
messageConfirm.getByText("Are you sure you want to delete this message from the space?"),
|
|
|
|
|
|
).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await messageConfirm.getByRole("button", {name: "Confirm"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Event has successfully been deleted!")
|
|
|
|
|
|
|
|
|
|
|
|
await admin.goto(spacePath(url) + "/articles")
|
|
|
|
|
|
await openEventMenu(articleCard(admin, "Charting the Reef"))
|
|
|
|
|
|
await menuItem(admin, "Delete Article").click()
|
|
|
|
|
|
|
|
|
|
|
|
const articleConfirm = dialog(admin, "Delete Article")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
articleConfirm.getByText("Are you sure you want to delete this article from the space?"),
|
|
|
|
|
|
).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await articleConfirm.getByRole("button", {name: "Confirm"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Event has successfully been deleted!")
|
|
|
|
|
|
|
|
|
|
|
|
// A viewer arriving after the fact reads the space as the relay now holds it
|
|
|
|
|
|
const carol = await as(users.carol, roomPath(url, "general"))
|
|
|
|
|
|
|
|
|
|
|
|
await expect(carol.getByText("the hull is patched")).toBeVisible()
|
|
|
|
|
|
await expect(carol.getByText("aye captain")).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
await carol.goto(spacePath(url) + "/articles")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(articleCard(carol, "Tides and Timetables")).toBeVisible()
|
|
|
|
|
|
await expect(articleCard(carol, "Charting the Reef")).toHaveCount(0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test("US-097 work through the action-items queue", async ({seed, as}) => {
|
|
|
|
|
|
const scenario = await seed(({relay, user, at}) => {
|
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
|
// A room the relay will not admit anyone to on their own say-so, so a join request stays
|
|
|
|
|
|
// pending instead of being granted the moment it lands.
|
|
|
|
|
|
space.room("vault", {name: "Vault", closed: true, private: true})
|
|
|
|
|
|
space.join(user.admin, "general", "vault")
|
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
|
space.join(user.carol, "general")
|
|
|
|
|
|
space.profile(user.alice, {name: "Alice Anchor"})
|
|
|
|
|
|
space.profile(user.bob, {name: "Bob Barnacle"})
|
|
|
|
|
|
space.profile(user.carol, {name: "Carol Cutter"})
|
|
|
|
|
|
space.message(user.alice, "general", "the hull is patched", at(2, HOUR))
|
|
|
|
|
|
space.message(user.alice, "general", "the sails are torn", at(90, MINUTE))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
|
const admin = await as(users.admin, spacePath(url) + "/about")
|
|
|
|
|
|
const queueButton = admin.getByRole("button", {name: /^Action Items/})
|
|
|
|
|
|
|
|
|
|
|
|
await openSpaceMenu(admin)
|
|
|
|
|
|
|
|
|
|
|
|
await expect(queueButton).toHaveText("Action Items (0)")
|
|
|
|
|
|
await expect(queueButton.locator(".bg-primary")).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
await admin.keyboard.press("Escape")
|
|
|
|
|
|
|
|
|
|
|
|
const report = async (page: Page, text: string, reason: string) => {
|
|
|
|
|
|
await openMessageMenu(page, text)
|
|
|
|
|
|
await menuItem(page, "Report Content").click()
|
|
|
|
|
|
|
|
|
|
|
|
const form = dialog(page, "Report Content")
|
|
|
|
|
|
|
|
|
|
|
|
await form.getByRole("combobox").selectOption(reason)
|
|
|
|
|
|
await form.getByRole("button", {name: "Send Report"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Your report has been sent!")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const askToJoinVault = async (page: Page) => {
|
|
|
|
|
|
await page.goto(roomPath(url, "vault"))
|
|
|
|
|
|
await page.getByRole("button", {name: "Join Room"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("button", {name: "Access Pending"})).toBeVisible()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const bob = await as(users.bob, roomPath(url, "general"))
|
|
|
|
|
|
|
|
|
|
|
|
await expect(bob.getByText("the hull is patched")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await report(bob, "the hull is patched", "Spam")
|
|
|
|
|
|
await askToJoinVault(bob)
|
|
|
|
|
|
|
|
|
|
|
|
const carol = await as(users.carol, roomPath(url, "general"))
|
|
|
|
|
|
|
|
|
|
|
|
await expect(carol.getByText("the sails are torn")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await report(carol, "the sails are torn", "Other")
|
|
|
|
|
|
await askToJoinVault(carol)
|
|
|
|
|
|
|
|
|
|
|
|
await openSpaceMenu(admin)
|
|
|
|
|
|
|
2026-08-22 16:10:47 +00:00
|
|
|
|
await expect(queueButton).toHaveText("Action Items (4)")
|
|
|
|
|
|
await expect(queueButton.locator(".bg-primary")).toHaveCount(1)
|
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
|
await queueButton.click()
|
|
|
|
|
|
|
|
|
|
|
|
const queue = dialog(admin, "Action Items")
|
|
|
|
|
|
const item = (text: string, kind: string) =>
|
|
|
|
|
|
queue.locator(".card").filter({hasText: text}).filter({hasText: kind})
|
|
|
|
|
|
|
|
|
|
|
|
// Each report names its reporter, its reason and the message it was filed against
|
|
|
|
|
|
const spamReport = item("Bob Barnacle", "Reported this event")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(spamReport).toContainText('as "spam"')
|
|
|
|
|
|
await expect(spamReport).toContainText("the hull is patched")
|
|
|
|
|
|
|
|
|
|
|
|
const otherReport = item("Carol Cutter", "Reported this event")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(otherReport).toContainText('as "other"')
|
|
|
|
|
|
await expect(otherReport).toContainText("the sails are torn")
|
|
|
|
|
|
|
|
|
|
|
|
// ...and each join request names its requester and the room they asked for
|
|
|
|
|
|
const carolsRequest = item("Carol Cutter", "requested membership")
|
|
|
|
|
|
const bobsRequest = item("Bob Barnacle", "requested membership")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(carolsRequest).toContainText("requested membership in #Vault")
|
|
|
|
|
|
await expect(bobsRequest).toContainText("requested membership in #Vault")
|
|
|
|
|
|
|
|
|
|
|
|
await carolsRequest.getByRole("button", {name: "Accept"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Member has been added to the room!")
|
|
|
|
|
|
await expect(carolsRequest).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
await bobsRequest.getByRole("button", {name: "Dismiss"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Join request has been dismissed.")
|
|
|
|
|
|
await expect(bobsRequest).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
// Removing content deletes the message the report was filed against...
|
|
|
|
|
|
await spamReport.getByRole("button").first().click()
|
|
|
|
|
|
await menuItem(admin, "Remove Content").click()
|
|
|
|
|
|
|
|
|
|
|
|
const removal = dialog(admin, "Remove Content")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(
|
|
|
|
|
|
removal.getByText("Are you sure you want to delete this content from the space?"),
|
|
|
|
|
|
).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await removal.getByRole("button", {name: "Confirm"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(admin.getByRole("alert")).toContainText("Content has successfully been deleted!")
|
|
|
|
|
|
await expect(spamReport).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
// ...while dismissing only clears the report
|
|
|
|
|
|
await otherReport.getByRole("button").first().click()
|
|
|
|
|
|
await menuItem(admin, "Dismiss Report").click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(otherReport).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
// Accepting granted membership, dismissing did not
|
|
|
|
|
|
await admin.goto(roomPath(url, "vault"))
|
|
|
|
|
|
await admin.locator('[data-component="PageBar"]').getByRole("button").last().click()
|
|
|
|
|
|
await admin.getByRole("button", {name: "View All"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
const members = dialog(admin, "Members")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(members.getByText("Carol Cutter")).toBeVisible()
|
|
|
|
|
|
await expect(members.getByText("Bob Barnacle")).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
// The removed message is gone from the space, the dismissed one is not
|
|
|
|
|
|
const alice = await as(users.alice, roomPath(url, "general"))
|
|
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByText("the sails are torn")).toBeVisible()
|
|
|
|
|
|
await expect(alice.getByText("the hull is patched")).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
// A member never sees the queue at all
|
|
|
|
|
|
const bobsSpace = await as(users.bob, spacePath(url) + "/about")
|
|
|
|
|
|
|
|
|
|
|
|
await openSpaceMenu(bobsSpace)
|
|
|
|
|
|
|
|
|
|
|
|
await expect(menuItem(bobsSpace, "Create Invite")).toBeVisible()
|
|
|
|
|
|
await expect(bobsSpace.getByRole("button", {name: /^Action Items/})).toHaveCount(0)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test("US-098 browse and create hosted spaces", async ({seed, as}) => {
|
|
|
|
|
|
await seed(({relay, user}) => {
|
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
const other = relay("other")
|
|
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
|
other.room("lounge", {name: "Lounge"})
|
|
|
|
|
|
other.join(user.alice, "lounge")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const page = await as(users.alice, "/settings/hosting", {
|
|
|
|
|
|
hosting: {plans: PLANS},
|
|
|
|
|
|
// A space created here is opened at wss://<subdomain>.<domain>/, so the domain has to be one
|
|
|
|
|
|
// the container serves and the subdomain has to name one of its tenants.
|
|
|
|
|
|
env: {VITE_HOSTING_RELAY_DOMAIN: "test"},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("You don't host any spaces yet.")).toBeVisible()
|
|
|
|
|
|
await expect(page.getByText("Create a space to get started.")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Create a Space"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
const form = dialog(page, "New Space")
|
|
|
|
|
|
|
|
|
|
|
|
await form.getByPlaceholder("My Space").fill("Space")
|
|
|
|
|
|
|
|
|
|
|
|
// The subdomain follows the name until it is edited
|
|
|
|
|
|
await expect(form.getByPlaceholder("my-space")).toHaveValue("space")
|
|
|
|
|
|
|
|
|
|
|
|
await form.getByRole("button").filter({hasText: "Up to 50 members"}).click()
|
|
|
|
|
|
await form.getByRole("button", {name: "Create Space", exact: true}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page).toHaveURL(/\/spaces\/space\.test\/admin/)
|
|
|
|
|
|
await expect(page.getByRole("heading", {name: "Space", exact: true})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await page.goto("/settings/hosting")
|
|
|
|
|
|
|
|
|
|
|
|
const listed = page.locator(".card").filter({hasText: "space.test"}).first()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(listed).toContainText("Space")
|
|
|
|
|
|
await expect(listed).toContainText("Active")
|
|
|
|
|
|
await expect(listed).toContainText("Free")
|
|
|
|
|
|
await expect(listed.getByRole("link", {name: "Manage"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
// A paid plan has to be paid for, so creation hands straight over to payment
|
|
|
|
|
|
await page.getByRole("button", {name: "Create a Space"}).click()
|
|
|
|
|
|
await form.getByPlaceholder("My Space").fill("Other")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(form.getByPlaceholder("my-space")).toHaveValue("other")
|
|
|
|
|
|
|
|
|
|
|
|
await form.getByRole("button").filter({hasText: "Unlimited members"}).click()
|
|
|
|
|
|
await form.getByRole("button", {name: "Create Space", exact: true}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page).toHaveURL(/\/spaces\/other\.test\/admin/)
|
|
|
|
|
|
await expect(page.getByRole("heading", {name: "Set Up Payments"})).toBeVisible()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test("US-099 configure a hosted relay", async ({seed, as}) => {
|
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
|
space.join(user.bob, "general")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
|
const relayRecord = hostedRelay({
|
|
|
|
|
|
activity: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "activity-1",
|
|
|
|
|
|
tenant_pubkey: users.alice.pubkey,
|
|
|
|
|
|
created_at: scenario.at(1, DAY),
|
|
|
|
|
|
activity_type: "create_relay",
|
|
|
|
|
|
resource_type: "relay",
|
|
|
|
|
|
resource_id: "relay-1",
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const alice = await as(users.alice, spacePath(url) + "/admin", {
|
|
|
|
|
|
hosting: {plans: PLANS, relays: [relayRecord]},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByRole("heading", {name: "Space", exact: true})).toBeVisible()
|
|
|
|
|
|
await expect(alice.getByText("Relay created")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await alice.getByRole("button", {name: "Relay actions"}).click()
|
|
|
|
|
|
await menuItem(alice, "Edit details").click()
|
|
|
|
|
|
|
|
|
|
|
|
const editor = dialog(alice, "Edit Space")
|
|
|
|
|
|
|
|
|
|
|
|
await editor.getByPlaceholder("My Space").fill("Harbor")
|
|
|
|
|
|
await editor.locator("textarea").fill("Where the fleet ties up.")
|
|
|
|
|
|
await editor.getByRole("button", {name: "Save Changes"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByRole("heading", {name: "Harbor", exact: true})).toBeVisible()
|
|
|
|
|
|
await expect(alice.getByText("Where the fleet ties up.")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
const publicRead = settingToggle(alice, "Public read")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(publicRead).not.toBeChecked()
|
|
|
|
|
|
|
|
|
|
|
|
await publicRead.click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(publicRead).toBeChecked()
|
|
|
|
|
|
|
|
|
|
|
|
await alice.reload()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(settingToggle(alice, "Public read")).toBeChecked()
|
|
|
|
|
|
|
|
|
|
|
|
// The free plan doesn't include either of these, and says so rather than failing on save
|
|
|
|
|
|
const blossom = settingToggle(alice, "Media storage")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(blossom).toBeDisabled()
|
|
|
|
|
|
await expect(settingToggle(alice, "LiveKit support")).toBeDisabled()
|
|
|
|
|
|
|
|
|
|
|
|
// The tooltip hangs off a wrapper rather than the input, so it still shows while it is disabled
|
|
|
|
|
|
await blossom.locator("xpath=..").hover()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(alice.getByText("Not available on your current plan").first()).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
// A member of the space who doesn't own it has nothing to manage here
|
|
|
|
|
|
const bob = await as(users.bob, spacePath(url) + "/admin")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(bob.getByRole("heading", {name: "Not a Coracle-hosted space"})).toBeVisible()
|
|
|
|
|
|
await expect(bob.getByRole("link", {name: "Go to Hosting settings"})).toHaveAttribute(
|
|
|
|
|
|
"href",
|
|
|
|
|
|
"/settings/hosting",
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test("US-100 change a hosted relay's plan", async ({seed, as}) => {
|
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
|
const page = await as(users.alice, spacePath(url) + "/admin", {
|
|
|
|
|
|
hosting: {plans: PLANS, relays: [hostedRelay()]},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await expect(setting(page, "Current plan")).toHaveText("Free")
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Relay actions"}).click()
|
|
|
|
|
|
await menuItem(page, "Change plan").click()
|
|
|
|
|
|
|
|
|
|
|
|
const plans = dialog(page, "Change plan")
|
|
|
|
|
|
|
|
|
|
|
|
await plans.getByRole("button").filter({hasText: "Unlimited members"}).click()
|
|
|
|
|
|
await plans.getByRole("button", {name: "Save", exact: true}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Plan updated.")
|
|
|
|
|
|
|
|
|
|
|
|
// Nothing is on file to charge, so the upgrade hands over to payment setup
|
|
|
|
|
|
await expect(page.getByRole("heading", {name: "Set Up Payments"})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Set up later"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(badge(page, "Basic")).toBeVisible()
|
|
|
|
|
|
await expect(setting(page, "Current plan")).toHaveText("Basic")
|
|
|
|
|
|
|
|
|
|
|
|
// The paid plan unlocks both gated features
|
|
|
|
|
|
const blossom = settingToggle(page, "Media storage")
|
|
|
|
|
|
const livekit = settingToggle(page, "LiveKit support")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(blossom).toBeEnabled()
|
|
|
|
|
|
|
|
|
|
|
|
await blossom.click()
|
|
|
|
|
|
await livekit.click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(blossom).toBeChecked()
|
|
|
|
|
|
await expect(livekit).toBeChecked()
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Relay actions"}).click()
|
|
|
|
|
|
await menuItem(page, "Change plan").click()
|
|
|
|
|
|
await plans.getByRole("button").filter({hasText: "Up to 50 members"}).click()
|
|
|
|
|
|
await plans.getByRole("button", {name: "Save", exact: true}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Plan updated.")
|
|
|
|
|
|
await expect(badge(page, "Free")).toBeVisible()
|
|
|
|
|
|
await expect(setting(page, "Current plan")).toHaveText("Free")
|
|
|
|
|
|
await expect(settingToggle(page, "Media storage")).not.toBeChecked()
|
|
|
|
|
|
await expect(settingToggle(page, "LiveKit support")).not.toBeChecked()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test("US-101 point a custom domain at a hosted relay", async ({seed, as}) => {
|
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
const other = relay("other")
|
|
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
|
|
|
|
|
|
|
// Where the space ends up once the domain verifies, so the client has somewhere real to follow
|
|
|
|
|
|
// it to.
|
|
|
|
|
|
other.room("general", {name: "General"})
|
|
|
|
|
|
other.join(user.alice, "general")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
|
const page = await as(users.alice, spacePath(url) + "/admin", {
|
|
|
|
|
|
hosting: {plans: PLANS, relays: [hostedRelay()]},
|
|
|
|
|
|
context: {permissions: ["clipboard-read", "clipboard-write"]},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("Not configured")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Add domain"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
const domainForm = dialog(page, "Custom domain")
|
|
|
|
|
|
|
|
|
|
|
|
await domainForm.getByPlaceholder("relay.example.com").fill("other.test")
|
|
|
|
|
|
await domainForm.getByRole("button", {name: "Save", exact: true}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Custom domain saved.")
|
|
|
|
|
|
await expect(page.getByText("other.test", {exact: true})).toBeVisible()
|
|
|
|
|
|
await expect(page.getByText("Pending", {exact: true})).toBeVisible()
|
|
|
|
|
|
await expect(page.getByText("other.test CNAME space.test")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await page.locator('[data-tip="Copy CNAME target"]').click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
|
|
|
|
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("space.test")
|
|
|
|
|
|
|
|
|
|
|
|
// The backend hasn't seen the record yet, so verifying says so and leaves the badge alone
|
|
|
|
|
|
await page.getByRole("button", {name: "Verify DNS record"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("alert")).toContainText(
|
|
|
|
|
|
"Not verified yet. DNS changes can take a while to propagate.",
|
|
|
|
|
|
)
|
|
|
|
|
|
await expect(page.getByText("Pending", {exact: true})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
getHosting(page.context()).setRelay("relay-1", {custom_domain_verified: 1})
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Verify DNS record"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Custom domain verified.")
|
|
|
|
|
|
await expect(page.getByText("Verified", {exact: true})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
// The space is now reached through the domain its owner controls
|
|
|
|
|
|
await expect(page.getByRole("link", {name: "wss://other.test"})).toBeVisible()
|
|
|
|
|
|
await expect(page).toHaveURL(/\/spaces\/other\.test\/admin/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test("US-102 pause a relay and settle the bill", async ({seed, as}) => {
|
|
|
|
|
|
const scenario = await seed(({relay, user}) => {
|
|
|
|
|
|
const space = relay("space")
|
|
|
|
|
|
|
|
|
|
|
|
space.room("general", {name: "General"})
|
|
|
|
|
|
space.join(user.alice, "general")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const {url} = scenario.space("space")
|
|
|
|
|
|
const paidPeriod = {start: scenario.at(2, MONTH), end: scenario.at(1, MONTH)}
|
|
|
|
|
|
const openPeriod = {start: scenario.at(1, MONTH), end: scenario.startedAt}
|
|
|
|
|
|
|
|
|
|
|
|
const page = await as(users.alice, spacePath(url) + "/admin", {
|
|
|
|
|
|
hosting: {
|
|
|
|
|
|
plans: PLANS,
|
|
|
|
|
|
relays: [hostedRelay({plan_id: "basic"})],
|
|
|
|
|
|
// Declared oldest first, which is not the order payment history reads in. Invoice's
|
|
|
|
|
|
// paid_at/voided_at/method are number | null / InvoiceMethod | null upstream, so null is
|
|
|
|
|
|
// how the hosting API spells "hasn't happened yet".
|
|
|
|
|
|
/* eslint-disable no-restricted-syntax */
|
|
|
|
|
|
invoices: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "invoice-paid",
|
|
|
|
|
|
tenant_pubkey: users.alice.pubkey,
|
|
|
|
|
|
amount: 500,
|
|
|
|
|
|
period_start: paidPeriod.start,
|
|
|
|
|
|
period_end: paidPeriod.end,
|
|
|
|
|
|
created_at: paidPeriod.start,
|
|
|
|
|
|
paid_at: paidPeriod.end,
|
|
|
|
|
|
voided_at: null,
|
|
|
|
|
|
method: "nwc",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: "invoice-open",
|
|
|
|
|
|
tenant_pubkey: users.alice.pubkey,
|
|
|
|
|
|
amount: 900,
|
|
|
|
|
|
period_start: openPeriod.start,
|
|
|
|
|
|
period_end: openPeriod.end,
|
|
|
|
|
|
created_at: openPeriod.start,
|
|
|
|
|
|
paid_at: null,
|
|
|
|
|
|
voided_at: null,
|
|
|
|
|
|
method: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
/* eslint-enable no-restricted-syntax */
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("Active", {exact: true})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Relay actions"}).click()
|
|
|
|
|
|
await menuItem(page, "Deactivate").click()
|
|
|
|
|
|
|
|
|
|
|
|
const deactivate = dialog(page, "Deactivate relay?")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(deactivate.getByText(/All client connections are dropped/)).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await deactivate.getByRole("button", {name: "Confirm"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("Inactive", {exact: true})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Relay actions"}).click()
|
|
|
|
|
|
await menuItem(page, "Reactivate").click()
|
|
|
|
|
|
|
|
|
|
|
|
await dialog(page, "Reactivate relay?").getByRole("button", {name: "Confirm"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText("Active", {exact: true})).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await page.goto("/settings/hosting")
|
|
|
|
|
|
|
|
|
|
|
|
const banner = page.getByText(
|
|
|
|
|
|
"You have an unpaid invoice. Pay it now to keep your relays running.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
await expect(banner).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await page.getByRole("button", {name: "Pay now"}).first().click()
|
|
|
|
|
|
|
|
|
|
|
|
const payment = dialog(page, "Pay invoice")
|
|
|
|
|
|
|
|
|
|
|
|
await expect(payment.getByText("$9.00")).toBeVisible()
|
|
|
|
|
|
await expect(payment.locator("canvas")).toBeVisible()
|
|
|
|
|
|
await expect(payment.locator("input[readonly]")).toHaveValue("lnbcinvoice-open")
|
|
|
|
|
|
|
|
|
|
|
|
await payment.getByRole("button", {name: "Check payment"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("alert")).toContainText(
|
|
|
|
|
|
"Payment not yet confirmed. Please try again after sending.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
getHosting(page.context()).setInvoice("invoice-open", {paid_at: now(), method: "nwc"})
|
|
|
|
|
|
|
|
|
|
|
|
await payment.getByRole("button", {name: "Check payment"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(payment.getByText("Payment confirmed!")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await payment.getByRole("button", {name: "Done"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(banner).toHaveCount(0)
|
|
|
|
|
|
|
|
|
|
|
|
// With the balance settled, the standing prompt is to keep it that way
|
|
|
|
|
|
await page.getByRole("button", {name: "Set up autopay"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
const autopay = dialog(page, "Set Up Payments")
|
|
|
|
|
|
|
|
|
|
|
|
await autopay
|
|
|
|
|
|
.getByPlaceholder("nostr+walletconnect://")
|
|
|
|
|
|
.fill(`nostr+walletconnect://${users.alice.pubkey}?relay=${url}&secret=abc`)
|
|
|
|
|
|
await autopay.getByRole("button", {name: "Save", exact: true}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(autopay.getByText("Wallet connected!")).toBeVisible()
|
|
|
|
|
|
|
|
|
|
|
|
await autopay.getByRole("button", {name: "Done"}).click()
|
|
|
|
|
|
|
|
|
|
|
|
await expect(page.getByRole("listitem").filter({hasText: "Lightning (NWC)"})).toContainText(
|
|
|
|
|
|
"Connected",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Payment history, newest first
|
|
|
|
|
|
const history = page.locator(".card").filter({hasText: "Payment History"}).first()
|
|
|
|
|
|
const invoices = history.getByRole("listitem")
|
|
|
|
|
|
|
|
|
|
|
|
// The whole period, not just its start: the two invoices meet at a month boundary, so either
|
|
|
|
|
|
// date on its own reads the same on both of them.
|
|
|
|
|
|
const period = ({start, end}: {start: number; end: number}) =>
|
|
|
|
|
|
`${new Date(start * 1000).toLocaleDateString()} – ${new Date(end * 1000).toLocaleDateString()}`
|
|
|
|
|
|
|
|
|
|
|
|
await expect(invoices.first()).toContainText("$9.00")
|
|
|
|
|
|
await expect(invoices.first()).toContainText(period(openPeriod))
|
|
|
|
|
|
await expect(invoices.nth(1)).toContainText("$5.00")
|
|
|
|
|
|
await expect(invoices.nth(1)).toContainText(period(paidPeriod))
|
|
|
|
|
|
})
|