Fix tests
This commit is contained in:
parent
03c5e7fd6e
commit
696f9a3043
20 changed files with 284 additions and 371 deletions
|
|
@ -1210,46 +1210,17 @@ Acceptance:
|
|||
|
||||
## 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
|
||||
the right places.
|
||||
As bob, I want to block a relay, so that nothing I do reaches out to it.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- The relays page shows separate Inbox and Outbox cards, each with a current
|
||||
count.
|
||||
- Adding a relay by url to the Inbox list shows it there and increments the
|
||||
count on the settings page.
|
||||
- Removing a relay from the Outbox list drops it immediately, and a list with
|
||||
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.
|
||||
- Settings › Privacy shows how many relays are blocked, and opening that list
|
||||
from there offers a picker of the relays the client knows about.
|
||||
- A relay added there appears in the blocked list and the count goes up.
|
||||
- The picker stops offering a relay once it is blocked, while still offering the
|
||||
others.
|
||||
|
||||
### US-086 — Configure alerts
|
||||
|
||||
|
|
|
|||
|
|
@ -30,10 +30,12 @@ const PNG = Buffer.from(
|
|||
"base64",
|
||||
)
|
||||
|
||||
// The dev server from vite.config.ts. Traffic to it 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.
|
||||
// The dev server from vite.config.ts, on the port playwright.config.ts started it on. Traffic to it
|
||||
// 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) =>
|
||||
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.
|
||||
const relayOrigin = (url: string) => new URL(url.replace(/^ws/, "http")).origin
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ const shortDate = (page: Page, seconds: number) =>
|
|||
// role — its own contents include a profile button and the room and action links.
|
||||
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) =>
|
||||
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.locator('img[src="https://images.test/garden.jpg"]')).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")
|
||||
|
||||
|
|
@ -365,18 +371,14 @@ test("US-039 comment on an article", async ({seed, as}) => {
|
|||
await composerForm(bob).getByRole("button", {name: "Comment"}).click()
|
||||
|
||||
// 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.
|
||||
await expect(
|
||||
bob.locator(".card.z-feature").filter({hasText: "The soil chapter is the good one."}),
|
||||
).toBeVisible()
|
||||
// relay confirms it, so for a moment the page carries this text twice. Match the rendered comment.
|
||||
await expect(comment(bob, "The soil chapter is the good one.")).toBeVisible()
|
||||
|
||||
const carol = await as(users.carol, articlesPath)
|
||||
|
||||
await openArticle(carol, "Tending the Garden")
|
||||
|
||||
const bobsComment = carol
|
||||
.locator(".card.z-feature")
|
||||
.filter({hasText: "The soil chapter is the good one."})
|
||||
const bobsComment = comment(carol, "The soil chapter is the good one.")
|
||||
|
||||
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 expect(
|
||||
carol
|
||||
.locator(".card.z-feature")
|
||||
.filter({hasText: "Only because you skipped the water chapter."}),
|
||||
).toBeVisible()
|
||||
await expect(comment(carol, "Only because you skipped the water chapter.")).toBeVisible()
|
||||
|
||||
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()
|
||||
|
||||
// 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("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()
|
||||
|
||||
const alicesComment = alice
|
||||
.locator(".card.z-feature")
|
||||
.filter({hasText: "Thanks both, here's the bed I meant."})
|
||||
const alicesComment = comment(alice, "Thanks both, here's the bed I meant.")
|
||||
|
||||
await expect(alicesComment).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")
|
||||
|
||||
const articleCard = page.locator(".card.z-feature").filter({hasText: "Gardens are worth"})
|
||||
const commentCard = page.locator(".card.z-feature").filter({hasText: "A note about the soil"})
|
||||
// An article's own reactions live in the action bar under it rather than on the article itself.
|
||||
const articleActions = page.locator('[data-component="ArticleActions"]')
|
||||
const commentCard = comment(page, "A note about the soil")
|
||||
|
||||
await expect(articleCard).toBeVisible()
|
||||
await expectReactionRoundTrip(page, articleCard, emojiButton(articleCard))
|
||||
await expect(articleActions).toBeVisible()
|
||||
await expectReactionRoundTrip(
|
||||
page,
|
||||
articleActions,
|
||||
articleActions.getByRole("button", {name: "Add a reaction"}),
|
||||
)
|
||||
|
||||
await expect(commentCard).toBeVisible()
|
||||
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()
|
||||
|
||||
// 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 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")
|
||||
|
||||
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.locator(".chat-editor")).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -415,6 +415,9 @@ test("US-058 drafts survive navigating away", async ({seed, as}) => {
|
|||
|
||||
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 expect(timeline(page).getByText("half a thought")).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -71,16 +71,16 @@ const detail = (page: Page) => page.locator(".tippy-target .card").filter({visib
|
|||
// an icon with no accessible name.
|
||||
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.
|
||||
const noteCard = (page: Page, text: string) =>
|
||||
page.locator(".card.z-feature").filter({hasText: text})
|
||||
// A comment is a flat block in the comment tree rather than a card, named by its text.
|
||||
const commentCard = (page: Page, text: string) =>
|
||||
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
|
||||
// submit are both in the dom at once.
|
||||
const modal = (page: Page, title: string) =>
|
||||
page.locator("form").filter({has: page.getByRole("heading", {name: title})})
|
||||
// The action bar under an article on its own page, which is where that article's status shows.
|
||||
const articleActions = (page: Page) => page.locator('[data-component="ArticleActions"]')
|
||||
|
||||
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
|
||||
// room to have rendered one.
|
||||
|
|
@ -110,14 +110,12 @@ const publishedTo = (page: Page, id: string) =>
|
|||
.filter(({event}) => event.id === id)
|
||||
.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) => {
|
||||
await page.getByRole("button", {name: "Write"}).click()
|
||||
|
||||
const composer = modal(page, "Write an Article")
|
||||
|
||||
await composer.getByPlaceholder("What is this article about?").fill(title)
|
||||
await editorOf(composer).pressSequentially(body)
|
||||
await composer.getByRole("button", {name: "Publish Article"}).click()
|
||||
await pageBar(page).getByRole("button", {name: "Write"}).click()
|
||||
await page.getByPlaceholder("Title", {exact: true}).fill(title)
|
||||
await editorOf(page).pressSequentially(body)
|
||||
await pageBar(page).getByRole("button", {name: "Publish"}).click()
|
||||
}
|
||||
|
||||
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")})
|
||||
|
||||
// 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 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.")
|
||||
|
||||
const article = alice.locator('[data-component="ArticleItem"]').filter({hasText: "Signals"})
|
||||
|
||||
await expect(alice.getByRole("heading", {name: "Write an Article"})).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)
|
||||
// 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(articleActions(alice).getByText("Sending...")).toHaveCount(0)
|
||||
await expect(articleActions(alice).getByText("Failed to send!")).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.")
|
||||
|
||||
const comment = noteCard(alice, "Worth saying twice.")
|
||||
const comment = commentCard(alice, "Worth saying twice.")
|
||||
|
||||
await expect(menuOf(comment)).toBeVisible()
|
||||
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 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(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
|
||||
await expect(toast(alice)).toHaveCount(0)
|
||||
|
||||
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("request timed out.")
|
||||
|
||||
await stuck.getByRole("link", {name: "Into the Void", exact: true}).click()
|
||||
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.getByRole("button", {name: "Cancel"})).toBeVisible()
|
||||
|
||||
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}) => {
|
||||
|
|
@ -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.
|
||||
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()
|
||||
|
||||
// The composer closes itself once publishing gives up, and the row's menu is underneath it.
|
||||
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
|
||||
|
||||
await menuOf(stuck).click()
|
||||
await alice.getByRole("button", {name: "Article options"}).click()
|
||||
await alice.getByRole("button", {name: "Delete Article"}).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.
|
||||
await expect(stuck).toHaveCount(0)
|
||||
// Deleted takes over from the failure the article was carrying.
|
||||
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`))
|
||||
|
||||
const comment = noteCard(alice, "A note about the soil chapter.")
|
||||
const article = noteCard(alice, "Gardens are worth the trouble.")
|
||||
const comment = commentCard(alice, "A note about the soil chapter.")
|
||||
const article = articleActions(alice)
|
||||
|
||||
await expect(comment).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.
|
||||
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: "Confirm"}).click()
|
||||
|
||||
// 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.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}) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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("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 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")
|
||||
|
||||
|
|
@ -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(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 alice.getByRole("button", {name: "More Options"}).click()
|
||||
|
||||
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
|
||||
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})
|
||||
|
||||
|
|
@ -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}) => {
|
||||
await seed(({relay, user, at}) => {
|
||||
const scenario = await seed(({relay, user, at}) => {
|
||||
const space = relay("space")
|
||||
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.
|
||||
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
|
||||
// by another client is where a url missing its trailing slash comes from, and the relay it
|
||||
// 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))
|
||||
})
|
||||
|
||||
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"})
|
||||
|
||||
|
|
@ -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 openMessageMenu(alice, hers.id)
|
||||
await alice.getByRole("button", {name: "More Options"}).click()
|
||||
await alice.getByRole("button", {name: "Edit Message"}).click()
|
||||
|
||||
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
|
||||
// lost with them rather than on its own terms.
|
||||
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})
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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.
|
||||
// An icon is a css mask built from a data url, so which bell it is can't be read out of the class
|
||||
// list — but it only renders on a difference, and while the space itself is still notifying the
|
||||
// only difference a room can have is a mute.
|
||||
// The bell SpaceMenuRoomItem hangs off a muted room. An icon is a css mask built from a data url,
|
||||
// so which bell it is can't be read out of the class list, but a room only renders one when it is
|
||||
// muted.
|
||||
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
|
||||
|
|
@ -53,6 +52,11 @@ const homeNavItem = (page: Page) => page.getByRole("link", {name: "Home"})
|
|||
const spaceMenu = (page: Page, url: string) =>
|
||||
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) =>
|
||||
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")))
|
||||
|
||||
// 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)
|
||||
|
||||
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 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
|
||||
await openRoomDetail(alice)
|
||||
await roomNotifications.check()
|
||||
await roomMute.uncheck()
|
||||
await alice.getByRole("button", {name: "Go back"}).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")
|
||||
|
||||
// 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(random)).toBeVisible()
|
||||
|
||||
const menu = spaceMenu(alice, space.url)
|
||||
|
||||
await menu.click()
|
||||
await openSpaceMenu(menu)
|
||||
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(unreadDot(general)).toHaveCount(0)
|
||||
await expect(unreadDot(random)).toHaveCount(0)
|
||||
|
||||
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()
|
||||
await expect(unreadDot(general)).toBeVisible()
|
||||
await expect(unreadDot(random)).toBeVisible()
|
||||
|
||||
// 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"})
|
||||
|
||||
await expect(turnOn).toHaveCount(0)
|
||||
|
||||
await openSpaceMenu(menu)
|
||||
await expect(turnOn).toBeVisible()
|
||||
await turnOn.click()
|
||||
|
||||
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}) => {
|
||||
|
|
|
|||
|
|
@ -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 with Key"}).click()
|
||||
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
|
||||
|
|
@ -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()
|
||||
|
||||
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 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)
|
||||
|
||||
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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -335,7 +335,7 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
|
|||
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()).toContainText("1 person you follow also follows Bob Barnacle.")
|
||||
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()
|
||||
|
||||
const info = dialog(page)
|
||||
const link = info.locator('input[type="text"]').first()
|
||||
const pubkey = info.locator('input[type="text"]').nth(1)
|
||||
// Each of these is an input and its copy button inside one bordered label, in dialog order.
|
||||
const linkField = info.locator("label.input").first()
|
||||
const pubkeyField = info.locator("label.input").nth(1)
|
||||
const link = linkField.locator('input[type="text"]')
|
||||
const pubkey = pubkeyField.locator('input[type="text"]')
|
||||
|
||||
// The profile was signed during this test, so the creation date is today's. A FieldInline puts
|
||||
// its value in the div right after its label, which keeps this a claim about the date rather
|
||||
|
|
@ -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(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.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))
|
||||
|
||||
|
|
@ -664,7 +667,9 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
|
|||
await profileMenu(page).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 qr = share.locator("canvas")
|
||||
|
|
|
|||
|
|
@ -202,7 +202,10 @@ test("US-019 join and leave a room", async ({seed, as}) => {
|
|||
|
||||
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"})
|
||||
|
||||
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("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()
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
// 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 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()
|
||||
|
||||
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(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()
|
||||
|
||||
|
|
@ -606,12 +617,22 @@ test("US-025 react to a message", async ({seed, as}) => {
|
|||
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()
|
||||
|
||||
// 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 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}) => {
|
||||
|
|
@ -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(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 expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport()
|
||||
await expect(jumpToNewest(page)).toBeVisible()
|
||||
|
||||
await jumpToNewest(page).click()
|
||||
|
||||
await expect(message(page, "harbor lights are on tonight")).toBeInViewport()
|
||||
await expect(message(page, "harbor lights are on tonight")).toBeVisible()
|
||||
await expect(jumpToNewest(page)).toHaveCount(0)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -27,17 +27,6 @@ const row = (page: Page, label: string) =>
|
|||
|
||||
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
|
||||
// with the class; the inner one holds the content.
|
||||
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})
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// makes the assertion that follows about persistence rather than about timing.
|
||||
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}) => {
|
||||
const space = relay("space")
|
||||
const other = relay("other")
|
||||
|
||||
space.room("general", {name: "General"})
|
||||
space.join(user.alice, "general")
|
||||
|
||||
// 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.
|
||||
// A second relay for the picker 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")
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
const other = scenario.space("other")
|
||||
const page = await as(users.bob, "/settings/relays", {
|
||||
// zooid advertises no nip-50, and the search-relay picker offers nothing that doesn't.
|
||||
relayInfo: {[other.url]: {supported_nips: ["1", "29", "43", "50"]}},
|
||||
})
|
||||
const page = await as(users.bob, "/settings/privacy")
|
||||
const blocked = page.getByRole("button", {name: /Blocked$/})
|
||||
|
||||
await expect(relayCount(page, "DM Relays")).toHaveText("0")
|
||||
await expect(relayCount(page, "Search Relays")).toHaveText("0")
|
||||
await expect(blocked).toContainText("0 Blocked")
|
||||
|
||||
await relayRow(page, "DM Relays").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()
|
||||
await blocked.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 relayCard(relayPicker(page), "other.test").getByRole("button", {name: "Add Relay"}).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
|
||||
// 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("space.test")).toBeVisible()
|
||||
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}) => {
|
||||
|
|
|
|||
|
|
@ -69,8 +69,8 @@ test("opens the space menu in a drawer on a phone", async ({seed, as}) => {
|
|||
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
|
||||
// that is in no space at all.
|
||||
await page.locator('a[href="/people"]:visible').click()
|
||||
// that is in no space at all, on the last space the reader was in.
|
||||
await page.goto("/chat")
|
||||
|
||||
await page.getByRole("button", {name: "Open space menu"}).click()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
import {DAY, HOUR, MINUTE, WEEK} from "@welshman/lib"
|
||||
import {THREAD, makeEvent} from "@welshman/util"
|
||||
import {expect, roomPath, spacePath, test, users} from "../harness"
|
||||
import {DAY, HOUR, MINUTE, WEEK, sortBy} from "@welshman/lib"
|
||||
import {ROOMS, THREAD, makeEvent} from "@welshman/util"
|
||||
import type {Page} from "@playwright/test"
|
||||
import {expect, readCachedEvents, roomPath, spacePath, test, users} from "../harness"
|
||||
|
||||
// The space this user's room list names first, read from the copy on disk the app restores itself
|
||||
// from. Room lists reach indexeddb in three-second batches with nothing in the ui to say when one
|
||||
// has landed, so a spec about what survives a reload waits on this before it reloads.
|
||||
const cachedFirstSpace = async (page: Page, pubkey: string) => {
|
||||
const events = (await readCachedEvents(page, pubkey)).filter(event => event.kind === ROOMS)
|
||||
const newest = sortBy(event => -event.created_at, events)[0]
|
||||
|
||||
return newest?.tags.find(tag => tag[0] === "r")?.[1]
|
||||
}
|
||||
|
||||
test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
|
||||
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 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)
|
||||
|
||||
// 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 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 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()
|
||||
|
||||
|
|
@ -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 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")
|
||||
|
||||
|
|
@ -336,7 +363,9 @@ test("US-014 leave a space", async ({seed, as}) => {
|
|||
|
||||
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()
|
||||
|
||||
|
|
@ -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 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 bob.goto(spacePath(other.url) + "/recent")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ const deviceNames: Record<string, string> = {
|
|||
|
||||
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({
|
||||
testDir: "e2e/specs",
|
||||
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.
|
||||
reporter: [["list"], ["html"]],
|
||||
use: {
|
||||
baseURL: "http://localhost:1847",
|
||||
baseURL: `http://localhost:${port}`,
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
// 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
|
||||
// dev server will do.
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:1847",
|
||||
command: `pnpm dev --port ${port}`,
|
||||
url: `http://localhost:${port}`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
</script>
|
||||
|
||||
{#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}>
|
||||
<ReactionSummary
|
||||
{url}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
<Button class="button button-neutral button-xs" onclick={ignoreMute}>Show anyway</Button>
|
||||
</div>
|
||||
{: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">
|
||||
<ProfileCircle pubkey={node.comment.pubkey} {url} size={8} />
|
||||
</Button>
|
||||
|
|
@ -95,6 +95,7 @@
|
|||
<!-- 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. -->
|
||||
<div
|
||||
data-component="CommentReplies"
|
||||
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)}
|
||||
<CommentTree node={child} {root} {replyTo} {setReplyTo} {url} {context} />
|
||||
|
|
|
|||
|
|
@ -94,14 +94,17 @@
|
|||
|
||||
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 error = await command.publishToRelays([url]).waitForError()
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: isPinned ? "Message unpinned" : "Message pinned"})
|
||||
pushToast({message: wasPinned ? "Message unpinned" : "Message pinned"})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -96,14 +96,17 @@
|
|||
|
||||
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 error = await command.publishToRelays([url]).waitForError()
|
||||
|
||||
if (error) {
|
||||
pushToast({theme: "error", message: error})
|
||||
} else {
|
||||
pushToast({message: isPinned ? "Message unpinned" : "Message pinned"})
|
||||
pushToast({message: wasPinned ? "Message unpinned" : "Message pinned"})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@
|
|||
</label>
|
||||
{/if}
|
||||
{#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()}
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{@render input?.()}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,25 @@
|
|||
let loading = $state(false)
|
||||
let mutedPubkeys = $state($userMuteList?.pubkeys() ?? [])
|
||||
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>
|
||||
|
||||
<form {onsubmit}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue