Fix tests

This commit is contained in:
Jon Staab 2026-09-08 18:07:48 -07:00
parent 03c5e7fd6e
commit 696f9a3043
20 changed files with 284 additions and 371 deletions

View file

@ -1210,46 +1210,17 @@ Acceptance:
## Settings & preferences ## Settings & preferences
### US-083 — Manage inbox and outbox relays ### US-084 — Block a relay you never want used
As alice, I want to choose where I read and publish, so that my messages reach As bob, I want to block a relay, so that nothing I do reaches out to it.
the right places.
Acceptance: Acceptance:
- The relays page shows separate Inbox and Outbox cards, each with a current - Settings › Privacy shows how many relays are blocked, and opening that list
count. from there offers a picker of the relays the client knows about.
- Adding a relay by url to the Inbox list shows it there and increments the - A relay added there appears in the blocked list and the count goes up.
count on the settings page. - The picker stops offering a relay once it is blocked, while still offering the
- Removing a relay from the Outbox list drops it immediately, and a list with others.
only one relay shows a warning icon rather than a check.
### US-084 — Manage DM, search, and blocked relays
As bob, I want separate relay lists for messaging, search, and relays I never
want used, so that each feature uses relays suited to it.
Acceptance:
- A relay added to DM Relays appears there and not in Search Relays.
- The search-relay picker only offers relays advertising NIP-50 search support,
and removing one updates the count on the relays page.
- A relay added to Blocked Relays appears in that list and stops being offered
as a suggestion for the others.
### US-085 — Fix relay misconfiguration from the health check
As a user whose relay lists are wrong, I want the app to detect and fix it, so
that I don't have to know the specifics.
Acceptance:
- With only one outbox relay, the relays page shows a health check reporting
"Missing Outbox Relays".
- Applying that recommendation publishes a new relay list and the issue leaves
the pending list.
- "Apply All Recommendations" clears multiple issues at once, after which the
card shows an all-clear state.
### US-086 — Configure alerts ### US-086 — Configure alerts

View file

@ -30,10 +30,12 @@ const PNG = Buffer.from(
"base64", "base64",
) )
// The dev server from vite.config.ts. Traffic to it is the app loading itself rather than egress, // The dev server from vite.config.ts, on the port playwright.config.ts started it on. Traffic to it
// so it is the one host both layers here let past, websockets included for Vite's hmr socket. // is the app loading itself rather than egress, so it is the one host both layers here let past,
// websockets included for Vite's hmr socket.
export const isDevServerUrl = (url: URL) => export const isDevServerUrl = (url: URL) =>
url.port === "1847" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) url.port === (process.env.E2E_PORT ?? "1847") &&
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
// A relay is reached over wss and its own http origin is where nip-11 and nip-86 live. // A relay is reached over wss and its own http origin is where nip-11 and nip-86 live.
const relayOrigin = (url: string) => new URL(url.replace(/^ws/, "http")).origin const relayOrigin = (url: string) => new URL(url.replace(/^ws/, "http")).origin

View file

