diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 4ec2bc82..1d28cfc0 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -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 diff --git a/e2e/harness/net/http.ts b/e2e/harness/net/http.ts index 1f623b4c..2a9b2f82 100644 --- a/e2e/harness/net/http.ts +++ b/e2e/harness/net/http.ts @@ -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 diff --git a/e2e/specs/articles-threads.spec.ts b/e2e/specs/articles-threads.spec.ts index 631d4ca2..e57e3f93 100644 --- a/e2e/specs/articles-threads.spec.ts +++ b/e2e/specs/articles-threads.spec.ts @@ -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() diff --git a/e2e/specs/composer.spec.ts b/e2e/specs/composer.spec.ts index e4901a1b..39894f7f 100644 --- a/e2e/specs/composer.spec.ts +++ b/e2e/specs/composer.spec.ts @@ -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() diff --git a/e2e/specs/delivery.spec.ts b/e2e/specs/delivery.spec.ts index 8570b408..7c4ecb48 100644 --- a/e2e/specs/delivery.spec.ts +++ b/e2e/specs/delivery.spec.ts @@ -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}) => { diff --git a/e2e/specs/dms.spec.ts b/e2e/specs/dms.spec.ts index 2ce8abbc..f9ef7538 100644 --- a/e2e/specs/dms.spec.ts +++ b/e2e/specs/dms.spec.ts @@ -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}) diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index e7e0d7fd..7276943c 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -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}) => { diff --git a/e2e/specs/onboarding.spec.ts b/e2e/specs/onboarding.spec.ts index 3ac9ecc2..9b791f89 100644 --- a/e2e/specs/onboarding.spec.ts +++ b/e2e/specs/onboarding.spec.ts @@ -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) }) diff --git a/e2e/specs/people.spec.ts b/e2e/specs/people.spec.ts index 93ef5b93..fddefc76 100644 --- a/e2e/specs/people.spec.ts +++ b/e2e/specs/people.spec.ts @@ -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") diff --git a/e2e/specs/rooms.spec.ts b/e2e/specs/rooms.spec.ts index 0bf2d4f9..ee4ee578 100644 --- a/e2e/specs/rooms.spec.ts +++ b/e2e/specs/rooms.spec.ts @@ -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) }) diff --git a/e2e/specs/settings.spec.ts b/e2e/specs/settings.spec.ts index 00e3a85a..afd56d66 100644 --- a/e2e/specs/settings.spec.ts +++ b/e2e/specs/settings.spec.ts @@ -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}) => { diff --git a/e2e/specs/space.spec.ts b/e2e/specs/space.spec.ts index 5e115991..54ea5d3c 100644 --- a/e2e/specs/space.spec.ts +++ b/e2e/specs/space.spec.ts @@ -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() diff --git a/e2e/specs/spaces.spec.ts b/e2e/specs/spaces.spec.ts index 83139fb7..4f133cb4 100644 --- a/e2e/specs/spaces.spec.ts +++ b/e2e/specs/spaces.spec.ts @@ -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") diff --git a/playwright.config.ts b/playwright.config.ts index 5a83b315..66769b91 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,6 +11,10 @@ const deviceNames: Record = { 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, }, diff --git a/src/app/components/ArticleActions.svelte b/src/app/components/ArticleActions.svelte index 3c59e64a..905b8805 100644 --- a/src/app/components/ArticleActions.svelte +++ b/src/app/components/ArticleActions.svelte @@ -38,7 +38,7 @@ {#if detail} -
+
Show anyway
{:else} -
+
@@ -95,6 +95,7 @@
{#each node.children as child (child.comment.id)} diff --git a/src/app/components/RoomItemMenu.svelte b/src/app/components/RoomItemMenu.svelte index 50ff4529..e1c378bc 100644 --- a/src/app/components/RoomItemMenu.svelte +++ b/src/app/components/RoomItemMenu.svelte @@ -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"}) } } diff --git a/src/app/components/RoomItemMenuMobile.svelte b/src/app/components/RoomItemMenuMobile.svelte index 0ca0cbc5..0437d0c5 100644 --- a/src/app/components/RoomItemMenuMobile.svelte +++ b/src/app/components/RoomItemMenuMobile.svelte @@ -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"}) } } diff --git a/src/lib/components/Field.svelte b/src/lib/components/Field.svelte index 1b3e7e44..0433bef7 100644 --- a/src/lib/components/Field.svelte +++ b/src/lib/components/Field.svelte @@ -20,9 +20,11 @@ {/if} {#if secondary} -
{@render input?.()} diff --git a/src/routes/settings/content/+page.svelte b/src/routes/settings/content/+page.svelte index f7bd6510..9b1c0064 100644 --- a/src/routes/settings/content/+page.svelte +++ b/src/routes/settings/content/+page.svelte @@ -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() + } + })