809 lines
30 KiB
TypeScript
809 lines
30 KiB
TypeScript
import {createHash} from "node:crypto"
|
|
import type {Locator, Page} from "@playwright/test"
|
|
import {nprofileEncode, npubEncode} from "nostr-tools/nip19"
|
|
import {HOUR, MINUTE, MONTH} from "@welshman/lib"
|
|
import {NOTE, STATUS, makeEvent} from "@welshman/util"
|
|
import type {SignedEvent} from "@welshman/util"
|
|
import {FollowList, Note, PinList, Profile} from "@welshman/domain"
|
|
import {
|
|
DEFAULT_BLOSSOM_ORIGIN,
|
|
GIF,
|
|
WEBP,
|
|
expect,
|
|
makeTestUser,
|
|
mockBlossom,
|
|
profilePath,
|
|
spacePath,
|
|
test,
|
|
topDialog,
|
|
users,
|
|
} from "../harness"
|
|
import type {TestUser} from "../harness"
|
|
|
|
// A handle to a seeded event, which only reads once seed() has drained its queue.
|
|
type Seeded = {readonly id: string; readonly event: SignedEvent}
|
|
|
|
// The profile page keeps its Reputation and Spaces panels in a sidebar that only exists above
|
|
// tailwind's xl breakpoint, and the default 1280 viewport sits exactly on it.
|
|
const DESKTOP = {viewport: {width: 1440, height: 900}}
|
|
|
|
const CLIPBOARD = {...DESKTOP, permissions: ["clipboard-read", "clipboard-write"]}
|
|
|
|
// uploadFile appends the extension when the descriptor's url carries none, which the mock's never
|
|
// does.
|
|
const uploadedUrl = (body: Buffer, extension: string) =>
|
|
`${DEFAULT_BLOSSOM_ORIGIN}/${createHash("sha256").update(body).digest("hex")}.${extension}`
|
|
|
|
// displayPubkey in @welshman/domain: the npub with its middle taken out.
|
|
const shortNpub = (user: TestUser) => {
|
|
const npub = npubEncode(user.pubkey)
|
|
|
|
return npub.slice(0, 8) + "…" + npub.slice(-5)
|
|
}
|
|
|
|
// The page's own region, so an assertion about an avatar isn't satisfied by the copy of it the
|
|
// nav renders.
|
|
const pageContent = (page: Page) => page.locator(".page__content")
|
|
|
|
// ProfileTrust and ProfileSharedSpaces are rendered twice — stacked for narrow viewports, and in
|
|
// the sidebar — so anything said about them is scoped to the copy that is actually on screen.
|
|
const sidebar = (page: Page) => page.locator("aside")
|
|
|
|
// A space path redirects to the space's entry room as the page mounts, and modal.ts closes every
|
|
// open modal on navigation, so a dialog opened before the redirect lands is thrown away with it.
|
|
const enteredSpace = (page: Page) => expect(page).toHaveURL(new RegExp("/spaces/[^/]+/."))
|
|
|
|
// Search is a dialog the nav opens rather than a page of its own.
|
|
const openSearch = (page: Page) => page.locator('.primary-nav button[data-tip="Search"]').click()
|
|
|
|
const searchTerm = (page: Page) => page.getByPlaceholder("Search your spaces...")
|
|
|
|
const searchResults = (page: Page) => topDialog(page).locator(".card.card-interactive")
|
|
|
|
const notes = (page: Page) => pageContent(page).locator(".cv.card")
|
|
|
|
const readClipboard = (page: Page) => page.evaluate(() => navigator.clipboard.readText())
|
|
|
|
// The "..." menu on a profile header, which is the only ghost circle button the page renders.
|
|
const profileMenu = (page: Page) => page.locator("button.button-circle.button-ghost")
|
|
|
|
// PeopleItem renders its link twice, once for each breakpoint; the wide one comes first.
|
|
const viewProfile = (card: Locator) => card.getByRole("link", {name: "View Profile"}).first()
|
|
|
|
test("US-074 find a person", async ({seed, as}) => {
|
|
// Sixty of them, so that the ten the dialog lists are visibly the best matches rather than
|
|
// everyone who matched.
|
|
const searchers = Array.from({length: 60}, (_, i) => makeTestUser(`searcher-${i}`))
|
|
const searcherName = (i: number) => `Searcher ${String(i).padStart(2, "0")}`
|
|
const searcherAvatar = (i: number) => `https://images.test/searcher-${i}.png`
|
|
const about = [
|
|
"Deck crew on the northern run.",
|
|
"Splices rope, keeps the log, argues about knots.",
|
|
"Ask about the time the compass froze solid.",
|
|
"Answers to a whistle and to nothing else.",
|
|
].join("\n")
|
|
|
|
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")
|
|
|
|
// Bob is the one person whose name shares no token with the others, so narrowing the term is
|
|
// visibly a filter rather than a reordering.
|
|
space.profile(user.bob, {
|
|
name: "Bob Barnacle",
|
|
about: "Dockside cook and keeper of the ship's cat.",
|
|
})
|
|
|
|
space.relayList(user.bob)
|
|
|
|
for (const [i, searcher] of searchers.entries()) {
|
|
space.member(searcher)
|
|
space.profile(searcher, {name: searcherName(i), about, picture: searcherAvatar(i)})
|
|
}
|
|
})
|
|
|
|
const page = await as(users.alice, spacePath(scenario.space("space").url), {context: DESKTOP})
|
|
const term = searchTerm(page)
|
|
const cards = searchResults(page)
|
|
|
|
await enteredSpace(page)
|
|
await openSearch(page)
|
|
await term.fill("Searcher")
|
|
|
|
// Which of them ranks first is fuse's business, so a result is described by what every
|
|
// result carries rather than by which one it turned out to be.
|
|
const first = cards.first()
|
|
|
|
await expect(first).toBeVisible()
|
|
await expect(first.locator('img[src^="https://images.test/searcher-"]')).toBeVisible()
|
|
await expect(first).toContainText(/Searcher \d\d/)
|
|
await expect(first).toContainText("Deck crew on the northern run.")
|
|
|
|
await expect.poll(() => cards.count()).toBe(10)
|
|
|
|
// Narrowing the term filters the list rather than reordering it.
|
|
await term.fill("Barnacle")
|
|
|
|
const bobCard = cards.filter({hasText: "Bob Barnacle"})
|
|
|
|
await expect(bobCard).toHaveCount(1)
|
|
await expect(cards.filter({hasText: /Searcher \d\d/})).toHaveCount(0)
|
|
|
|
await viewProfile(bobCard).click()
|
|
|
|
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
|
|
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
|
|
})
|
|
|
|
test("US-075 view someone's profile", async ({seed, as}) => {
|
|
const avatar = "https://images.test/bob-avatar.png"
|
|
const banner = "https://images.test/bob-banner.png"
|
|
|
|
const scenario = await seed(({relay, user, at}) => {
|
|
const space = relay("space")
|
|
const other = relay("other")
|
|
|
|
space.room("general", {name: "General"})
|
|
space.join(user.alice, "general")
|
|
space.join(user.bob, "general")
|
|
|
|
// A second space alice does not belong to, so "Member" is a claim about the overlap rather
|
|
// than about every space bob is in.
|
|
other.room("lounge", {name: "Lounge"})
|
|
other.join(user.bob, "lounge")
|
|
|
|
space.event(user.bob, () =>
|
|
space
|
|
.kind(Profile)
|
|
.writer()
|
|
.setName("Bob Barnacle")
|
|
.setAbout("Deckhand, dockside cook, and keeper of the ship's cat.")
|
|
.setPicture(avatar)
|
|
.setBanner(banner)
|
|
.setWebsite("bobbarnacle.example")
|
|
.renderTemplate(),
|
|
)
|
|
|
|
space.event(
|
|
user.bob,
|
|
makeEvent(STATUS, {
|
|
content: "Scrubbing the decks",
|
|
tags: [
|
|
["d", "general"],
|
|
["r", "https://bobbarnacle.example/decks"],
|
|
],
|
|
}),
|
|
)
|
|
|
|
space.relayList(user.bob)
|
|
|
|
// Carol belongs to no space at all, which is what the panel's empty state is about.
|
|
space.member(user.carol)
|
|
space.profile(user.carol, {name: "Carol Cutter"})
|
|
// An expired status is one its author asked relays to stop serving, so it is not what she is
|
|
// up to any more.
|
|
space.event(
|
|
user.carol,
|
|
makeEvent(STATUS, {
|
|
content: "Ashore until spring",
|
|
tags: [
|
|
["d", "general"],
|
|
["expiration", String(at(1, HOUR))],
|
|
],
|
|
}),
|
|
)
|
|
space.relayList(user.carol)
|
|
})
|
|
|
|
const space = scenario.space("space")
|
|
const other = scenario.space("other")
|
|
const page = await as(users.alice, profilePath(users.bob.pubkey), {context: CLIPBOARD})
|
|
const region = pageContent(page)
|
|
|
|
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
|
|
// The header renders the avatar twice, one size per breakpoint; the wide one is second.
|
|
await expect(region.locator(`img[src="${avatar}"]`).last()).toBeVisible()
|
|
await expect(region.locator(`img[src="${banner}"]`)).toBeVisible()
|
|
await expect(region.getByText("Deckhand, dockside cook")).toBeVisible()
|
|
await expect(region.getByRole("link", {name: "Scrubbing the decks"})).toHaveAttribute(
|
|
"href",
|
|
"https://bobbarnacle.example/decks",
|
|
)
|
|
|
|
const npub = region.getByText(shortNpub(users.bob))
|
|
|
|
await expect(npub).toBeVisible()
|
|
|
|
await npub.locator("xpath=following-sibling::button").click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
|
|
await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey))
|
|
|
|
await expect(region.getByRole("link", {name: "bobbarnacle.example"})).toHaveAttribute(
|
|
"href",
|
|
"https://bobbarnacle.example",
|
|
)
|
|
|
|
const spaces = sidebar(page).locator(".card.card-sm").filter({hasText: "Spaces"})
|
|
const spaceLink = spaces.locator(`a[href="${spacePath(space.url)}"]`)
|
|
const otherLink = spaces.locator(`a[href="${spacePath(other.url)}"]`)
|
|
|
|
await expect(spaces.locator(".badge").first()).toHaveText("2")
|
|
await expect(spaceLink).toContainText("Member")
|
|
await expect(otherLink).toBeVisible()
|
|
await expect(otherLink.locator(".badge").filter({hasText: "Member"})).toHaveCount(0)
|
|
|
|
await spaceLink.click()
|
|
|
|
await expect(page).toHaveURL(new RegExp(spacePath(space.url)))
|
|
|
|
await page.goto(profilePath(users.carol.pubkey))
|
|
|
|
await expect(page.getByRole("heading", {name: "Carol Cutter"})).toBeVisible()
|
|
await expect(sidebar(page).getByText("No spaces found.")).toBeVisible()
|
|
await expect(region.getByText("Ashore until spring")).toHaveCount(0)
|
|
})
|
|
|
|
test("US-076 follow and unfollow", async ({seed, as}) => {
|
|
await seed(({relay, user}) => {
|
|
const space = relay("space")
|
|
|
|
space.room("general", {name: "General"})
|
|
space.join(user.alice, "general")
|
|
space.join(user.bob, "general")
|
|
space.profile(user.bob, {name: "Bob Barnacle"})
|
|
space.relayList(user.alice)
|
|
space.relayList(user.bob)
|
|
})
|
|
|
|
const page = await as(users.alice, profilePath(users.bob.pubkey), {context: DESKTOP})
|
|
const follow = page.getByRole("button", {name: "Follow", exact: true})
|
|
const unfollow = page.getByRole("button", {name: "Unfollow", exact: true})
|
|
|
|
await expect(follow).toBeVisible()
|
|
|
|
await follow.click()
|
|
|
|
// The same page, still on the same url: the label flips where it stands.
|
|
await expect(unfollow).toBeVisible()
|
|
await expect(follow).toHaveCount(0)
|
|
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
|
|
|
|
await unfollow.click()
|
|
|
|
await expect(follow).toBeVisible()
|
|
await expect(unfollow).toHaveCount(0)
|
|
})
|
|
|
|
test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
|
|
const carolAvatar = "https://images.test/carol-avatar.png"
|
|
|
|
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")
|
|
space.join(user.carol, "general")
|
|
space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."})
|
|
space.profile(user.carol, {name: "Carol Cutter", about: "Sailmaker.", picture: carolAvatar})
|
|
space.relayList(user.alice)
|
|
space.relayList(user.bob)
|
|
space.relayList(user.carol)
|
|
|
|
// Carol already follows bob, so alice following carol is the one thing that has to happen
|
|
// through the ui for his standing to move.
|
|
space.event(user.carol, () =>
|
|
space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(),
|
|
)
|
|
})
|
|
|
|
// Every hop here is a link click rather than a navigation, so the follow alice publishes stays
|
|
// in the client that published it.
|
|
const page = await as(users.alice, spacePath(scenario.space("space").url), {context: DESKTOP})
|
|
const term = searchTerm(page)
|
|
const cards = searchResults(page)
|
|
const bobCard = cards.filter({hasText: "Bob Barnacle"})
|
|
const carolCard = cards.filter({hasText: "Carol Cutter"})
|
|
const reputation = () => sidebar(page).locator(".card.card-sm").filter({hasText: "Reputation"})
|
|
|
|
// The trust ring beside a name: a circle whose dash offset shrinks as the ring fills in.
|
|
const ring = async () =>
|
|
Number(await bobCard.locator("circle.wot-highlight").getAttribute("stroke-dashoffset"))
|
|
|
|
await enteredSpace(page)
|
|
await openSearch(page)
|
|
await term.fill("Barnacle")
|
|
|
|
await expect(bobCard).toHaveCount(1)
|
|
await expect(bobCard.locator("circle.wot-highlight")).toBeAttached()
|
|
|
|
const emptyRing = await ring()
|
|
|
|
await viewProfile(bobCard).click()
|
|
|
|
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
|
|
// Word-bounded: a plain "0 / 100" is also a substring of "10 / 100" and "20 / 100", which are
|
|
// exactly the readings this is supposed to rule out.
|
|
await expect(reputation()).toContainText(/\b0 \/ 100\b/)
|
|
await expect(reputation()).toContainText("This user is not well known in your network.")
|
|
|
|
await openSearch(page)
|
|
await term.fill("Cutter")
|
|
|
|
await expect(carolCard).toHaveCount(1)
|
|
|
|
await viewProfile(carolCard).click()
|
|
|
|
await expect(page).toHaveURL(new RegExp(`${profilePath(users.carol.pubkey)}$`))
|
|
|
|
await page.getByRole("button", {name: "Follow", exact: true}).click()
|
|
|
|
await expect(page.getByRole("button", {name: "Unfollow", exact: true})).toBeVisible()
|
|
|
|
await openSearch(page)
|
|
await term.fill("Barnacle")
|
|
|
|
await expect(bobCard).toHaveCount(1)
|
|
await expect.poll(ring).toBeLessThan(emptyRing)
|
|
|
|
await viewProfile(bobCard).click()
|
|
|
|
await expect(reputation()).toContainText("Followed by 1+ people in your network.")
|
|
await expect(reputation()).toContainText("1 person you follow also follows Bob Barnacle.")
|
|
await expect(reputation().locator(`img[src="${carolAvatar}"]`)).toBeVisible()
|
|
})
|
|
|
|
test("US-078 edit your own profile", async ({seed, as}) => {
|
|
const oldAvatar = "https://images.test/alice-avatar.png"
|
|
const newAvatar = uploadedUrl(WEBP, "webp")
|
|
const newBanner = uploadedUrl(GIF, "gif")
|
|
|
|
await seed(({relay, user}) => {
|
|
const space = relay("space")
|
|
|
|
space.room("general", {name: "General"})
|
|
space.join(user.alice, "general")
|
|
space.event(user.alice, () =>
|
|
space
|
|
.kind(Profile)
|
|
.writer()
|
|
.setName("Alice Anderson")
|
|
.setAbout("Ship's navigator.")
|
|
.setPicture(oldAvatar)
|
|
.setNip05("alice@flotilla.test")
|
|
.renderTemplate(),
|
|
)
|
|
space.relayList(user.alice)
|
|
})
|
|
|
|
const page = await as(users.alice, profilePath(users.alice.pubkey), {context: DESKTOP})
|
|
|
|
await mockBlossom(page.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
|
|
|
|
const region = pageContent(page)
|
|
const edit = page.getByRole("button", {name: "Edit profile"})
|
|
|
|
await expect(edit).toBeVisible()
|
|
await expect(page.getByRole("button", {name: "Follow", exact: true})).toHaveCount(0)
|
|
// Exact, or this would match the nav's "Messages".
|
|
await expect(page.getByRole("button", {name: "Message", exact: true})).toHaveCount(0)
|
|
|
|
await edit.click()
|
|
|
|
// ProfileEditForm's text inputs, in the order it renders them: nickname, website, nostr address.
|
|
const form = page.locator("form").first()
|
|
const nickname = form.locator('input[type="text"]').first()
|
|
const about = form.locator("textarea")
|
|
const nostrAddress = form.locator('input[type="text"]').nth(2)
|
|
const picker = page.locator('label[aria-label="Drag and drop files here."]')
|
|
|
|
await expect(nickname).toHaveValue("Alice Anderson")
|
|
await expect(about).toHaveValue("Ship's navigator.")
|
|
await expect(nostrAddress).toHaveValue("alice@flotilla.test")
|
|
await expect.poll(() => picker.getAttribute("style")).toContain(oldAvatar)
|
|
|
|
await nickname.fill("Alice Anchor")
|
|
await about.fill("Ship's navigator and part-time cartographer.")
|
|
await page.getByRole("button", {name: "Save Changes"}).click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Your profile has been updated!")
|
|
await expect(page.getByRole("heading", {name: "Alice Anchor"})).toBeVisible()
|
|
await expect(region.getByText("part-time cartographer")).toBeVisible()
|
|
|
|
await edit.click()
|
|
|
|
const avatarChooser = page.waitForEvent("filechooser")
|
|
|
|
await picker.click()
|
|
await (
|
|
await avatarChooser
|
|
).setFiles({
|
|
name: "avatar.webp",
|
|
mimeType: "image/webp",
|
|
buffer: WEBP,
|
|
})
|
|
|
|
await expect.poll(() => picker.getAttribute("style")).toContain(newAvatar)
|
|
|
|
await page.getByRole("button", {name: "Save Changes"}).click()
|
|
|
|
await expect(region.locator(`img[src="${newAvatar}"]`).last()).toBeVisible()
|
|
|
|
const bannerChooser = page.waitForEvent("filechooser")
|
|
|
|
await page.getByRole("button", {name: "Change banner"}).click()
|
|
await (await bannerChooser).setFiles({name: "banner.gif", mimeType: "image/gif", buffer: GIF})
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Banner updated.")
|
|
await expect(region.locator(`img[src="${newBanner}"]`)).toBeVisible()
|
|
})
|
|
|
|
test("US-079 read a person's notes", async ({seed, as}) => {
|
|
const avatar = "https://images.test/alice-avatar.png"
|
|
|
|
let carolNote!: Seeded
|
|
let pinned!: Seeded
|
|
|
|
const scenario = await seed(({relay, user, at}) => {
|
|
const space = relay("space")
|
|
|
|
space.room("general", {name: "General"})
|
|
space.join(user.alice, "general")
|
|
space.join(user.bob, "general")
|
|
space.join(user.carol, "general")
|
|
space.profile(user.alice, {name: "Alice Anderson", picture: avatar})
|
|
space.profile(user.carol, {name: "Carol Cutter"})
|
|
space.relayList(user.alice)
|
|
space.relayList(user.carol)
|
|
|
|
carolNote = space.event(
|
|
user.carol,
|
|
makeEvent(NOTE, {content: "Anyone seen the tide charts?", created_at: at(4, HOUR)}),
|
|
)
|
|
|
|
pinned = space.event(
|
|
user.alice,
|
|
makeEvent(NOTE, {content: "PINNED how to read a tide chart", created_at: at(3, HOUR)}),
|
|
)
|
|
|
|
space.event(
|
|
user.alice,
|
|
makeEvent(NOTE, {content: "MIDDLE rigging notes from this morning", created_at: at(2, HOUR)}),
|
|
)
|
|
|
|
space.event(
|
|
user.alice,
|
|
makeEvent(NOTE, {content: "NEWEST the wind has finally turned", created_at: at(1, HOUR)}),
|
|
)
|
|
|
|
// Older than the window the feed opens with, so reaching it is the feed paging backwards.
|
|
space.event(
|
|
user.alice,
|
|
makeEvent(NOTE, {content: "OLDEST from the spring refit", created_at: at(8, MONTH)}),
|
|
)
|
|
|
|
// A reply to carol, which a profile's note list leaves out.
|
|
space.event(
|
|
user.alice,
|
|
() =>
|
|
space
|
|
.kind(Note)
|
|
.writer()
|
|
.setParent(carolNote.event)
|
|
.setContent("REPLY they are in the chart drawer")
|
|
.renderTemplate(),
|
|
at(90, MINUTE),
|
|
)
|
|
|
|
space.event(user.alice, () =>
|
|
space.kind(PinList).writer().pinPublicly(["e", pinned.id]).renderTemplate(),
|
|
)
|
|
})
|
|
|
|
const {url} = scenario.space("space")
|
|
const page = await as(users.bob, profilePath(users.alice.pubkey), {context: DESKTOP})
|
|
const list = notes(page)
|
|
const newest = list.filter({hasText: "NEWEST"})
|
|
|
|
await expect(newest).toBeVisible()
|
|
await expect(newest.getByText("Alice Anderson")).toBeVisible()
|
|
await expect(newest.locator(`img[src="${avatar}"]`)).toBeVisible()
|
|
// The story asks for a relative timestamp; the app renders formatTimestamp — a short date plus a
|
|
// clock time — the same way every other content item does (thread items, chat items), so that
|
|
// shared convention is what a note's stamp reads as here.
|
|
await expect(newest).toContainText(/\d{1,2}\/\d{1,2}\/\d{2,4}/)
|
|
|
|
await expect(list.filter({hasText: "REPLY"})).toHaveCount(0)
|
|
|
|
// The pin outranks the newer notes; the rest are newest first.
|
|
await expect(list.first()).toContainText("PINNED")
|
|
await expect(list.nth(1)).toContainText("NEWEST")
|
|
await expect(list.nth(2)).toContainText("MIDDLE")
|
|
|
|
// Nothing is scrolled here: the feed keeps widening its window until the page is full, which is
|
|
// what "loads older notes automatically" means.
|
|
await expect(list.filter({hasText: "OLDEST"})).toBeVisible()
|
|
|
|
// A note alice publishes while bob is looking. Flotilla has no composer for a kind-1 note, so
|
|
// it goes out through the app's own primitives in her signed-in session, over her socket to the
|
|
// space — which is the half of this the story is about.
|
|
const alice = await as(users.alice, spacePath(url), {context: DESKTOP})
|
|
|
|
await alice.evaluate(
|
|
async ([relayUrl, content]) => {
|
|
const {
|
|
app,
|
|
network,
|
|
makeEvent: make,
|
|
} = window as unknown as {
|
|
app: {get(): {user: {sign(template: object): Promise<object>}}}
|
|
network: {get(): {publish(options: {event: object; relays: string[]}): Promise<unknown>}}
|
|
makeEvent(kind: number, values: {content: string}): object
|
|
}
|
|
|
|
await network.get().publish({
|
|
event: await app.get().user.sign(make(1, {content})),
|
|
relays: [relayUrl],
|
|
})
|
|
},
|
|
[url, "LIVE straight off the deck"] as const,
|
|
)
|
|
|
|
// The profile feed fetches on load rather than subscribing live, so bob sees it the next time he
|
|
// opens the page — where it lands above every unpinned note, below the pin.
|
|
await page.reload()
|
|
|
|
await expect(list.filter({hasText: "LIVE"})).toBeVisible()
|
|
await expect(list.nth(1)).toContainText("LIVE")
|
|
|
|
// Carol's own note is on her profile; alice's reply to it is alice's, so it isn't here.
|
|
await page.goto(profilePath(users.carol.pubkey))
|
|
|
|
await expect(notes(page).filter({hasText: "Anyone seen the tide charts?"})).toBeVisible()
|
|
await expect(notes(page).filter({hasText: "REPLY"})).toHaveCount(0)
|
|
})
|
|
|
|
test("US-080 preview a profile from anywhere", async ({seed, as}) => {
|
|
const avatar = "https://images.test/bob-avatar.png"
|
|
|
|
const scenario = await seed(({relay, user, at}) => {
|
|
const space = relay("space")
|
|
|
|
space.room("general", {name: "General"})
|
|
space.join(user.alice, "general")
|
|
space.join(user.bob, "general")
|
|
space.profile(user.bob, {
|
|
name: "Bob Barnacle",
|
|
about: "Deckhand, dockside cook, and keeper of the ship's cat.",
|
|
picture: avatar,
|
|
})
|
|
space.message(user.bob, "general", "the cat has the helm", at(1, HOUR))
|
|
space.event(
|
|
user.bob,
|
|
makeEvent(STATUS, {content: "Scrubbing the decks", tags: [["d", "general"]]}),
|
|
)
|
|
space.relayList(user.bob)
|
|
})
|
|
|
|
const {url} = scenario.space("space")
|
|
const page = await as(users.alice, `${spacePath(url)}/directory`, {context: DESKTOP})
|
|
// SpaceMember covers its whole card with one button, whose aria-label is interpolated from
|
|
// `$profiles.display(pubkey).get()` — a plain call, so the label keeps whatever the name was at
|
|
// first render, which is the npub the card falls back to before the profile has loaded. The
|
|
// name it *displays* comes from a store and does update, so the card is found by that.
|
|
const preview = page
|
|
.locator(".card.card-interactive")
|
|
.filter({hasText: "Bob Barnacle"})
|
|
.locator("button[aria-label]")
|
|
.first()
|
|
|
|
await expect(preview).toBeVisible()
|
|
|
|
await preview.click()
|
|
|
|
await expect(topDialog(page).getByText("Bob Barnacle")).toBeVisible()
|
|
await expect(topDialog(page).locator(`img[src="${avatar}"]`)).toBeVisible()
|
|
await expect(topDialog(page).getByText("Deckhand, dockside cook")).toBeVisible()
|
|
await expect(topDialog(page).getByText("Scrubbing the decks")).toBeVisible()
|
|
await expect(topDialog(page).getByText(/Last active/)).toBeVisible()
|
|
|
|
await page.keyboard.press("Escape")
|
|
|
|
await expect(page.locator(".dialog")).toHaveCount(0)
|
|
await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`))
|
|
await expect(preview).toBeVisible()
|
|
|
|
await preview.click()
|
|
|
|
await topDialog(page).getByRole("button", {name: "Go back"}).click()
|
|
|
|
// Closed, and alice is exactly where she opened it from.
|
|
await expect(page.locator(".dialog")).toHaveCount(0)
|
|
await expect(page).toHaveURL(new RegExp(`${spacePath(url)}/directory$`))
|
|
await expect(preview).toBeVisible()
|
|
|
|
await preview.click()
|
|
await page.getByRole("button", {name: "View Full Profile"}).click()
|
|
|
|
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
|
|
await expect(page.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
|
|
})
|
|
|
|
test("US-081 inspect and share a profile", async ({seed, as}) => {
|
|
await seed(({relay, user}) => {
|
|
const space = relay("space")
|
|
|
|
space.room("general", {name: "General"})
|
|
space.join(user.alice, "general")
|
|
space.join(user.bob, "general")
|
|
space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."})
|
|
space.relayList(user.bob)
|
|
})
|
|
|
|
const page = await as(users.alice, profilePath(users.bob.pubkey), {context: CLIPBOARD})
|
|
|
|
await profileMenu(page).click()
|
|
await page.getByRole("button", {name: "Profile Info"}).click()
|
|
|
|
await expect(page.getByRole("heading", {name: "Profile Details"})).toBeVisible()
|
|
|
|
const info = topDialog(page)
|
|
// Each of these is an input and its copy button inside one bordered label, in dialog order.
|
|
const linkField = info.locator("label.input").first()
|
|
const pubkeyField = info.locator("label.input").nth(1)
|
|
const link = linkField.locator('input[type="text"]')
|
|
const pubkey = pubkeyField.locator('input[type="text"]')
|
|
|
|
// The profile was signed during this test, so the creation date is today's. A FieldInline puts
|
|
// its value in the div right after its label, which keeps this a claim about the date rather
|
|
// than about anything else in the dialog that happens to carry four digits.
|
|
const createdAt = info
|
|
.locator("label")
|
|
.filter({hasText: "Created At"})
|
|
.locator("xpath=following-sibling::div")
|
|
|
|
await expect(createdAt).toContainText(String(new Date().getFullYear()))
|
|
|
|
await expect(link).toHaveValue(/^nostr:nprofile1/)
|
|
await expect(pubkey).toHaveValue(npubEncode(users.bob.pubkey))
|
|
await expect(info.locator("pre code")).toContainText("Bob Barnacle")
|
|
|
|
await linkField.getByRole("button").click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
|
|
await expect.poll(() => readClipboard(page)).toBe(await link.inputValue())
|
|
|
|
await pubkeyField.getByRole("button").click()
|
|
|
|
await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey))
|
|
|
|
await info.getByRole("button", {name: "Copy"}).click()
|
|
|
|
await expect.poll(() => readClipboard(page)).toContain("Bob Barnacle")
|
|
|
|
await page.getByRole("button", {name: "Got it"}).click()
|
|
|
|
await profileMenu(page).click()
|
|
await page.getByRole("button", {name: "Share"}).click()
|
|
|
|
// The menu that opened it is still mounted behind the dialog, and its own items run together as
|
|
// "Share Profile Info", so the dialog's heading is matched exactly.
|
|
await expect(page.getByText("Share Profile", {exact: true})).toBeVisible()
|
|
|
|
const share = topDialog(page)
|
|
const qr = share.locator("canvas")
|
|
const shareLink = share.locator("input")
|
|
|
|
await expect(qr).toBeVisible()
|
|
expect((await qr.boundingBox())?.width ?? 0).toBeGreaterThan(0)
|
|
|
|
// pubkeyLink in src/app/env.ts, which shares a profile without relay hints.
|
|
await expect(shareLink).toHaveValue(
|
|
`https://coracle.social/${nprofileEncode({pubkey: users.bob.pubkey, relays: []})}`,
|
|
)
|
|
|
|
await share.locator("label.input").getByRole("button").click()
|
|
|
|
await expect.poll(() => readClipboard(page)).toBe(await shareLink.inputValue())
|
|
})
|
|
|
|
test("US-082 mute an account", async ({seed, as}) => {
|
|
await seed(({relay, user, at}) => {
|
|
const space = relay("space")
|
|
|
|
space.room("general", {name: "General"})
|
|
space.join(user.alice, "general")
|
|
space.join(user.bob, "general")
|
|
space.profile(user.bob, {name: "Bob Barnacle", about: "Dockside cook."})
|
|
space.relayList(user.alice)
|
|
space.relayList(user.bob)
|
|
|
|
space.event(
|
|
user.bob,
|
|
makeEvent(NOTE, {content: "Salt on the rigging this morning", created_at: at(2, HOUR)}),
|
|
)
|
|
|
|
space.event(
|
|
user.bob,
|
|
makeEvent(NOTE, {content: "The gulls have taken the mainsail", created_at: at(1, HOUR)}),
|
|
)
|
|
})
|
|
|
|
const page = await as(users.alice, "/settings/content", {context: DESKTOP})
|
|
const badge = page.locator(".badge").filter({hasText: "Bob Barnacle"})
|
|
const save = page.getByRole("button", {name: "Save Changes"})
|
|
|
|
// Typed rather than filled, and slower than the search's own debounce: the suggestion list is
|
|
// rebuilt per keystroke, and the profile it is searching for only arrives from the relay once a
|
|
// pause in the typing has let the query go out.
|
|
await page
|
|
.getByPlaceholder("Search for profiles...")
|
|
.pressSequentially("Bob Barnacle", {delay: 700})
|
|
|
|
const suggestion = page.locator(".tiptap-suggestions__item").filter({hasText: "Bob Barnacle"})
|
|
|
|
await expect(suggestion).toBeVisible()
|
|
|
|
await suggestion.click()
|
|
|
|
await expect(badge).toBeVisible()
|
|
|
|
await save.click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
|
|
|
|
// Through the nav rather than a fresh navigation, so what is on screen is what the client that
|
|
// just published the mute believes.
|
|
const openBobsProfile = async () => {
|
|
await openSearch(page)
|
|
await searchTerm(page).fill("Barnacle")
|
|
|
|
const card = searchResults(page).filter({hasText: "Bob Barnacle"})
|
|
|
|
await expect(card).toHaveCount(1)
|
|
await viewProfile(card).click()
|
|
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`))
|
|
}
|
|
|
|
await openBobsProfile()
|
|
|
|
const list = notes(page)
|
|
|
|
await expect(list).toHaveCount(2)
|
|
await expect(list.first()).toContainText("You have muted this person.")
|
|
await expect(list.last()).toContainText("You have muted this person.")
|
|
await expect(pageContent(page).getByText("Salt on the rigging")).toHaveCount(0)
|
|
|
|
await list.first().getByRole("button", {name: "Show anyway"}).click()
|
|
|
|
await expect(list.first()).toContainText("The gulls have taken the mainsail")
|
|
await expect(list.last()).toContainText("You have muted this person.")
|
|
|
|
await page.locator('.primary-nav a[href="/settings/profile"]').click()
|
|
await page.getByRole("link", {name: "Content"}).click()
|
|
|
|
await expect(badge).toBeVisible()
|
|
|
|
await page.reload()
|
|
|
|
await expect(badge).toBeVisible()
|
|
|
|
await badge.getByRole("button").first().click()
|
|
|
|
await expect(badge).toHaveCount(0)
|
|
|
|
await save.click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Your settings have been saved!")
|
|
|
|
await openBobsProfile()
|
|
|
|
await expect(list).toHaveCount(2)
|
|
await expect(pageContent(page).getByText("Salt on the rigging this morning")).toBeVisible()
|
|
await expect(pageContent(page).getByText("The gulls have taken the mainsail")).toBeVisible()
|
|
await expect(pageContent(page).getByText("You have muted this person.")).toHaveCount(0)
|
|
})
|