781 lines
29 KiB
TypeScript
781 lines
29 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, makeEvent} from "@welshman/util"
|
|
import type {SignedEvent} from "@welshman/util"
|
|
import {FollowList, Note, PinList, Profile, RelayList} from "@welshman/domain"
|
|
import {expect, makeTestUser, mockBlossom, spacePath, test, users} from "../harness"
|
|
import type {SeededSpace, 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"]}
|
|
|
|
// Where an upload lands. Both the avatar picker and the banner picker call into uploadFile with no
|
|
// relay of their own, so the blossom probe is skipped and VITE_DEFAULT_BLOSSOM_SERVERS is used.
|
|
const BLOSSOM_ORIGIN = "https://blossom.primal.net"
|
|
|
|
// A 1x1 gif and a 1x1 webp. compressFileForUpload passes both formats through untouched rather
|
|
// than re-encoding them through a canvas, so the bytes the server hashes are the bytes chosen
|
|
// here and the url an upload resolves to is predictable from node.
|
|
const GIF = Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64")
|
|
const WEBP = Buffer.from("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==", "base64")
|
|
|
|
// uploadFile appends the extension when the descriptor's url carries none, which the mock's never
|
|
// does.
|
|
const uploadedUrl = (body: Buffer, extension: string) =>
|
|
`${BLOSSOM_ORIGIN}/${createHash("sha256").update(body).digest("hex")}.${extension}`
|
|
|
|
const profilePath = (user: TestUser) => `/people/${npubEncode(user.pubkey)}`
|
|
|
|
// 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 modal is a `.dialog` overlay wrapping a `.dialog` card, and the card is the one with the
|
|
// content in it.
|
|
const dialog = (page: Page) => page.locator(".dialog").last()
|
|
|
|
// 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) => dialog(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()
|
|
|
|
// Outbox routing resolves everything about a person through their relay list, so a seeded fixture
|
|
// is only loadable by somebody else once its author has one.
|
|
const seedRelayList = (space: SeededSpace, user: TestUser) =>
|
|
space.event(user, () =>
|
|
space
|
|
.kind(RelayList)
|
|
.writer()
|
|
.setReadUrls([space.url])
|
|
.setWriteUrls([space.url])
|
|
.renderTemplate(),
|
|
)
|
|
|
|
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.",
|
|
})
|
|
|
|
seedRelayList(space, 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 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)}$`))
|
|
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}) => {
|
|
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(),
|
|
)
|
|
|
|
seedRelayList(space, 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"})
|
|
seedRelayList(space, user.carol)
|
|
})
|
|
|
|
const space = scenario.space("space")
|
|
const other = scenario.space("other")
|
|
const page = await as(users.alice, profilePath(users.bob), {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()
|
|
|
|
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))
|
|
|
|
await expect(page.getByRole("heading", {name: "Carol Cutter"})).toBeVisible()
|
|
await expect(sidebar(page).getByText("No spaces found.")).toBeVisible()
|
|
})
|
|
|
|
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"})
|
|
seedRelayList(space, user.alice)
|
|
seedRelayList(space, user.bob)
|
|
})
|
|
|
|
const page = await as(users.alice, profilePath(users.bob), {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)}$`))
|
|
|
|
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})
|
|
seedRelayList(space, user.alice)
|
|
seedRelayList(space, user.bob)
|
|
seedRelayList(space, 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 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)}$`))
|
|
// 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)}$`))
|
|
|
|
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 follow 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(),
|
|
)
|
|
seedRelayList(space, user.alice)
|
|
})
|
|
|
|
const page = await as(users.alice, profilePath(users.alice), {context: DESKTOP})
|
|
|
|
await mockBlossom(page.context(), {server: 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"})
|
|
seedRelayList(space, user.alice)
|
|
seedRelayList(space, 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), {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))
|
|
|
|
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))
|
|
seedRelayList(space, 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(dialog(page).getByText("Bob Barnacle")).toBeVisible()
|
|
await expect(dialog(page).locator(`img[src="${avatar}"]`)).toBeVisible()
|
|
await expect(dialog(page).getByText("Deckhand, dockside cook")).toBeVisible()
|
|
await expect(dialog(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 dialog(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)}$`))
|
|
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."})
|
|
seedRelayList(space, user.bob)
|
|
})
|
|
|
|
const page = await as(users.alice, profilePath(users.bob), {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 = dialog(page)
|
|
const link = info.locator('input[type="text"]').first()
|
|
const pubkey = info.locator('input[type="text"]').nth(1)
|
|
|
|
// 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 info.locator("label.input").filter({has: link}).getByRole("button").click()
|
|
|
|
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
|
|
await expect.poll(() => readClipboard(page)).toBe(await link.inputValue())
|
|
|
|
await info.locator("label.input").filter({has: pubkey}).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()
|
|
|
|
await expect(page.getByText("Share Profile")).toBeVisible()
|
|
|
|
const share = dialog(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."})
|
|
seedRelayList(space, user.alice)
|
|
seedRelayList(space, 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)}$`))
|
|
}
|
|
|
|
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)
|
|
})
|