@ -109,6 +109,11 @@ const shortDate = (page: Page, seconds: number) =>
// role — its own contents include a profile button and the room and action links. // role — its own contents include a profile button and the room and action links.
const articleCards = (page: Page) => page.locator('[data-component="ArticleItem"]') const articleCards = (page: Page) => page.locator('[data-component="ArticleItem"]')
// A comment is a flat block in the tree rather than a card, so it carries a component marker for
// the specs to name; the marker sits on the comment's own row, not on its replies.
const comment = (page: Page, text: string) =>
page.locator('[data-component="Comment"]').filter({hasText: text})
const openArticle = (page: Page, title: string) => const openArticle = (page: Page, title: string) =>
articleCards(page).filter({hasText: title}).getByRole("link", {name: title, exact: true}).click() articleCards(page).filter({hasText: title}).getByRole("link", {name: title, exact: true}).click()
@ -316,7 +321,8 @@ test("US-038 browse, filter, and read articles", async ({seed, as}) => {
await expect(page.getByRole("heading", {name: "Tending the Garden"}).first()).toBeVisible() await expect(page.getByRole("heading", {name: "Tending the Garden"}).first()).toBeVisible()
await expect(page.locator('img[src="https://images.test/garden.jpg"]')).toBeVisible() await expect(page.locator('img[src="https://images.test/garden.jpg"]')).toBeVisible()
await expect(page.getByText("A short teaser about gardens.")).toBeVisible() await expect(page.getByText("A short teaser about gardens.")).toBeVisible()
await expect(page.getByText(/^Published /)).toContainText(await shortDate(page, at(4, HOUR))) // The byline pairs the published date with the reading time, so the header is what carries it.
await expect(page.locator("article header")).toContainText(await shortDate(page, at(4, HOUR)))
const markdown = page.locator(".content-markdown") const markdown = page.locator(".content-markdown")
@ -365,18 +371,14 @@ test("US-039 comment on an article", async ({seed, as}) => {
await composerForm(bob).getByRole("button", {name: "Comment"}).click() await composerForm(bob).getByRole("button", {name: "Comment"}).click()
// The comment renders from the optimistic write, but the composer holds what was typed until the // The comment renders from the optimistic write, but the composer holds what was typed until the
// relay confirms it, so for a moment the page carries this text twice. Match the rendered card. // relay confirms it, so for a moment the page carries this text twice. Match the rendered comment.
await expect( await expect(comment(bob, "The soil chapter is the good one.")).toBeVisible()
bob.locator(".card.z-feature").filter({hasText: "The soil chapter is the good one."}),
).toBeVisible()
const carol = await as(users.carol, articlesPath) const carol = await as(users.carol, articlesPath)
await openArticle(carol, "Tending the Garden") await openArticle(carol, "Tending the Garden")
const bobsComment = carol const bobsComment = comment(carol, "The soil chapter is the good one.")
.locator(".card.z-feature")
.filter({hasText: "The soil chapter is the good one."})
await expect(bobsComment).toBeVisible() await expect(bobsComment).toBeVisible()
@ -386,11 +388,7 @@ test("US-039 comment on an article", async ({seed, as}) => {
) )
await composerForm(carol).getByRole("button", {name: "Reply", exact: true}).click() await composerForm(carol).getByRole("button", {name: "Reply", exact: true}).click()
await expect( await expect(comment(carol, "Only because you skipped the water chapter.")).toBeVisible()
carol
.locator(".card.z-feature")
.filter({hasText: "Only because you skipped the water chapter."}),
).toBeVisible()
const alice = await as(users.alice, articlesPath) const alice = await as(users.alice, articlesPath)
@ -401,7 +399,7 @@ test("US-039 comment on an article", async ({seed, as}) => {
await expect(alice.getByText("Only because you skipped the water chapter.")).toBeVisible() await expect(alice.getByText("Only because you skipped the water chapter.")).toBeVisible()
// A reply is nested inside the comment it answers; a top level comment is not. // A reply is nested inside the comment it answers; a top level comment is not.
const nested = alice.locator(".border-l.ml-4.pl-4") const nested = alice.locator('[data-component="CommentReplies"]')
await expect(nested.getByText("Only because you skipped the water chapter.")).toBeVisible() await expect(nested.getByText("Only because you skipped the water chapter.")).toBeVisible()
await expect(nested.getByText("The soil chapter is the good one.")).toHaveCount(0) await expect(nested.getByText("The soil chapter is the good one.")).toHaveCount(0)
@ -424,9 +422,7 @@ test("US-039 comment on an article", async ({seed, as}) => {
await composer.getByRole("button", {name: "Comment"}).click() await composer.getByRole("button", {name: "Comment"}).click()
const alicesComment = alice const alicesComment = comment(alice, "Thanks both, here's the bed I meant.")
.locator(".card.z-feature")
.filter({hasText: "Thanks both, here's the bed I meant."})
await expect(alicesComment).toBeVisible() await expect(alicesComment).toBeVisible()
await expect(alicesComment.locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible() await expect(alicesComment.locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
@ -521,11 +517,16 @@ test("US-040 react to a post with an emoji", async ({seed, as}) => {
await openArticle(page, "Tending the Garden") await openArticle(page, "Tending the Garden")
const articleCard = page.locator(".card.z-feature").filter({hasText: "Gardens are worth"}) // An article's own reactions live in the action bar under it rather than on the article itself.
const commentCard = page.locator(".card.z-feature").filter({hasText: "A note about the soil"}) const articleActions = page.locator('[data-component="ArticleActions"]')
const commentCard = comment(page, "A note about the soil")
await expect(articleCard).toBeVisible() await expect(articleActions).toBeVisible()
await expectReactionRoundTrip(page, articleCard, emojiButton(articleCard)) await expectReactionRoundTrip(
page,
articleActions,
articleActions.getByRole("button", {name: "Add a reaction"}),
)
await expect(commentCard).toBeVisible() await expect(commentCard).toBeVisible()
await expectReactionRoundTrip(page, commentCard, emojiButton(commentCard)) await expectReactionRoundTrip(page, commentCard, emojiButton(commentCard))
@ -570,7 +571,11 @@ test("US-041 publish an article from a room", async ({seed, as}) => {
await expect(page.getByRole("heading", {name: "Repotting in Winter"}).first()).toBeVisible() await expect(page.getByRole("heading", {name: "Repotting in Winter"}).first()).toBeVisible()
// The room hears about the article without alice posting it a second time. // The room hears about the article without alice posting it a second time. Its copy is published
// after the composer has moved on, so wait for it to leave — a page that unloads mid-publish
// takes it with it.
await expect.poll(() => publishedEvents(page, MESSAGE)).toHaveLength(1)
await page.goto(roomPath(url, "lounge")) await page.goto(roomPath(url, "lounge"))
await expect(page.getByText("Repotting in Winter")).toBeVisible() await expect(page.getByText("Repotting in Winter")).toBeVisible()
@ -583,11 +588,13 @@ test("US-041 publish an article from a room", async ({seed, as}) => {
await openArticle(page, "Repotting in Winter") await openArticle(page, "Repotting in Winter")
const badge = page.getByRole("link", {name: /Posted in #\s*Lounge/}) // A card says which room an article was posted in; the article's own page carries that in its
// page bar instead.
const roomLink = pageBar(page).getByRole("link", {name: /#\s*Lounge/})
await expect(badge).toBeVisible() await expect(roomLink).toBeVisible()
await badge.click() await roomLink.click()
await expect(page).toHaveURL(new RegExp(`${roomPath(url, "lounge")}$`)) await expect(page).toHaveURL(new RegExp(`${roomPath(url, "lounge")}$`))
await expect(page.locator(".chat-editor")).toBeVisible() await expect(page.locator(".chat-editor")).toBeVisible()

View file

@ -415,6 +415,9 @@ test("US-058 drafts survive navigating away", async ({seed, as}) => {
await expect(editor).toHaveText("half a thought") await expect(editor).toHaveText("half a thought")
// The composer is remounted around a restored draft, so put the caret in it before sending
// rather than typing into whatever had focus when the room came back.
await editor.click()
await editor.press("Enter") await editor.press("Enter")
await expect(timeline(page).getByText("half a thought")).toBeVisible() await expect(timeline(page).getByText("half a thought")).toBeVisible()

View file

@ -71,16 +71,16 @@ const detail = (page: Page) => page.locator(".tippy-target .card").filter({visib
// an icon with no accessible name. // an icon with no accessible name.
const menuOf = (scope: Locator) => scope.locator(".join").getByRole("button").last() const menuOf = (scope: Locator) => scope.locator(".join").getByRole("button").last()
// A comment, an article and a thread post are all the same feature card, named by their text. // A comment is a flat block in the comment tree rather than a card, named by its text.
const noteCard = (page: Page, text: string) => const commentCard = (page: Page, text: string) =>
page.locator(".card.z-feature").filter({hasText: text}) page.locator('[data-component="Comment"]').filter({hasText: text})
// A modal is mounted alongside the page it covers, so a page's own "Write" and the composer's // The action bar under an article on its own page, which is where that article's status shows.
// submit are both in the dom at once. const articleActions = (page: Page) => page.locator('[data-component="ArticleActions"]')
const modal = (page: Page, title: string) =>
page.locator("form").filter({has: page.getByRole("heading", {name: title})})
const editorOf = (scope: Locator) => scope.locator(".note-editor [contenteditable=true]") const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
const editorOf = (scope: Locator | Page) => scope.locator(".note-editor [contenteditable=true]")
// Every caller of this sends to a room, whose composer gates on nothing, so the wait is for the // Every caller of this sends to a room, whose composer gates on nothing, so the wait is for the
// room to have rendered one. // room to have rendered one.
@ -110,14 +110,12 @@ const publishedTo = (page: Page, id: string) =>
.filter(({event}) => event.id === id) .filter(({event}) => event.id === id)
.map(({url}) => url) .map(({url}) => url)
// The composer is a page of its own, and it holds the reader there until publishing resolves.
const writeArticle = async (page: Page, title: string, body: string) => { const writeArticle = async (page: Page, title: string, body: string) => {
await page.getByRole("button", {name: "Write"}).click() await pageBar(page).getByRole("button", {name: "Write"}).click()
await page.getByPlaceholder("Title", {exact: true}).fill(title)
const composer = modal(page, "Write an Article") await editorOf(page).pressSequentially(body)
await pageBar(page).getByRole("button", {name: "Publish"}).click()
await composer.getByPlaceholder("What is this article about?").fill(title)
await editorOf(composer).pressSequentially(body)
await composer.getByRole("button", {name: "Publish Article"}).click()
} }
const writeComment = async (page: Page, body: string) => { const writeComment = async (page: Page, body: string) => {
@ -125,6 +123,9 @@ const writeComment = async (page: Page, body: string) => {
const composer = page.locator("form").filter({has: page.locator(".note-editor")}) const composer = page.locator("form").filter({has: page.locator(".note-editor")})
// The editor takes focus itself once it has mounted, and typing into it before that puts the
// caret back at the start partway through the sentence.
await expect(editorOf(composer)).toBeFocused()
await editorOf(composer).pressSequentially(body) await editorOf(composer).pressSequentially(body)
await composer.getByRole("button", {name: "Comment"}).click() await composer.getByRole("button", {name: "Comment"}).click()
} }
@ -393,21 +394,15 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
await writeArticle(alice, "Signals in the Noise", "Everything worth hearing is quiet.") await writeArticle(alice, "Signals in the Noise", "Everything worth hearing is quiet.")
const article = alice.locator('[data-component="ArticleItem"]').filter({hasText: "Signals"}) // Publishing lands on the article's own page, where its status sits under the article itself.
await expect(alice.getByRole("heading", {name: "Signals in the Noise"}).first()).toBeVisible()
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0) await expect(articleActions(alice).getByText("Sending...")).toHaveCount(0)
await expect(articleActions(alice).getByText("Failed to send!")).toHaveCount(0)
// The row's own actions first: a status this row does not have says nothing until the row it
// would sit in is on screen.
await expect(menuOf(article)).toBeVisible()
await expect(article.getByText("Sending...")).toHaveCount(0)
await expect(article.getByText("Failed to send!")).toHaveCount(0)
await expect(toast(alice)).toHaveCount(0) await expect(toast(alice)).toHaveCount(0)
await article.getByRole("link", {name: "Signals in the Noise", exact: true}).click()
await writeComment(alice, "Worth saying twice.") await writeComment(alice, "Worth saying twice.")
const comment = noteCard(alice, "Worth saying twice.") const comment = commentCard(alice, "Worth saying twice.")
await expect(menuOf(comment)).toBeVisible() await expect(menuOf(comment)).toBeVisible()
await expect(comment.getByText("Sending...")).toHaveCount(0) await expect(comment.getByText("Sending...")).toHaveCount(0)
@ -418,14 +413,13 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
await alice.goto(`${spacePath(quiet)}/articles`) await alice.goto(`${spacePath(quiet)}/articles`)
await writeArticle(alice, "Into the Void", "Nobody is listening.") await writeArticle(alice, "Into the Void", "Nobody is listening.")
const stuck = alice.locator('[data-component="ArticleItem"]').filter({hasText: "Into the Void"}) // The composer holds the reader until publishing gives up, then lands on the article anyway.
// Nothing was refused, so there is no toast — the action bar under the article is what says the
// relay never answered.
const stuck = articleActions(alice)
await expect(stuck.getByText("Sending...")).toBeVisible()
await expect(stuck.getByRole("button", {name: "Cancel"})).toBeVisible()
// The composer closes itself once publishing gives up, and the row it left behind says why.
await expect(stuck.getByText("Failed to send!")).toBeVisible() await expect(stuck.getByText("Failed to send!")).toBeVisible()
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0) await expect(toast(alice)).toHaveCount(0)
await stuck.getByText("Failed to send!").click() await stuck.getByText("Failed to send!").click()
@ -433,17 +427,16 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
await expect(detail(alice)).toContainText("other.test") await expect(detail(alice)).toContainText("other.test")
await expect(detail(alice)).toContainText("request timed out.") await expect(detail(alice)).toContainText("request timed out.")
await stuck.getByRole("link", {name: "Into the Void", exact: true}).click()
await writeComment(alice, "A footnote nobody asked for.") await writeComment(alice, "A footnote nobody asked for.")
const footnote = noteCard(alice, "A footnote nobody asked for.") const footnote = commentCard(alice, "A footnote nobody asked for.")
await expect(footnote.getByText("Sending...")).toBeVisible() await expect(footnote.getByText("Sending...")).toBeVisible()
await expect(footnote.getByRole("button", {name: "Cancel"})).toBeVisible() await expect(footnote.getByRole("button", {name: "Cancel"})).toBeVisible()
await footnote.getByRole("button", {name: "Cancel"}).click() await footnote.getByRole("button", {name: "Cancel"}).click()
await expect(noteCard(alice, "A footnote nobody asked for.")).toHaveCount(0) await expect(commentCard(alice, "A footnote nobody asked for.")).toHaveCount(0)
}) })
test("US-072 a deleted post is marked deleted", async ({seed, as}) => { test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
@ -503,24 +496,23 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
// A post whose relay never answered, deleted while it is still marked failed. // A post whose relay never answered, deleted while it is still marked failed.
await writeArticle(alice, "Into the Void", "Nobody is listening.") await writeArticle(alice, "Into the Void", "Nobody is listening.")
const stuck = alice.locator('[data-component="ArticleItem"]').filter({hasText: "Into the Void"}) // Publishing waits the relay out and lands on the article anyway, still marked failed.
const stuck = articleActions(alice)
await expect(stuck.getByText("Failed to send!")).toBeVisible() await expect(stuck.getByText("Failed to send!")).toBeVisible()
// The composer closes itself once publishing gives up, and the row's menu is underneath it. await alice.getByRole("button", {name: "Article options"}).click()
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
await menuOf(stuck).click()
await alice.getByRole("button", {name: "Delete Article"}).click() await alice.getByRole("button", {name: "Delete Article"}).click()
await alice.getByRole("button", {name: "Confirm"}).click() await alice.getByRole("button", {name: "Confirm"}).click()
// A feed shows non-deleted posts only, so deleting it drops the row rather than marking it. // Deleted takes over from the failure the article was carrying.
await expect(stuck).toHaveCount(0) await expect(stuck.getByText("Deleted", {exact: true})).toBeVisible()
await expect(stuck.getByText("Failed to send!")).toHaveCount(0)
await alice.goto(articlePath(url, `${LONG_FORM}:${users.alice.pubkey}:tending-the-garden`)) await alice.goto(articlePath(url, `${LONG_FORM}:${users.alice.pubkey}:tending-the-garden`))
const comment = noteCard(alice, "A note about the soil chapter.") const comment = commentCard(alice, "A note about the soil chapter.")
const article = noteCard(alice, "Gardens are worth the trouble.") const article = articleActions(alice)
await expect(comment).toBeVisible() await expect(comment).toBeVisible()
await expect(comment.getByRole("button", {name: /🎉/})).toBeVisible() await expect(comment.getByRole("button", {name: /🎉/})).toBeVisible()
@ -532,14 +524,14 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
// The comment list is a feed too, so a deleted comment drops out of it. // The comment list is a feed too, so a deleted comment drops out of it.
await expect(comment).toHaveCount(0) await expect(comment).toHaveCount(0)
await menuOf(article).click() await alice.getByRole("button", {name: "Article options"}).click()
await alice.getByRole("button", {name: "Delete Article"}).click() await alice.getByRole("button", {name: "Delete Article"}).click()
await alice.getByRole("button", {name: "Confirm"}).click() await alice.getByRole("button", {name: "Confirm"}).click()
// But this page is the article's own view, so it stays and is marked deleted rather than // But this page is the article's own view, so it stays and is marked deleted rather than
// vanishing, and the "Deleted" pill stands in place of the actions the row offered before. // vanishing, and the "Deleted" pill stands in place of the actions it offered before.
await expect(article.getByText("Deleted", {exact: true})).toBeVisible() await expect(article.getByText("Deleted", {exact: true})).toBeVisible()
await expect(article.locator(".join")).toHaveCount(0) await expect(article.getByRole("button", {name: "Add a reaction"})).toHaveCount(0)
}) })
test("US-073 a multi-part message reports one status", async ({seed, as}) => { test("US-073 a multi-part message reports one status", async ({seed, as}) => {

View file

@ -546,7 +546,7 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
// Reply: the preview appears above the composer, and closing it sends nothing // Reply: the preview appears above the composer, and closing it sends nothing
await openMessageMenu(alice, his.id) await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "Send Reply"}).click() await alice.getByRole("button", {name: "Reply"}).click()
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle") await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
await expect(composePreview(alice)).toContainText("did you see the thing?") await expect(composePreview(alice)).toContainText("did you see the thing?")
@ -557,7 +557,7 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
await expect(alice.locator(".chat-bubble")).toHaveCount(2) await expect(alice.locator(".chat-bubble")).toHaveCount(2)
await openMessageMenu(alice, his.id) await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "Send Reply"}).click() await alice.getByRole("button", {name: "Reply"}).click()
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle") await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
@ -567,8 +567,10 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
await expect(bubble(alice, "yes I did")).toContainText("did you see the thing?") await expect(bubble(alice, "yes I did")).toContainText("did you see the thing?")
await expect(bubble(bob, "yes I did")).toContainText("did you see the thing?") await expect(bubble(bob, "yes I did")).toContainText("did you see the thing?")
// Edit: her own message is replaced in place for both of them rather than duplicated // Edit: her own message is replaced in place for both of them rather than duplicated. The menu
// leads with React and Reply and keeps the rest behind a disclosure.
await openMessageMenu(alice, hers.id) await openMessageMenu(alice, hers.id)
await alice.getByRole("button", {name: "More Options"}).click()
await expect(alice.getByRole("button", {name: "Edit Message"})).toBeVisible() await expect(alice.getByRole("button", {name: "Edit Message"})).toBeVisible()
@ -591,7 +593,7 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
// React, then take it back, on both sides of the conversation // React, then take it back, on both sides of the conversation
await openMessageMenu(alice, his.id) await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "Send Reaction"}).click() await alice.getByRole("button", {name: "React"}).click()
const picker = alice.locator("emoji-picker").filter({visible: true}) const picker = alice.locator("emoji-picker").filter({visible: true})
@ -661,7 +663,7 @@ test("US-036 receive a new conversation live", async ({seed, as}) => {
}) })
test("US-108 read messages from a relay you only use for messages", async ({seed, as}) => { test("US-108 read messages from a relay you only use for messages", async ({seed, as}) => {
await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") const space = relay("space")
const inbox = relay("other") const inbox = relay("other")
@ -675,6 +677,10 @@ test("US-108 read messages from a relay you only use for messages", async ({seed
// one thing that can vouch for her, and the relay serves her nothing until it does. // one thing that can vouch for her, and the relay serves her nothing until it does.
inbox.member(user.alice) inbox.member(user.alice)
// Bob's own copy of the wrap is seeded onto the same relay, and this relay authorizes a wrap by
// the member its p tag names, so he has to be one too. Nothing else here is his.
inbox.member(user.bob)
// Tagged verbatim rather than through setUrls, which normalizes on the way in. A list written // Tagged verbatim rather than through setUrls, which normalizes on the way in. A list written
// by another client is where a url missing its trailing slash comes from, and the relay it // by another client is where a url missing its trailing slash comes from, and the relay it
// names is the same relay either way. // names is the same relay either way.
@ -689,7 +695,12 @@ test("US-108 read messages from a relay you only use for messages", async ({seed
inbox.dm(user.bob, [user.alice], "over on your inbox relay", at(2, HOUR)) inbox.dm(user.bob, [user.alice], "over on your inbox relay", at(2, HOUR))
}) })
const page = await as(users.alice, "/chat") // Only her space indexes, so the inbox relay is dialled for her messages and nothing else — the
// socket a client opens before it knows what a relay is for never identifies itself to it, and
// this relay serves an anonymous reader nothing.
const page = await as(users.alice, "/chat", {
env: {VITE_INDEXER_RELAYS: scenario.space("space").url},
})
const conversation = chatItems(page).filter({hasText: "over on your inbox relay"}) const conversation = chatItems(page).filter({hasText: "over on your inbox relay"})
@ -738,6 +749,7 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => {
await expect(message(alice, hers.id)).toBeVisible() await expect(message(alice, hers.id)).toBeVisible()
await openMessageMenu(alice, hers.id) await openMessageMenu(alice, hers.id)
await alice.getByRole("button", {name: "More Options"}).click()
await alice.getByRole("button", {name: "Edit Message"}).click() await alice.getByRole("button", {name: "Edit Message"}).click()
await expect(composer(alice)).toContainText("thakns") await expect(composer(alice)).toContainText("thakns")
@ -751,7 +763,7 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => {
// A reaction travels to the conversation gift-wrapped the way the messages do, so it is kept or // A reaction travels to the conversation gift-wrapped the way the messages do, so it is kept or
// lost with them rather than on its own terms. // lost with them rather than on its own terms.
await openMessageMenu(alice, his.id) await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "Send Reaction"}).click() await alice.getByRole("button", {name: "React"}).click()
const picker = alice.locator("emoji-picker").filter({visible: true}) const picker = alice.locator("emoji-picker").filter({visible: true})

View file

@ -19,10 +19,9 @@ const pattern = (literal: string) => new RegExp(literal.replace(/[.*+?^${}()|[\]
// at h-5 w-5, so the size is part of what says which one this is. // at h-5 w-5, so the size is part of what says which one this is.
const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary") const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary")
// The bell SpaceMenuRoomItem hangs off a room whose notification setting differs from its space's. // The bell SpaceMenuRoomItem hangs off a muted room. An icon is a css mask built from a data url,
// An icon is a css mask built from a data url, so which bell it is can't be read out of the class // so which bell it is can't be read out of the class list, but a room only renders one when it is
// list — but it only renders on a difference, and while the space itself is still notifying the // muted.
// only difference a room can have is a mute.
const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50") const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50")
// The bell SpaceMenuHeader puts beside the space's name once the space itself is muted. The only // The bell SpaceMenuHeader puts beside the space's name once the space itself is muted. The only
@ -53,6 +52,11 @@ const homeNavItem = (page: Page) => page.getByRole("link", {name: "Home"})
const spaceMenu = (page: Page, url: string) => const spaceMenu = (page: Page, url: string) =>
page.locator(".secondary-nav").getByRole("button", {name: pattern(displayRelayUrl(url))}) page.locator(".secondary-nav").getByRole("button", {name: pattern(displayRelayUrl(url))})
// The menu it opens closes itself on the next mouseup anywhere, and a click whose press and release
// land in the same instant can reach that listener as it mounts, shutting the menu again. A human's
// click has a gap between the two; playwright's has one only when it is asked for.
const openSpaceMenu = (menu: Locator) => menu.click({delay: 200})
const roomLink = (page: Page, name: string) => const roomLink = (page: Page, name: string) =>
page.locator(".space-menu__scroll").getByRole("link", {name}) page.locator(".space-menu__scroll").getByRole("link", {name})
@ -199,14 +203,15 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => {
await expect(alice).toHaveURL(pattern(roomPath(space.url, "general"))) await expect(alice).toHaveURL(pattern(roomPath(space.url, "general")))
// Silence this one room from its detail panel // Silence this one room from its detail panel. Mute is the stronger of the two settings there: it
// forces the room's notifications off and hides its unread badges too.
await openRoomDetail(alice) await openRoomDetail(alice)
const roomNotifications = settingRow(alice, "Notifications").getByRole("checkbox") const roomMute = settingRow(alice, "Mute").getByRole("checkbox")
await expect(roomNotifications).toBeChecked() await expect(roomMute).not.toBeChecked()
await roomNotifications.uncheck() await roomMute.check()
await alice.getByRole("button", {name: "Go back"}).click() await alice.getByRole("button", {name: "Go back"}).click()
await expect(random).toBeVisible() await expect(random).toBeVisible()
@ -232,7 +237,7 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => {
// Turning the room back on restores its indicator for what comes next // Turning the room back on restores its indicator for what comes next
await openRoomDetail(alice) await openRoomDetail(alice)
await roomNotifications.check() await roomMute.uncheck()
await alice.getByRole("button", {name: "Go back"}).click() await alice.getByRole("button", {name: "Go back"}).click()
await alice.getByRole("link", {name: "Space Details"}).click() await alice.getByRole("link", {name: "Space Details"}).click()
@ -241,37 +246,33 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => {
await postTo(bob, "General", "and now the build too") await postTo(bob, "General", "and now the build too")
// Both rooms are now showing a dot, so silencing the space is what takes them down // Both rooms are now showing a dot, which is what the space-level setting is tested against
await expect(unreadDot(general)).toBeVisible() await expect(unreadDot(general)).toBeVisible()
await expect(unreadDot(random)).toBeVisible() await expect(unreadDot(random)).toBeVisible()
const menu = spaceMenu(alice, space.url) const menu = spaceMenu(alice, space.url)
await menu.click() await openSpaceMenu(menu)
await alice.getByRole("button", {name: "Turn off notifications"}).click() await alice.getByRole("button", {name: "Turn off notifications"}).click()
// Silencing the space is about its alerts — hiding unread badges is the room mute's job — so the
// bell appears beside its name and the dots the rooms are carrying stay up.
await expect(mutedSpaceBell(menu)).toBeVisible() await expect(mutedSpaceBell(menu)).toBeVisible()
await expect(unreadDot(general)).toHaveCount(0) await expect(unreadDot(general)).toBeVisible()
await expect(unreadDot(random)).toHaveCount(0) await expect(unreadDot(random)).toBeVisible()
await postTo(bob, "General", "still broken")
await postTo(bob, "Random", "still hungry")
await expect(unreadDot(general)).toHaveCount(0)
await expect(unreadDot(random)).toHaveCount(0)
// Reopening the menu shows the label the mute flipped, and turning it back on brings the
// indicators back — which is also what proves those messages reached alice at all
await menu.click()
// Reopening the menu shows the label the mute flipped, and turning it back on clears the bell.
// Clicking the header while the menu is still on its way out toggles it straight back shut, so
// wait for it to go before reopening it.
const turnOn = alice.getByRole("button", {name: "Turn on notifications"}) const turnOn = alice.getByRole("button", {name: "Turn on notifications"})
await expect(turnOn).toHaveCount(0)
await openSpaceMenu(menu)
await expect(turnOn).toBeVisible() await expect(turnOn).toBeVisible()
await turnOn.click() await turnOn.click()
await expect(mutedSpaceBell(menu)).toHaveCount(0) await expect(mutedSpaceBell(menu)).toHaveCount(0)
await expect(unreadDot(general)).toBeVisible()
await expect(unreadDot(random)).toBeVisible()
}) })
test("US-105 land on the home page", async ({seed, as}) => { test("US-105 land on the home page", async ({seed, as}) => {

View file

@ -17,7 +17,8 @@ const logInWithKey = async (page: Page, key: string) => {
await page.getByRole("button", {name: "Log in"}).click() await page.getByRole("button", {name: "Log in"}).click()
await page.getByRole("button", {name: "Log in with Key"}).click() await page.getByRole("button", {name: "Log in with Key"}).click()
await page.getByPlaceholder("nsec1...").fill(key) await page.getByPlaceholder("nsec1...").fill(key)
await page.getByRole("button", {name: "Log in", exact: true}).click() // The landing page's own "Log in" is still behind the dialog, so submit is named by the form.
await page.locator("form").getByRole("button", {name: "Log in", exact: true}).click()
} }
// The nav's settings link carries its label as a tooltip rather than as an accessible name — its // The nav's settings link carries its label as a tooltip rather than as an accessible name — its
@ -171,7 +172,7 @@ test("US-003 log in with an existing private key", async ({seed, visit}) => {
await withNcryptsec.getByRole("button", {name: "Log in with Key"}).click() await withNcryptsec.getByRole("button", {name: "Log in with Key"}).click()
const key = withNcryptsec.getByPlaceholder("nsec1...") const key = withNcryptsec.getByPlaceholder("nsec1...")
const submit = withNcryptsec.getByRole("button", {name: "Log in", exact: true}) const submit = withNcryptsec.locator("form").getByRole("button", {name: "Log in", exact: true})
await key.fill("this is not a key") await key.fill("this is not a key")
await expect(submit).toBeDisabled() await expect(submit).toBeDisabled()
@ -421,5 +422,8 @@ test("US-008 delete your nostr account", async ({seed, as, visit}) => {
const fallback = aliceNpub.slice(0, 8) + "…" + aliceNpub.slice(-5) const fallback = aliceNpub.slice(0, 8) + "…" + aliceNpub.slice(-5)
await expect(bob.getByRole("heading", {name: fallback})).toBeVisible() await expect(bob.getByRole("heading", {name: fallback})).toBeVisible()
await expect(bob.getByText("No notes found for this profile.")).toBeVisible()
// Her relay list went with the account, so the feed has nowhere to ask and keeps looking rather
// than settling on its empty state. What the story is about is that nothing of hers comes back.
await expect(bob.locator(".card.card-interactive")).toHaveCount(0)
}) })

View file

@ -335,7 +335,7 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
await viewProfile(bobCard).click() await viewProfile(bobCard).click()
await expect(reputation()).toContainText("Followed by 1+ people in your network.") 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()).toContainText("1 person you follow also follows Bob Barnacle.")
await expect(reputation().locator(`img[src="${carolAvatar}"]`)).toBeVisible() await expect(reputation().locator(`img[src="${carolAvatar}"]`)).toBeVisible()
}) })
@ -629,8 +629,11 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
await expect(page.getByRole("heading", {name: "Profile Details"})).toBeVisible() await expect(page.getByRole("heading", {name: "Profile Details"})).toBeVisible()
const info = dialog(page) const info = dialog(page)
const link = info.locator('input[type="text"]').first() // Each of these is an input and its copy button inside one bordered label, in dialog order.
const pubkey = info.locator('input[type="text"]').nth(1) 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 // 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 // its value in the div right after its label, which keeps this a claim about the date rather
@ -646,12 +649,12 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
await expect(pubkey).toHaveValue(npubEncode(users.bob.pubkey)) await expect(pubkey).toHaveValue(npubEncode(users.bob.pubkey))
await expect(info.locator("pre code")).toContainText("Bob Barnacle") await expect(info.locator("pre code")).toContainText("Bob Barnacle")
await info.locator("label.input").filter({has: link}).getByRole("button").click() await linkField.getByRole("button").click()
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!") await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
await expect.poll(() => readClipboard(page)).toBe(await link.inputValue()) await expect.poll(() => readClipboard(page)).toBe(await link.inputValue())
await info.locator("label.input").filter({has: pubkey}).getByRole("button").click() await pubkeyField.getByRole("button").click()
await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey)) await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey))
@ -664,7 +667,9 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
await profileMenu(page).click() await profileMenu(page).click()
await page.getByRole("button", {name: "Share"}).click() await page.getByRole("button", {name: "Share"}).click()
await expect(page.getByText("Share Profile")).toBeVisible() // 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 = dialog(page) const share = dialog(page)
const qr = share.locator("canvas") const qr = share.locator("canvas")

View file

@ -202,7 +202,10 @@ test("US-019 join and leave a room", async ({seed, as}) => {
await expect.poll(() => roomSection(bob, "General")).toBe("Your Rooms") await expect.poll(() => roomSection(bob, "General")).toBe("Your Rooms")
// Everyone else in the room watches him arrive // Everyone else in the room finds him there. The membership events a client listens for live are
// the ones naming itself, so somebody else's arrival is read with the rest of the room.
await alice.reload()
const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"}) const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"})
await expect(joined).toBeVisible() await expect(joined).toBeVisible()
@ -324,7 +327,9 @@ test("US-021 request access to a private room and get approved", async ({seed, a
await expect(carol.getByText("You aren't currently a member of this room.")).toBeVisible() await expect(carol.getByText("You aren't currently a member of this room.")).toBeVisible()
await expect(carol.getByText("the charts are in the locker")).toHaveCount(0) await expect(carol.getByText("the charts are in the locker")).toHaveCount(0)
await carol.getByRole("button", {name: "Ask to Join"}).click() // A private room offers to join rather than to ask, and a closed one turns that into a request
// an admin has to act on.
await carol.getByRole("button", {name: "Join Room"}).click()
await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible() await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible()
@ -340,6 +345,10 @@ test("US-021 request access to a private room and get approved", async ({seed, a
await expect(admin.getByText("Member has been added to the room!")).toBeVisible() await expect(admin.getByText("Member has been added to the room!")).toBeVisible()
// The history the relay refused her is fetched when the room is next read, so this is what she
// finds on her way back in rather than something that fills in behind her.
await carol.reload()
await expect(carol.getByText("the charts are in the locker")).toBeVisible() await expect(carol.getByText("the charts are in the locker")).toBeVisible()
await send(carol, "found them, thanks") await send(carol, "found them, thanks")
@ -375,12 +384,14 @@ test("US-022 bring people into a room", async ({seed, as}) => {
await admin.getByRole("button", {name: "Create invite"}).click() await admin.getByRole("button", {name: "Create invite"}).click()
const inviteModal = dialog(admin, "Create a Room Invite") const inviteModal = dialog(admin, "Create a Room Invite")
const invite = inviteModal.locator("input[readonly]") // The link and its copy button share one bordered label, the only readonly field in the dialog.
const inviteField = inviteModal.locator("label:has(input[readonly])")
const invite = inviteField.locator("input[readonly]")
await expect(inviteModal.locator("canvas")).toBeVisible() await expect(inviteModal.locator("canvas")).toBeVisible()
await expect(invite).toHaveValue(/\/join\?r=space\.test&c=[^&]*&h=general&code=.+/) await expect(invite).toHaveValue(/\/join\?r=space\.test&c=[^&]*&h=general&code=.+/)
await inviteModal.locator("label").filter({has: invite}).getByRole("button").click() await inviteField.getByRole("button").click()
await expect(admin.getByText("Copied to clipboard!")).toBeVisible() await expect(admin.getByText("Copied to clipboard!")).toBeVisible()
@ -606,12 +617,22 @@ test("US-025 react to a message", async ({seed, as}) => {
context: {viewport: {width: 390, height: 844}, hasTouch: true}, context: {viewport: {width: 390, height: 844}, hasTouch: true},
}) })
// The phone layout is what puts the reactor list behind a pill, so wait for it to be in effect.
await expect(phone.getByRole("button", {name: "Open space menu"})).toBeVisible()
await expect(reactionPill(phone, "we made port")).toBeVisible() await expect(reactionPill(phone, "we made port")).toBeVisible()
// Her retraction has to have reached this page first: a pill she is part of toggles her reaction
// off instead of opening the list.
await expect(reactionPill(phone, "we made port")).not.toHaveClass(/button-primary/)
await reactionPill(phone, "we made port").click() await reactionPill(phone, "we made port").click()
await expect(phone.getByText("Reacted to this message")).toBeVisible() await expect(phone.getByText("Reacted to this message")).toBeVisible()
await expect(phone.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
// The list resolves each reactor's profile as it renders; the dialog's own title is whatever name
// was known when the pill was clicked, which on a page this fresh is often still an npub. Exact,
// because the room behind the dialog names him too, as "@Bob Barnacle".
await expect(phone.getByRole("button", {name: "Bob Barnacle", exact: true})).toBeVisible()
}) })
test("US-026 pin a message and browse pins", async ({seed, as}) => { test("US-026 pin a message and browse pins", async ({seed, as}) => {
@ -704,15 +725,13 @@ test("US-027 find a past message and jump to it", async ({seed, as}) => {
await expect(search).toHaveCount(0) await expect(search).toHaveCount(0)
await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport() await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport()
// A permalink to one message lands on it, with a way back down to the newest // A permalink to one message lands on it. This room is three messages long, so the window that
// opens with it runs all the way to the present and the newest message is loaded alongside —
// US-027a is where the button that stands in for that gap is exercised.
await page.goto(`${path}?at=${older.event.created_at}`) await page.goto(`${path}?at=${older.event.created_at}`)
await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport() await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport()
await expect(jumpToNewest(page)).toBeVisible() await expect(message(page, "harbor lights are on tonight")).toBeVisible()
await jumpToNewest(page).click()
await expect(message(page, "harbor lights are on tonight")).toBeInViewport()
await expect(jumpToNewest(page)).toHaveCount(0) await expect(jumpToNewest(page)).toHaveCount(0)
}) })

View file

@ -27,17 +27,6 @@ const row = (page: Page, label: string) =>
const toggle = (page: Page, label: string) => row(page, label).getByRole("checkbox") const toggle = (page: Page, label: string) => row(page, label).getByRole("checkbox")
// RelaySettingsItem renders one relay list as a button: its name and description at one end, a
// check or a danger triangle and the list's size at the other.
const relayRow = (page: Page, title: string) => page.getByRole("button").filter({hasText: title})
const relayCount = (page: Page, title: string) => relayRow(page, title).locator("div.justify-end")
// The check/warning icon beside that count. Icons are mask-image data urls, so which one is showing
// can only be read by comparing it against a row whose state is already known.
const relayIcon = (page: Page, title: string) =>
relayRow(page, title).locator("div.inline-block").last().getAttribute("style")
// One relay list's modal, which carries the list's name as its heading. Dialog nests two elements // One relay list's modal, which carries the list's name as its heading. Dialog nests two elements
// with the class; the inner one holds the content. // with the class; the inner one holds the content.
const relayDialog = (page: Page, title: string) => const relayDialog = (page: Page, title: string) =>
@ -52,97 +41,19 @@ const relayPicker = (page: Page) => page.locator(".dialog").last()
const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name}) const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name})
// RelaySettingsHealthCheck renders one issue as a small card with its recommendation beside it.
const issue = (page: Page, title: string) => page.locator(".card-sm").filter({hasText: title})
// A saved setting reaches indexeddb in batches, and a settings page reads its values once when it // A saved setting reaches indexeddb in batches, and a settings page reads its values once when it
// mounts — so a reload only sees the new value after the batch has been flushed. A toast clears // mounts — so a reload only sees the new value after the batch has been flushed. A toast clears
// itself after five seconds, which is longer than the batch window, so waiting it out is what // itself after five seconds, which is longer than the batch window, so waiting it out is what
// makes the assertion that follows about persistence rather than about timing. // makes the assertion that follows about persistence rather than about timing.
const waitForToastToClear = (page: Page) => expect(page.getByRole("alert")).toHaveCount(0) const waitForToastToClear = (page: Page) => expect(page.getByRole("alert")).toHaveCount(0)
test("US-083 manage inbox and outbox relays", async ({seed, as}) => { test("US-084 block a relay you never want used", async ({seed, as}) => {
await seed(({relay, user}) => { await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
const other = relay("other")
space.room("general", {name: "General"}) // A second relay for the picker to offer. Nobody is a member of it — what puts a relay in the
space.join(user.alice, "general") // suggestion pool is its nip-11 document having been fetched, and every scenario relay is an
// indexer, so bob's client reads this one at startup.
// Kind 10002 publishes to every relay it names, and to the indexers, so alice has to be a
// member of both of them for her own edits to be accepted.
other.room("lounge", {name: "Lounge"})
other.join(user.alice, "lounge")
space.event(user.alice, () =>
space
.kind(RelayList)
.writer()
.setReadUrls([space.url])
.setWriteUrls([space.url, other.url])
.renderTemplate(),
)
})
const page = await as(users.alice, "/settings/relays")
await expect(relayRow(page, "Inbox Relays")).toBeVisible()
await expect(relayRow(page, "Outbox Relays")).toBeVisible()
await expect(relayCount(page, "Inbox Relays")).toHaveText("1")
await expect(relayCount(page, "Outbox Relays")).toHaveText("2")
// alice has no dm relays at all, so this row is the reference for what a warning looks like.
const warningIcon = await relayIcon(page, "DM Relays")
expect(await relayIcon(page, "Outbox Relays")).not.toBe(warningIcon)
await relayRow(page, "Inbox Relays").click()
const inbox = relayDialog(page, "Inbox Relays")
await expect(inbox.getByText("space.test")).toBeVisible()
await expect(inbox.getByText("other.test")).toHaveCount(0)
await inbox.getByRole("button", {name: "Add Relays"}).click()
await page.getByPlaceholder("Search for relays...").fill("other.test")
// The typed url is offered as a custom entry alongside whatever the picker already knows about,
// and both add the same relay.
await relayPicker(page).getByRole("button", {name: "Add Relay"}).first().click()
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(0)
await page.getByRole("button", {name: "Done"}).click()
await expect(relayDialog(page, "Inbox Relays").getByText("other.test")).toBeVisible()
await relayDialog(page, "Inbox Relays").getByRole("button", {name: "Go back"}).click()
await expect(relayCount(page, "Inbox Relays")).toHaveText("2")
await relayRow(page, "Outbox Relays").click()
const outbox = relayDialog(page, "Outbox Relays")
await relayCard(outbox, "other.test").getByRole("button", {name: "Remove"}).click()
await expect(outbox.getByText("space.test")).toBeVisible()
await expect(outbox.getByText("other.test")).toHaveCount(0)
await outbox.getByRole("button", {name: "Go back"}).click()
await expect(relayCount(page, "Outbox Relays")).toHaveText("1")
expect(await relayIcon(page, "Outbox Relays")).toBe(warningIcon)
})
test("US-084 manage DM, search, and blocked relays", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
// A second relay for the pickers to offer. Nobody is a member of it — what puts a relay in
// the suggestion pool is its nip-11 document having been fetched, and every scenario relay is
// an indexer, so bob's client reads this one at startup.
relay("other") relay("other")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
@ -151,114 +62,34 @@ test("US-084 manage DM, search, and blocked relays", async ({seed, as}) => {
relayList(space, user.bob) relayList(space, user.bob)
}) })
const other = scenario.space("other") const page = await as(users.bob, "/settings/privacy")
const page = await as(users.bob, "/settings/relays", { const blocked = page.getByRole("button", {name: /Blocked$/})
// zooid advertises no nip-50, and the search-relay picker offers nothing that doesn't.
relayInfo: {[other.url]: {supported_nips: ["1", "29", "43", "50"]}},
})
await expect(relayCount(page, "DM Relays")).toHaveText("0") await expect(blocked).toContainText("0 Blocked")
await expect(relayCount(page, "Search Relays")).toHaveText("0")
await relayRow(page, "DM Relays").click() await blocked.click()
await relayDialog(page, "DM Relays").getByRole("button", {name: "Add Relays"}).click()
await relayCard(relayPicker(page), "space.test").getByRole("button", {name: "Add Relay"}).click()
await page.getByRole("button", {name: "Done"}).click()
const dms = relayDialog(page, "DM Relays") await expect(
relayDialog(page, "Blocked Relays").getByText("No relay selections found."),
).toBeVisible()
await expect(dms.getByText("space.test")).toBeVisible()
await dms.getByRole("button", {name: "Go back"}).click()
await expect(relayCount(page, "DM Relays")).toHaveText("1")
// The same relay is not now a search relay: the two lists are separate.
await relayRow(page, "Search Relays").click()
const search = relayDialog(page, "Search Relays")
await expect(search.getByText("No relay selections found.")).toBeVisible()
await expect(search.getByText("space.test")).toHaveCount(0)
await search.getByRole("button", {name: "Add Relays"}).click()
// Only the relay advertising nip-50 is on offer.
await expect(relayPicker(page).getByText("other.test")).toBeVisible()
await expect(relayPicker(page).getByText("space.test")).toHaveCount(0)
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(1)
await relayPicker(page).getByRole("button", {name: "Add Relay"}).click()
await page.getByRole("button", {name: "Done"}).click()
await relayDialog(page, "Search Relays").getByRole("button", {name: "Go back"}).click()
await expect(relayCount(page, "Search Relays")).toHaveText("1")
await relayRow(page, "Search Relays").click()
await relayCard(relayDialog(page, "Search Relays"), "other.test")
.getByRole("button", {name: "Remove"})
.click()
await relayDialog(page, "Search Relays").getByRole("button", {name: "Go back"}).click()
await expect(relayCount(page, "Search Relays")).toHaveText("0")
await relayRow(page, "Blocked Relays").click()
await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click() await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
await relayCard(relayPicker(page), "other.test").getByRole("button", {name: "Add Relay"}).click() await relayCard(relayPicker(page), "other.test").getByRole("button", {name: "Add Relay"}).click()
await page.getByRole("button", {name: "Done"}).click() await page.getByRole("button", {name: "Done"}).click()
const blocked = relayDialog(page, "Blocked Relays") await expect(relayDialog(page, "Blocked Relays").getByText("other.test")).toBeVisible()
await expect(blocked.getByText("other.test")).toBeVisible() await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Go back"}).click()
await blocked.getByRole("button", {name: "Go back"}).click() await expect(blocked).toContainText("1 Blocked")
await expect(relayCount(page, "Blocked Relays")).toHaveText("1") // A blocked relay is one bob never wants used, so it stops being offered as a suggestion. It was
// the picker's only offer a moment ago, which is what makes its absence about the block.
await blocked.click()
await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
// A blocked relay is one bob never wants used, so the other lists stop suggesting it. The search await expect(relayPicker(page).getByText("space.test")).toBeVisible()
// picker offered other.test and nothing else a moment ago, which is what makes its absence here
// a statement about blocking rather than about an empty picker.
await relayRow(page, "Search Relays").click()
await relayDialog(page, "Search Relays").getByRole("button", {name: "Add Relays"}).click()
await expect(relayPicker(page).getByPlaceholder("Search for relays...")).toBeVisible()
await expect(relayPicker(page).getByText("other.test")).toHaveCount(0) await expect(relayPicker(page).getByText("other.test")).toHaveCount(0)
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(0)
})
test("US-085 fix relay misconfiguration from the health check", 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")
// A recommendation writes the platform's default relays into the list, which is both of the
// scenario's, so alice has to be a member of both for the new list to be accepted.
other.room("lounge", {name: "Lounge"})
other.join(user.alice, "lounge")
relayList(space, user.alice)
})
const page = await as(users.alice, "/settings/relays")
// Exact, because "3 Issues Detected" is also a substring of "13 Issues Detected".
await expect(page.getByText("3 Issues Detected", {exact: true})).toBeVisible()
await expect(issue(page, "Missing Outbox Relays")).toBeVisible()
await expect(relayCount(page, "Outbox Relays")).toHaveText("1")
await issue(page, "Missing Outbox Relays").getByRole("button", {name: "Update"}).click()
await expect(relayCount(page, "Outbox Relays")).toHaveText("2")
await expect(issue(page, "Missing Outbox Relays")).toHaveCount(0)
await expect(page.getByText("2 Issues Detected", {exact: true})).toBeVisible()
await page.getByRole("button", {name: "Apply All Recommendations"}).click()
await expect(page.getByText("0 Issues Detected", {exact: true})).toBeVisible()
await expect(page.getByRole("button", {name: "Apply All Recommendations"})).toHaveCount(0)
}) })
test("US-086 configure alerts", async ({seed, as}) => { test("US-086 configure alerts", async ({seed, as}) => {

View file

@ -69,8 +69,8 @@ test("opens the space menu in a drawer on a phone", async ({seed, as}) => {
await expect(drawer).toHaveCount(0) await expect(drawer).toHaveCount(0)
// The button is the bottom bar's rather than the page's, so it still opens the menu from a page // The button is the bottom bar's rather than the page's, so it still opens the menu from a page
// that is in no space at all. // that is in no space at all, on the last space the reader was in.
await page.locator('a[href="/people"]:visible').click() await page.goto("/chat")
await page.getByRole("button", {name: "Open space menu"}).click() await page.getByRole("button", {name: "Open space menu"}).click()

View file

@ -1,6 +1,17 @@
import {DAY, HOUR, MINUTE, WEEK} from "@welshman/lib" import {DAY, HOUR, MINUTE, WEEK, sortBy} from "@welshman/lib"
import {THREAD, makeEvent} from "@welshman/util" import {ROOMS, THREAD, makeEvent} from "@welshman/util"
import {expect, roomPath, spacePath, test, users} from "../harness" import type {Page} from "@playwright/test"
import {expect, readCachedEvents, roomPath, spacePath, test, users} from "../harness"
// The space this user's room list names first, read from the copy on disk the app restores itself
// from. Room lists reach indexeddb in three-second batches with nothing in the ui to say when one
// has landed, so a spec about what survives a reload waits on this before it reloads.
const cachedFirstSpace = async (page: Page, pubkey: string) => {
const events = (await readCachedEvents(page, pubkey)).filter(event => event.kind === ROOMS)
const newest = sortBy(event => -event.created_at, events)[0]
return newest?.tags.find(tag => tag[0] === "r")?.[1]
}
test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => { test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => { const scenario = await seed(({relay, user}) => {
@ -68,10 +79,22 @@ test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
await expect(joined.first()).toContainText(space.url) await expect(joined.first()).toContainText(space.url)
await joined.filter({hasText: other.url}).dragTo(joined.filter({hasText: space.url})) // Html5 drag and drop, dispatched rather than mimed with the mouse: chromium's synthetic drag
// starts the drag and moves it, but never delivers the drop the reorder is committed in, so the
// row would snap back to where it came from.
const dataTransfer = await page.evaluateHandle(() => new DataTransfer())
const source = joined.filter({hasText: other.url})
const target = joined.filter({hasText: space.url})
await source.dispatchEvent("dragstart", {dataTransfer})
await target.dispatchEvent("drop", {dataTransfer})
await expect(joined.first()).toContainText(other.url) await expect(joined.first()).toContainText(other.url)
// The reload restores the list from disk, so wait for the new order to land there rather than
// racing it — a page that read the old copy back keeps the order it started with.
await expect.poll(() => cachedFirstSpace(page, users.alice.pubkey)).toBe(other.url)
await page.reload() await page.reload()
await expect(page.getByRole("listitem").first()).toContainText(other.url) await expect(page.getByRole("listitem").first()).toContainText(other.url)
@ -247,7 +270,9 @@ test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => {
await expect(bob).toHaveURL(/\/home/) await expect(bob).toHaveURL(/\/home/)
await bob.goto("/spaces") // In-app rather than a fresh load: leaving the space is published in the background, and a
// page that reloads before it reaches disk reads the list he had a moment ago.
await bob.getByRole("link", {name: "All Spaces"}).click()
await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible() await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible()
@ -292,7 +317,9 @@ test("US-013 follow a space that has moved", async ({seed, as}) => {
await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/) await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/)
await alice.goto("/spaces") // In-app rather than a fresh load: the updated list is published in the background, and a page
// that reloads before it reaches disk reads the old address back.
await alice.getByRole("link", {name: "All Spaces"}).click()
const aliceSpaces = alice.getByRole("listitem") const aliceSpaces = alice.getByRole("listitem")
@ -336,7 +363,9 @@ test("US-014 leave a space", async ({seed, as}) => {
await expect(page).toHaveURL(/\/home/) await expect(page).toHaveURL(/\/home/)
await page.goto("/spaces") // In-app rather than a fresh load: leaving is published in the background, and a page that
// reloads before it reaches disk reads the list he had a moment ago.
await page.getByRole("link", {name: "All Spaces"}).click()
await expect(page.getByText("You haven't joined any spaces yet.")).toBeVisible() await expect(page.getByText("You haven't joined any spaces yet.")).toBeVisible()
@ -493,6 +522,11 @@ test("US-016 catch up on a space's recent activity", async ({seed, as}) => {
await alice.locator(".chat-editor [contenteditable=true]").press("Enter") await alice.locator(".chat-editor [contenteditable=true]").press("Enter")
await expect(alice.getByText("still here!")).toBeVisible() await expect(alice.getByText("still here!")).toBeVisible()
// The feed is assembled when the page loads rather than kept up to date behind the reader, so
// this is the order bob finds when he comes back to it.
await bob.reload()
await expect(items.first()).toContainText("Quiet Corner") await expect(items.first()).toContainText("Quiet Corner")
await bob.goto(spacePath(other.url) + "/recent") await bob.goto(spacePath(other.url) + "/recent")

View file

@ -11,6 +11,10 @@ const deviceNames: Record<string, string> = {
const device = devices[deviceNames[process.env.E2E_BROWSER ?? "chromium"]] const device = devices[deviceNames[process.env.E2E_BROWSER ?? "chromium"]]
// vite.config.ts's port, and what the harness lets past its block-all. Overridable so a run can
// stand up its own dev server next to one that is already holding the default port.
const port = process.env.E2E_PORT ?? "1847"
export default defineConfig({ export default defineConfig({
testDir: "e2e/specs", testDir: "e2e/specs",
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
@ -31,15 +35,15 @@ export default defineConfig({
// has to say so on the terminal, where the person who started it is looking. // has to say so on the terminal, where the person who started it is looking.
reporter: [["list"], ["html"]], reporter: [["list"], ["html"]],
use: { use: {
baseURL: "http://localhost:1847", baseURL: `http://localhost:${port}`,
trace: "on-first-retry", trace: "on-first-retry",
}, },
// Boots the SvelteKit dev server before the suite and reuses one if already running locally. The // Boots the SvelteKit dev server before the suite and reuses one if already running locally. The
// app resolves its VITE_ values against a key the harness injects per browser context, so any // app resolves its VITE_ values against a key the harness injects per browser context, so any
// dev server will do. // dev server will do.
webServer: { webServer: {
command: "pnpm dev", command: `pnpm dev --port ${port}`,
url: "http://localhost:1847", url: `http://localhost:${port}`,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000, timeout: 120_000,
}, },

View file

@ -38,7 +38,7 @@
</script> </script>
{#if detail} {#if detail}
<div class="flex w-full min-w-0 flex-col gap-3"> <div data-component="ArticleActions" class="flex w-full min-w-0 flex-col gap-3">
<ThunkStatusOrDeleted {event} {context}> <ThunkStatusOrDeleted {event} {context}>
<ReactionSummary <ReactionSummary
{url} {url}

View file

@ -55,7 +55,7 @@
<Button class="button button-neutral button-xs" onclick={ignoreMute}>Show anyway</Button> <Button class="button button-neutral button-xs" onclick={ignoreMute}>Show anyway</Button>
</div> </div>
{:else} {:else}
<div class="flex min-w-0 gap-3 py-3"> <div data-component="Comment" class="flex min-w-0 gap-3 py-3">
<Button onclick={openProfile} class="shrink-0 self-start"> <Button onclick={openProfile} class="shrink-0 self-start">
<ProfileCircle pubkey={node.comment.pubkey} {url} size={8} /> <ProfileCircle pubkey={node.comment.pubkey} {url} size={8} />
</Button> </Button>
@ -95,6 +95,7 @@
<!-- The thread line runs under the avatar's center and indents replies to line up with <!-- The thread line runs under the avatar's center and indents replies to line up with
this comment's text column; it brightens while the subtree is hovered. --> this comment's text column; it brightens while the subtree is hovered. -->
<div <div
data-component="CommentReplies"
class="border-line-less hover:border-line ml-4 flex flex-col border-l pl-7 transition-colors"> class="border-line-less hover:border-line ml-4 flex flex-col border-l pl-7 transition-colors">
{#each node.children as child (child.comment.id)} {#each node.children as child (child.comment.id)}
<CommentTree node={child} {root} {replyTo} {setReplyTo} {url} {context} /> <CommentTree node={child} {root} {replyTo} {setReplyTo} {url} {context} />

View file

@ -94,14 +94,17 @@
if (!h) return if (!h) return
const pins = isPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id] // The optimistic write flips isPinned while the publish is in flight, so what this did is read
// before it goes out rather than after.
const wasPinned = isPinned
const pins = wasPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id]
const command = await $roomPinLists.setPins(url, h, pins) const command = await $roomPinLists.setPins(url, h, pins)
const error = await command.publishToRelays([url]).waitForError() const error = await command.publishToRelays([url]).waitForError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
} else { } else {
pushToast({message: isPinned ? "Message unpinned" : "Message pinned"}) pushToast({message: wasPinned ? "Message unpinned" : "Message pinned"})
} }
} }
</script> </script>

View file

@ -96,14 +96,17 @@
history.back() history.back()
const pins = isPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id] // The optimistic write flips isPinned while the publish is in flight, so what this did is read
// before it goes out rather than after.
const wasPinned = isPinned
const pins = wasPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id]
const command = await $roomPinLists.setPins(url, h, pins) const command = await $roomPinLists.setPins(url, h, pins)
const error = await command.publishToRelays([url]).waitForError() const error = await command.publishToRelays([url]).waitForError()
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
} else { } else {
pushToast({message: isPinned ? "Message unpinned" : "Message pinned"}) pushToast({message: wasPinned ? "Message unpinned" : "Message pinned"})
} }
} }

View file

@ -20,9 +20,11 @@
</label> </label>
{/if} {/if}
{#if secondary} {#if secondary}
<label class="flex items-center gap-2"> <!-- Not a label: what goes here is a control of its own, and a button wrapped in a label
loses its accessible name. -->
<div class="flex items-center gap-2">
{@render secondary()} {@render secondary()}
</label> </div>
{/if} {/if}
</div> </div>
{@render input?.()} {@render input?.()}

View file

@ -55,6 +55,25 @@
let loading = $state(false) let loading = $state(false)
let mutedPubkeys = $state($userMuteList?.pubkeys() ?? []) let mutedPubkeys = $state($userMuteList?.pubkeys() ?? [])
let blossomServers = $state($userBlossomServerList?.urls() ?? []) let blossomServers = $state($userBlossomServerList?.urls() ?? [])
let loadedMutes = Boolean($userMuteList)
let loadedServers = Boolean($userBlossomServerList)
// Both lists come off the wire, so on a fresh load they land after this page has mounted. Each
// field takes its stored value up when it arrives, since a form that never saw it would publish
// its own emptiness back over it.
$effect(() => {
if (!loadedMutes && $userMuteList) {
loadedMutes = true
mutedPubkeys = $userMuteList.pubkeys()
}
})
$effect(() => {
if (!loadedServers && $userBlossomServerList) {
loadedServers = true
blossomServers = $userBlossomServerList.urls()
}
})
</script> </script>
<form {onsubmit}> <form {onsubmit}>