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
|
## Settings & preferences
|
||||||
|
|
||||||
### US-083 — Manage inbox and outbox relays
|
### US-084 — Block a relay you never want used
|
||||||
|
|
||||||
As alice, I want to choose where I read and publish, so that my messages reach
|
As bob, I want to block a relay, so that nothing I do reaches out to it.
|
||||||
the right places.
|
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
- The relays page shows separate Inbox and Outbox cards, each with a current
|
- Settings › Privacy shows how many relays are blocked, and opening that list
|
||||||
count.
|
from there offers a picker of the relays the client knows about.
|
||||||
- Adding a relay by url to the Inbox list shows it there and increments the
|
- A relay added there appears in the blocked list and the count goes up.
|
||||||
count on the settings page.
|
- The picker stops offering a relay once it is blocked, while still offering the
|
||||||
- Removing a relay from the Outbox list drops it immediately, and a list with
|
others.
|
||||||
only one relay shows a warning icon rather than a check.
|
|
||||||
|
|
||||||
### US-084 — Manage DM, search, and blocked relays
|
|
||||||
|
|
||||||
As bob, I want separate relay lists for messaging, search, and relays I never
|
|
||||||
want used, so that each feature uses relays suited to it.
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
|
|
||||||
- A relay added to DM Relays appears there and not in Search Relays.
|
|
||||||
- The search-relay picker only offers relays advertising NIP-50 search support,
|
|
||||||
and removing one updates the count on the relays page.
|
|
||||||
- A relay added to Blocked Relays appears in that list and stops being offered
|
|
||||||
as a suggestion for the others.
|
|
||||||
|
|
||||||
### US-085 — Fix relay misconfiguration from the health check
|
|
||||||
|
|
||||||
As a user whose relay lists are wrong, I want the app to detect and fix it, so
|
|
||||||
that I don't have to know the specifics.
|
|
||||||
|
|
||||||
Acceptance:
|
|
||||||
|
|
||||||
- With only one outbox relay, the relays page shows a health check reporting
|
|
||||||
"Missing Outbox Relays".
|
|
||||||
- Applying that recommendation publishes a new relay list and the issue leaves
|
|
||||||
the pending list.
|
|
||||||
- "Apply All Recommendations" clears multiple issues at once, after which the
|
|
||||||
card shows an all-clear state.
|
|
||||||
|
|
||||||
### US-086 — Configure alerts
|
### US-086 — Configure alerts
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,12 @@ const PNG = Buffer.from(
|
||||||
"base64",
|
"base64",
|
||||||
)
|
)
|
||||||
|
|
||||||
// The dev server from vite.config.ts. Traffic to it is the app loading itself rather than egress,
|
// The dev server from vite.config.ts, on the port playwright.config.ts started it on. Traffic to it
|
||||||
// so it is the one host both layers here let past, websockets included for Vite's hmr socket.
|
// is the app loading itself rather than egress, so it is the one host both layers here let past,
|
||||||
|
// websockets included for Vite's hmr socket.
|
||||||
export const isDevServerUrl = (url: URL) =>
|
export const isDevServerUrl = (url: URL) =>
|
||||||
url.port === "1847" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
url.port === (process.env.E2E_PORT ?? "1847") &&
|
||||||
|
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
||||||
|
|
||||||
// A relay is reached over wss and its own http origin is where nip-11 and nip-86 live.
|
// A relay is reached over wss and its own http origin is where nip-11 and nip-86 live.
|
||||||
const relayOrigin = (url: string) => new URL(url.replace(/^ws/, "http")).origin
|
const relayOrigin = (url: string) => new URL(url.replace(/^ws/, "http")).origin
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,11 @@ const shortDate = (page: Page, seconds: number) =>
|
||||||
// role — its own contents include a profile button and the room and action links.
|
// role — its own contents include a profile button and the room and action links.
|
||||||
const articleCards = (page: Page) => page.locator('[data-component="ArticleItem"]')
|
const articleCards = (page: Page) => page.locator('[data-component="ArticleItem"]')
|
||||||
|
|
||||||
|
// A comment is a flat block in the tree rather than a card, so it carries a component marker for
|
||||||
|
// the specs to name; the marker sits on the comment's own row, not on its replies.
|
||||||
|
const comment = (page: Page, text: string) =>
|
||||||
|
page.locator('[data-component="Comment"]').filter({hasText: text})
|
||||||
|
|
||||||
const openArticle = (page: Page, title: string) =>
|
const openArticle = (page: Page, title: string) =>
|
||||||
articleCards(page).filter({hasText: title}).getByRole("link", {name: title, exact: true}).click()
|
articleCards(page).filter({hasText: title}).getByRole("link", {name: title, exact: true}).click()
|
||||||
|
|
||||||
|
|
@ -316,7 +321,8 @@ test("US-038 browse, filter, and read articles", async ({seed, as}) => {
|
||||||
await expect(page.getByRole("heading", {name: "Tending the Garden"}).first()).toBeVisible()
|
await expect(page.getByRole("heading", {name: "Tending the Garden"}).first()).toBeVisible()
|
||||||
await expect(page.locator('img[src="https://images.test/garden.jpg"]')).toBeVisible()
|
await expect(page.locator('img[src="https://images.test/garden.jpg"]')).toBeVisible()
|
||||||
await expect(page.getByText("A short teaser about gardens.")).toBeVisible()
|
await expect(page.getByText("A short teaser about gardens.")).toBeVisible()
|
||||||
await expect(page.getByText(/^Published /)).toContainText(await shortDate(page, at(4, HOUR)))
|
// The byline pairs the published date with the reading time, so the header is what carries it.
|
||||||
|
await expect(page.locator("article header")).toContainText(await shortDate(page, at(4, HOUR)))
|
||||||
|
|
||||||
const markdown = page.locator(".content-markdown")
|
const markdown = page.locator(".content-markdown")
|
||||||
|
|
||||||
|
|
@ -365,18 +371,14 @@ test("US-039 comment on an article", async ({seed, as}) => {
|
||||||
await composerForm(bob).getByRole("button", {name: "Comment"}).click()
|
await composerForm(bob).getByRole("button", {name: "Comment"}).click()
|
||||||
|
|
||||||
// The comment renders from the optimistic write, but the composer holds what was typed until the
|
// The comment renders from the optimistic write, but the composer holds what was typed until the
|
||||||
// relay confirms it, so for a moment the page carries this text twice. Match the rendered card.
|
// relay confirms it, so for a moment the page carries this text twice. Match the rendered comment.
|
||||||
await expect(
|
await expect(comment(bob, "The soil chapter is the good one.")).toBeVisible()
|
||||||
bob.locator(".card.z-feature").filter({hasText: "The soil chapter is the good one."}),
|
|
||||||
).toBeVisible()
|
|
||||||
|
|
||||||
const carol = await as(users.carol, articlesPath)
|
const carol = await as(users.carol, articlesPath)
|
||||||
|
|
||||||
await openArticle(carol, "Tending the Garden")
|
await openArticle(carol, "Tending the Garden")
|
||||||
|
|
||||||
const bobsComment = carol
|
const bobsComment = comment(carol, "The soil chapter is the good one.")
|
||||||
.locator(".card.z-feature")
|
|
||||||
.filter({hasText: "The soil chapter is the good one."})
|
|
||||||
|
|
||||||
await expect(bobsComment).toBeVisible()
|
await expect(bobsComment).toBeVisible()
|
||||||
|
|
||||||
|
|
@ -386,11 +388,7 @@ test("US-039 comment on an article", async ({seed, as}) => {
|
||||||
)
|
)
|
||||||
await composerForm(carol).getByRole("button", {name: "Reply", exact: true}).click()
|
await composerForm(carol).getByRole("button", {name: "Reply", exact: true}).click()
|
||||||
|
|
||||||
await expect(
|
await expect(comment(carol, "Only because you skipped the water chapter.")).toBeVisible()
|
||||||
carol
|
|
||||||
.locator(".card.z-feature")
|
|
||||||
.filter({hasText: "Only because you skipped the water chapter."}),
|
|
||||||
).toBeVisible()
|
|
||||||
|
|
||||||
const alice = await as(users.alice, articlesPath)
|
const alice = await as(users.alice, articlesPath)
|
||||||
|
|
||||||
|
|
@ -401,7 +399,7 @@ test("US-039 comment on an article", async ({seed, as}) => {
|
||||||
await expect(alice.getByText("Only because you skipped the water chapter.")).toBeVisible()
|
await expect(alice.getByText("Only because you skipped the water chapter.")).toBeVisible()
|
||||||
|
|
||||||
// A reply is nested inside the comment it answers; a top level comment is not.
|
// A reply is nested inside the comment it answers; a top level comment is not.
|
||||||
const nested = alice.locator(".border-l.ml-4.pl-4")
|
const nested = alice.locator('[data-component="CommentReplies"]')
|
||||||
|
|
||||||
await expect(nested.getByText("Only because you skipped the water chapter.")).toBeVisible()
|
await expect(nested.getByText("Only because you skipped the water chapter.")).toBeVisible()
|
||||||
await expect(nested.getByText("The soil chapter is the good one.")).toHaveCount(0)
|
await expect(nested.getByText("The soil chapter is the good one.")).toHaveCount(0)
|
||||||
|
|
@ -424,9 +422,7 @@ test("US-039 comment on an article", async ({seed, as}) => {
|
||||||
|
|
||||||
await composer.getByRole("button", {name: "Comment"}).click()
|
await composer.getByRole("button", {name: "Comment"}).click()
|
||||||
|
|
||||||
const alicesComment = alice
|
const alicesComment = comment(alice, "Thanks both, here's the bed I meant.")
|
||||||
.locator(".card.z-feature")
|
|
||||||
.filter({hasText: "Thanks both, here's the bed I meant."})
|
|
||||||
|
|
||||||
await expect(alicesComment).toBeVisible()
|
await expect(alicesComment).toBeVisible()
|
||||||
await expect(alicesComment.locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
await expect(alicesComment.locator(`img[src^="${BLOSSOM_ORIGIN}/"]`)).toBeVisible()
|
||||||
|
|
@ -521,11 +517,16 @@ test("US-040 react to a post with an emoji", async ({seed, as}) => {
|
||||||
|
|
||||||
await openArticle(page, "Tending the Garden")
|
await openArticle(page, "Tending the Garden")
|
||||||
|
|
||||||
const articleCard = page.locator(".card.z-feature").filter({hasText: "Gardens are worth"})
|
// An article's own reactions live in the action bar under it rather than on the article itself.
|
||||||
const commentCard = page.locator(".card.z-feature").filter({hasText: "A note about the soil"})
|
const articleActions = page.locator('[data-component="ArticleActions"]')
|
||||||
|
const commentCard = comment(page, "A note about the soil")
|
||||||
|
|
||||||
await expect(articleCard).toBeVisible()
|
await expect(articleActions).toBeVisible()
|
||||||
await expectReactionRoundTrip(page, articleCard, emojiButton(articleCard))
|
await expectReactionRoundTrip(
|
||||||
|
page,
|
||||||
|
articleActions,
|
||||||
|
articleActions.getByRole("button", {name: "Add a reaction"}),
|
||||||
|
)
|
||||||
|
|
||||||
await expect(commentCard).toBeVisible()
|
await expect(commentCard).toBeVisible()
|
||||||
await expectReactionRoundTrip(page, commentCard, emojiButton(commentCard))
|
await expectReactionRoundTrip(page, commentCard, emojiButton(commentCard))
|
||||||
|
|
@ -570,7 +571,11 @@ test("US-041 publish an article from a room", async ({seed, as}) => {
|
||||||
|
|
||||||
await expect(page.getByRole("heading", {name: "Repotting in Winter"}).first()).toBeVisible()
|
await expect(page.getByRole("heading", {name: "Repotting in Winter"}).first()).toBeVisible()
|
||||||
|
|
||||||
// The room hears about the article without alice posting it a second time.
|
// The room hears about the article without alice posting it a second time. Its copy is published
|
||||||
|
// after the composer has moved on, so wait for it to leave — a page that unloads mid-publish
|
||||||
|
// takes it with it.
|
||||||
|
await expect.poll(() => publishedEvents(page, MESSAGE)).toHaveLength(1)
|
||||||
|
|
||||||
await page.goto(roomPath(url, "lounge"))
|
await page.goto(roomPath(url, "lounge"))
|
||||||
await expect(page.getByText("Repotting in Winter")).toBeVisible()
|
await expect(page.getByText("Repotting in Winter")).toBeVisible()
|
||||||
|
|
||||||
|
|
@ -583,11 +588,13 @@ test("US-041 publish an article from a room", async ({seed, as}) => {
|
||||||
|
|
||||||
await openArticle(page, "Repotting in Winter")
|
await openArticle(page, "Repotting in Winter")
|
||||||
|
|
||||||
const badge = page.getByRole("link", {name: /Posted in #\s*Lounge/})
|
// A card says which room an article was posted in; the article's own page carries that in its
|
||||||
|
// page bar instead.
|
||||||
|
const roomLink = pageBar(page).getByRole("link", {name: /#\s*Lounge/})
|
||||||
|
|
||||||
await expect(badge).toBeVisible()
|
await expect(roomLink).toBeVisible()
|
||||||
|
|
||||||
await badge.click()
|
await roomLink.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${roomPath(url, "lounge")}$`))
|
await expect(page).toHaveURL(new RegExp(`${roomPath(url, "lounge")}$`))
|
||||||
await expect(page.locator(".chat-editor")).toBeVisible()
|
await expect(page.locator(".chat-editor")).toBeVisible()
|
||||||
|
|
|
||||||
|
|
@ -415,6 +415,9 @@ test("US-058 drafts survive navigating away", async ({seed, as}) => {
|
||||||
|
|
||||||
await expect(editor).toHaveText("half a thought")
|
await expect(editor).toHaveText("half a thought")
|
||||||
|
|
||||||
|
// The composer is remounted around a restored draft, so put the caret in it before sending
|
||||||
|
// rather than typing into whatever had focus when the room came back.
|
||||||
|
await editor.click()
|
||||||
await editor.press("Enter")
|
await editor.press("Enter")
|
||||||
|
|
||||||
await expect(timeline(page).getByText("half a thought")).toBeVisible()
|
await expect(timeline(page).getByText("half a thought")).toBeVisible()
|
||||||
|
|
|
||||||
|
|
@ -71,16 +71,16 @@ const detail = (page: Page) => page.locator(".tippy-target .card").filter({visib
|
||||||
// an icon with no accessible name.
|
// an icon with no accessible name.
|
||||||
const menuOf = (scope: Locator) => scope.locator(".join").getByRole("button").last()
|
const menuOf = (scope: Locator) => scope.locator(".join").getByRole("button").last()
|
||||||
|
|
||||||
// A comment, an article and a thread post are all the same feature card, named by their text.
|
// A comment is a flat block in the comment tree rather than a card, named by its text.
|
||||||
const noteCard = (page: Page, text: string) =>
|
const commentCard = (page: Page, text: string) =>
|
||||||
page.locator(".card.z-feature").filter({hasText: text})
|
page.locator('[data-component="Comment"]').filter({hasText: text})
|
||||||
|
|
||||||
// A modal is mounted alongside the page it covers, so a page's own "Write" and the composer's
|
// The action bar under an article on its own page, which is where that article's status shows.
|
||||||
// submit are both in the dom at once.
|
const articleActions = (page: Page) => page.locator('[data-component="ArticleActions"]')
|
||||||
const modal = (page: Page, title: string) =>
|
|
||||||
page.locator("form").filter({has: page.getByRole("heading", {name: title})})
|
|
||||||
|
|
||||||
const editorOf = (scope: Locator) => scope.locator(".note-editor [contenteditable=true]")
|
const pageBar = (page: Page) => page.locator('[data-component="PageBar"]')
|
||||||
|
|
||||||
|
const editorOf = (scope: Locator | Page) => scope.locator(".note-editor [contenteditable=true]")
|
||||||
|
|
||||||
// Every caller of this sends to a room, whose composer gates on nothing, so the wait is for the
|
// Every caller of this sends to a room, whose composer gates on nothing, so the wait is for the
|
||||||
// room to have rendered one.
|
// room to have rendered one.
|
||||||
|
|
@ -110,14 +110,12 @@ const publishedTo = (page: Page, id: string) =>
|
||||||
.filter(({event}) => event.id === id)
|
.filter(({event}) => event.id === id)
|
||||||
.map(({url}) => url)
|
.map(({url}) => url)
|
||||||
|
|
||||||
|
// The composer is a page of its own, and it holds the reader there until publishing resolves.
|
||||||
const writeArticle = async (page: Page, title: string, body: string) => {
|
const writeArticle = async (page: Page, title: string, body: string) => {
|
||||||
await page.getByRole("button", {name: "Write"}).click()
|
await pageBar(page).getByRole("button", {name: "Write"}).click()
|
||||||
|
await page.getByPlaceholder("Title", {exact: true}).fill(title)
|
||||||
const composer = modal(page, "Write an Article")
|
await editorOf(page).pressSequentially(body)
|
||||||
|
await pageBar(page).getByRole("button", {name: "Publish"}).click()
|
||||||
await composer.getByPlaceholder("What is this article about?").fill(title)
|
|
||||||
await editorOf(composer).pressSequentially(body)
|
|
||||||
await composer.getByRole("button", {name: "Publish Article"}).click()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const writeComment = async (page: Page, body: string) => {
|
const writeComment = async (page: Page, body: string) => {
|
||||||
|
|
@ -125,6 +123,9 @@ const writeComment = async (page: Page, body: string) => {
|
||||||
|
|
||||||
const composer = page.locator("form").filter({has: page.locator(".note-editor")})
|
const composer = page.locator("form").filter({has: page.locator(".note-editor")})
|
||||||
|
|
||||||
|
// The editor takes focus itself once it has mounted, and typing into it before that puts the
|
||||||
|
// caret back at the start partway through the sentence.
|
||||||
|
await expect(editorOf(composer)).toBeFocused()
|
||||||
await editorOf(composer).pressSequentially(body)
|
await editorOf(composer).pressSequentially(body)
|
||||||
await composer.getByRole("button", {name: "Comment"}).click()
|
await composer.getByRole("button", {name: "Comment"}).click()
|
||||||
}
|
}
|
||||||
|
|
@ -393,21 +394,15 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
|
||||||
|
|
||||||
await writeArticle(alice, "Signals in the Noise", "Everything worth hearing is quiet.")
|
await writeArticle(alice, "Signals in the Noise", "Everything worth hearing is quiet.")
|
||||||
|
|
||||||
const article = alice.locator('[data-component="ArticleItem"]').filter({hasText: "Signals"})
|
// Publishing lands on the article's own page, where its status sits under the article itself.
|
||||||
|
await expect(alice.getByRole("heading", {name: "Signals in the Noise"}).first()).toBeVisible()
|
||||||
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
|
await expect(articleActions(alice).getByText("Sending...")).toHaveCount(0)
|
||||||
|
await expect(articleActions(alice).getByText("Failed to send!")).toHaveCount(0)
|
||||||
// The row's own actions first: a status this row does not have says nothing until the row it
|
|
||||||
// would sit in is on screen.
|
|
||||||
await expect(menuOf(article)).toBeVisible()
|
|
||||||
await expect(article.getByText("Sending...")).toHaveCount(0)
|
|
||||||
await expect(article.getByText("Failed to send!")).toHaveCount(0)
|
|
||||||
await expect(toast(alice)).toHaveCount(0)
|
await expect(toast(alice)).toHaveCount(0)
|
||||||
|
|
||||||
await article.getByRole("link", {name: "Signals in the Noise", exact: true}).click()
|
|
||||||
await writeComment(alice, "Worth saying twice.")
|
await writeComment(alice, "Worth saying twice.")
|
||||||
|
|
||||||
const comment = noteCard(alice, "Worth saying twice.")
|
const comment = commentCard(alice, "Worth saying twice.")
|
||||||
|
|
||||||
await expect(menuOf(comment)).toBeVisible()
|
await expect(menuOf(comment)).toBeVisible()
|
||||||
await expect(comment.getByText("Sending...")).toHaveCount(0)
|
await expect(comment.getByText("Sending...")).toHaveCount(0)
|
||||||
|
|
@ -418,14 +413,13 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
|
||||||
await alice.goto(`${spacePath(quiet)}/articles`)
|
await alice.goto(`${spacePath(quiet)}/articles`)
|
||||||
await writeArticle(alice, "Into the Void", "Nobody is listening.")
|
await writeArticle(alice, "Into the Void", "Nobody is listening.")
|
||||||
|
|
||||||
const stuck = alice.locator('[data-component="ArticleItem"]').filter({hasText: "Into the Void"})
|
// The composer holds the reader until publishing gives up, then lands on the article anyway.
|
||||||
|
// Nothing was refused, so there is no toast — the action bar under the article is what says the
|
||||||
|
// relay never answered.
|
||||||
|
const stuck = articleActions(alice)
|
||||||
|
|
||||||
await expect(stuck.getByText("Sending...")).toBeVisible()
|
|
||||||
await expect(stuck.getByRole("button", {name: "Cancel"})).toBeVisible()
|
|
||||||
|
|
||||||
// The composer closes itself once publishing gives up, and the row it left behind says why.
|
|
||||||
await expect(stuck.getByText("Failed to send!")).toBeVisible()
|
await expect(stuck.getByText("Failed to send!")).toBeVisible()
|
||||||
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
|
await expect(toast(alice)).toHaveCount(0)
|
||||||
|
|
||||||
await stuck.getByText("Failed to send!").click()
|
await stuck.getByText("Failed to send!").click()
|
||||||
|
|
||||||
|
|
@ -433,17 +427,16 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) =>
|
||||||
await expect(detail(alice)).toContainText("other.test")
|
await expect(detail(alice)).toContainText("other.test")
|
||||||
await expect(detail(alice)).toContainText("request timed out.")
|
await expect(detail(alice)).toContainText("request timed out.")
|
||||||
|
|
||||||
await stuck.getByRole("link", {name: "Into the Void", exact: true}).click()
|
|
||||||
await writeComment(alice, "A footnote nobody asked for.")
|
await writeComment(alice, "A footnote nobody asked for.")
|
||||||
|
|
||||||
const footnote = noteCard(alice, "A footnote nobody asked for.")
|
const footnote = commentCard(alice, "A footnote nobody asked for.")
|
||||||
|
|
||||||
await expect(footnote.getByText("Sending...")).toBeVisible()
|
await expect(footnote.getByText("Sending...")).toBeVisible()
|
||||||
await expect(footnote.getByRole("button", {name: "Cancel"})).toBeVisible()
|
await expect(footnote.getByRole("button", {name: "Cancel"})).toBeVisible()
|
||||||
|
|
||||||
await footnote.getByRole("button", {name: "Cancel"}).click()
|
await footnote.getByRole("button", {name: "Cancel"}).click()
|
||||||
|
|
||||||
await expect(noteCard(alice, "A footnote nobody asked for.")).toHaveCount(0)
|
await expect(commentCard(alice, "A footnote nobody asked for.")).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
|
test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
|
||||||
|
|
@ -503,24 +496,23 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
|
||||||
// A post whose relay never answered, deleted while it is still marked failed.
|
// A post whose relay never answered, deleted while it is still marked failed.
|
||||||
await writeArticle(alice, "Into the Void", "Nobody is listening.")
|
await writeArticle(alice, "Into the Void", "Nobody is listening.")
|
||||||
|
|
||||||
const stuck = alice.locator('[data-component="ArticleItem"]').filter({hasText: "Into the Void"})
|
// Publishing waits the relay out and lands on the article anyway, still marked failed.
|
||||||
|
const stuck = articleActions(alice)
|
||||||
|
|
||||||
await expect(stuck.getByText("Failed to send!")).toBeVisible()
|
await expect(stuck.getByText("Failed to send!")).toBeVisible()
|
||||||
|
|
||||||
// The composer closes itself once publishing gives up, and the row's menu is underneath it.
|
await alice.getByRole("button", {name: "Article options"}).click()
|
||||||
await expect(alice.getByRole("heading", {name: "Write an Article"})).toHaveCount(0)
|
|
||||||
|
|
||||||
await menuOf(stuck).click()
|
|
||||||
await alice.getByRole("button", {name: "Delete Article"}).click()
|
await alice.getByRole("button", {name: "Delete Article"}).click()
|
||||||
await alice.getByRole("button", {name: "Confirm"}).click()
|
await alice.getByRole("button", {name: "Confirm"}).click()
|
||||||
|
|
||||||
// A feed shows non-deleted posts only, so deleting it drops the row rather than marking it.
|
// Deleted takes over from the failure the article was carrying.
|
||||||
await expect(stuck).toHaveCount(0)
|
await expect(stuck.getByText("Deleted", {exact: true})).toBeVisible()
|
||||||
|
await expect(stuck.getByText("Failed to send!")).toHaveCount(0)
|
||||||
|
|
||||||
await alice.goto(articlePath(url, `${LONG_FORM}:${users.alice.pubkey}:tending-the-garden`))
|
await alice.goto(articlePath(url, `${LONG_FORM}:${users.alice.pubkey}:tending-the-garden`))
|
||||||
|
|
||||||
const comment = noteCard(alice, "A note about the soil chapter.")
|
const comment = commentCard(alice, "A note about the soil chapter.")
|
||||||
const article = noteCard(alice, "Gardens are worth the trouble.")
|
const article = articleActions(alice)
|
||||||
|
|
||||||
await expect(comment).toBeVisible()
|
await expect(comment).toBeVisible()
|
||||||
await expect(comment.getByRole("button", {name: /🎉/})).toBeVisible()
|
await expect(comment.getByRole("button", {name: /🎉/})).toBeVisible()
|
||||||
|
|
@ -532,14 +524,14 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => {
|
||||||
// The comment list is a feed too, so a deleted comment drops out of it.
|
// The comment list is a feed too, so a deleted comment drops out of it.
|
||||||
await expect(comment).toHaveCount(0)
|
await expect(comment).toHaveCount(0)
|
||||||
|
|
||||||
await menuOf(article).click()
|
await alice.getByRole("button", {name: "Article options"}).click()
|
||||||
await alice.getByRole("button", {name: "Delete Article"}).click()
|
await alice.getByRole("button", {name: "Delete Article"}).click()
|
||||||
await alice.getByRole("button", {name: "Confirm"}).click()
|
await alice.getByRole("button", {name: "Confirm"}).click()
|
||||||
|
|
||||||
// But this page is the article's own view, so it stays and is marked deleted rather than
|
// But this page is the article's own view, so it stays and is marked deleted rather than
|
||||||
// vanishing, and the "Deleted" pill stands in place of the actions the row offered before.
|
// vanishing, and the "Deleted" pill stands in place of the actions it offered before.
|
||||||
await expect(article.getByText("Deleted", {exact: true})).toBeVisible()
|
await expect(article.getByText("Deleted", {exact: true})).toBeVisible()
|
||||||
await expect(article.locator(".join")).toHaveCount(0)
|
await expect(article.getByRole("button", {name: "Add a reaction"})).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("US-073 a multi-part message reports one status", async ({seed, as}) => {
|
test("US-073 a multi-part message reports one status", async ({seed, as}) => {
|
||||||
|
|
|
||||||
|
|
@ -546,7 +546,7 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
|
||||||
|
|
||||||
// Reply: the preview appears above the composer, and closing it sends nothing
|
// Reply: the preview appears above the composer, and closing it sends nothing
|
||||||
await openMessageMenu(alice, his.id)
|
await openMessageMenu(alice, his.id)
|
||||||
await alice.getByRole("button", {name: "Send Reply"}).click()
|
await alice.getByRole("button", {name: "Reply"}).click()
|
||||||
|
|
||||||
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
|
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
|
||||||
await expect(composePreview(alice)).toContainText("did you see the thing?")
|
await expect(composePreview(alice)).toContainText("did you see the thing?")
|
||||||
|
|
@ -557,7 +557,7 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
|
||||||
await expect(alice.locator(".chat-bubble")).toHaveCount(2)
|
await expect(alice.locator(".chat-bubble")).toHaveCount(2)
|
||||||
|
|
||||||
await openMessageMenu(alice, his.id)
|
await openMessageMenu(alice, his.id)
|
||||||
await alice.getByRole("button", {name: "Send Reply"}).click()
|
await alice.getByRole("button", {name: "Reply"}).click()
|
||||||
|
|
||||||
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
|
await expect(composePreview(alice)).toContainText("Replying to @Bob Barnacle")
|
||||||
|
|
||||||
|
|
@ -567,8 +567,10 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
|
||||||
await expect(bubble(alice, "yes I did")).toContainText("did you see the thing?")
|
await expect(bubble(alice, "yes I did")).toContainText("did you see the thing?")
|
||||||
await expect(bubble(bob, "yes I did")).toContainText("did you see the thing?")
|
await expect(bubble(bob, "yes I did")).toContainText("did you see the thing?")
|
||||||
|
|
||||||
// Edit: her own message is replaced in place for both of them rather than duplicated
|
// Edit: her own message is replaced in place for both of them rather than duplicated. The menu
|
||||||
|
// leads with React and Reply and keeps the rest behind a disclosure.
|
||||||
await openMessageMenu(alice, hers.id)
|
await openMessageMenu(alice, hers.id)
|
||||||
|
await alice.getByRole("button", {name: "More Options"}).click()
|
||||||
|
|
||||||
await expect(alice.getByRole("button", {name: "Edit Message"})).toBeVisible()
|
await expect(alice.getByRole("button", {name: "Edit Message"})).toBeVisible()
|
||||||
|
|
||||||
|
|
@ -591,7 +593,7 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as})
|
||||||
|
|
||||||
// React, then take it back, on both sides of the conversation
|
// React, then take it back, on both sides of the conversation
|
||||||
await openMessageMenu(alice, his.id)
|
await openMessageMenu(alice, his.id)
|
||||||
await alice.getByRole("button", {name: "Send Reaction"}).click()
|
await alice.getByRole("button", {name: "React"}).click()
|
||||||
|
|
||||||
const picker = alice.locator("emoji-picker").filter({visible: true})
|
const picker = alice.locator("emoji-picker").filter({visible: true})
|
||||||
|
|
||||||
|
|
@ -661,7 +663,7 @@ test("US-036 receive a new conversation live", async ({seed, as}) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
test("US-108 read messages from a relay you only use for messages", async ({seed, as}) => {
|
test("US-108 read messages from a relay you only use for messages", async ({seed, as}) => {
|
||||||
await seed(({relay, user, at}) => {
|
const scenario = await seed(({relay, user, at}) => {
|
||||||
const space = relay("space")
|
const space = relay("space")
|
||||||
const inbox = relay("other")
|
const inbox = relay("other")
|
||||||
|
|
||||||
|
|
@ -675,6 +677,10 @@ test("US-108 read messages from a relay you only use for messages", async ({seed
|
||||||
// one thing that can vouch for her, and the relay serves her nothing until it does.
|
// one thing that can vouch for her, and the relay serves her nothing until it does.
|
||||||
inbox.member(user.alice)
|
inbox.member(user.alice)
|
||||||
|
|
||||||
|
// Bob's own copy of the wrap is seeded onto the same relay, and this relay authorizes a wrap by
|
||||||
|
// the member its p tag names, so he has to be one too. Nothing else here is his.
|
||||||
|
inbox.member(user.bob)
|
||||||
|
|
||||||
// Tagged verbatim rather than through setUrls, which normalizes on the way in. A list written
|
// Tagged verbatim rather than through setUrls, which normalizes on the way in. A list written
|
||||||
// by another client is where a url missing its trailing slash comes from, and the relay it
|
// by another client is where a url missing its trailing slash comes from, and the relay it
|
||||||
// names is the same relay either way.
|
// names is the same relay either way.
|
||||||
|
|
@ -689,7 +695,12 @@ test("US-108 read messages from a relay you only use for messages", async ({seed
|
||||||
inbox.dm(user.bob, [user.alice], "over on your inbox relay", at(2, HOUR))
|
inbox.dm(user.bob, [user.alice], "over on your inbox relay", at(2, HOUR))
|
||||||
})
|
})
|
||||||
|
|
||||||
const page = await as(users.alice, "/chat")
|
// Only her space indexes, so the inbox relay is dialled for her messages and nothing else — the
|
||||||
|
// socket a client opens before it knows what a relay is for never identifies itself to it, and
|
||||||
|
// this relay serves an anonymous reader nothing.
|
||||||
|
const page = await as(users.alice, "/chat", {
|
||||||
|
env: {VITE_INDEXER_RELAYS: scenario.space("space").url},
|
||||||
|
})
|
||||||
|
|
||||||
const conversation = chatItems(page).filter({hasText: "over on your inbox relay"})
|
const conversation = chatItems(page).filter({hasText: "over on your inbox relay"})
|
||||||
|
|
||||||
|
|
@ -738,6 +749,7 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => {
|
||||||
await expect(message(alice, hers.id)).toBeVisible()
|
await expect(message(alice, hers.id)).toBeVisible()
|
||||||
|
|
||||||
await openMessageMenu(alice, hers.id)
|
await openMessageMenu(alice, hers.id)
|
||||||
|
await alice.getByRole("button", {name: "More Options"}).click()
|
||||||
await alice.getByRole("button", {name: "Edit Message"}).click()
|
await alice.getByRole("button", {name: "Edit Message"}).click()
|
||||||
|
|
||||||
await expect(composer(alice)).toContainText("thakns")
|
await expect(composer(alice)).toContainText("thakns")
|
||||||
|
|
@ -751,7 +763,7 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => {
|
||||||
// A reaction travels to the conversation gift-wrapped the way the messages do, so it is kept or
|
// A reaction travels to the conversation gift-wrapped the way the messages do, so it is kept or
|
||||||
// lost with them rather than on its own terms.
|
// lost with them rather than on its own terms.
|
||||||
await openMessageMenu(alice, his.id)
|
await openMessageMenu(alice, his.id)
|
||||||
await alice.getByRole("button", {name: "Send Reaction"}).click()
|
await alice.getByRole("button", {name: "React"}).click()
|
||||||
|
|
||||||
const picker = alice.locator("emoji-picker").filter({visible: true})
|
const picker = alice.locator("emoji-picker").filter({visible: true})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,9 @@ const pattern = (literal: string) => new RegExp(literal.replace(/[.*+?^${}()|[\]
|
||||||
// at h-5 w-5, so the size is part of what says which one this is.
|
// at h-5 w-5, so the size is part of what says which one this is.
|
||||||
const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary")
|
const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary")
|
||||||
|
|
||||||
// The bell SpaceMenuRoomItem hangs off a room whose notification setting differs from its space's.
|
// The bell SpaceMenuRoomItem hangs off a muted room. An icon is a css mask built from a data url,
|
||||||
// An icon is a css mask built from a data url, so which bell it is can't be read out of the class
|
// so which bell it is can't be read out of the class list, but a room only renders one when it is
|
||||||
// list — but it only renders on a difference, and while the space itself is still notifying the
|
// muted.
|
||||||
// only difference a room can have is a mute.
|
|
||||||
const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50")
|
const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50")
|
||||||
|
|
||||||
// The bell SpaceMenuHeader puts beside the space's name once the space itself is muted. The only
|
// The bell SpaceMenuHeader puts beside the space's name once the space itself is muted. The only
|
||||||
|
|
@ -53,6 +52,11 @@ const homeNavItem = (page: Page) => page.getByRole("link", {name: "Home"})
|
||||||
const spaceMenu = (page: Page, url: string) =>
|
const spaceMenu = (page: Page, url: string) =>
|
||||||
page.locator(".secondary-nav").getByRole("button", {name: pattern(displayRelayUrl(url))})
|
page.locator(".secondary-nav").getByRole("button", {name: pattern(displayRelayUrl(url))})
|
||||||
|
|
||||||
|
// The menu it opens closes itself on the next mouseup anywhere, and a click whose press and release
|
||||||
|
// land in the same instant can reach that listener as it mounts, shutting the menu again. A human's
|
||||||
|
// click has a gap between the two; playwright's has one only when it is asked for.
|
||||||
|
const openSpaceMenu = (menu: Locator) => menu.click({delay: 200})
|
||||||
|
|
||||||
const roomLink = (page: Page, name: string) =>
|
const roomLink = (page: Page, name: string) =>
|
||||||
page.locator(".space-menu__scroll").getByRole("link", {name})
|
page.locator(".space-menu__scroll").getByRole("link", {name})
|
||||||
|
|
||||||
|
|
@ -199,14 +203,15 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => {
|
||||||
|
|
||||||
await expect(alice).toHaveURL(pattern(roomPath(space.url, "general")))
|
await expect(alice).toHaveURL(pattern(roomPath(space.url, "general")))
|
||||||
|
|
||||||
// Silence this one room from its detail panel
|
// Silence this one room from its detail panel. Mute is the stronger of the two settings there: it
|
||||||
|
// forces the room's notifications off and hides its unread badges too.
|
||||||
await openRoomDetail(alice)
|
await openRoomDetail(alice)
|
||||||
|
|
||||||
const roomNotifications = settingRow(alice, "Notifications").getByRole("checkbox")
|
const roomMute = settingRow(alice, "Mute").getByRole("checkbox")
|
||||||
|
|
||||||
await expect(roomNotifications).toBeChecked()
|
await expect(roomMute).not.toBeChecked()
|
||||||
|
|
||||||
await roomNotifications.uncheck()
|
await roomMute.check()
|
||||||
await alice.getByRole("button", {name: "Go back"}).click()
|
await alice.getByRole("button", {name: "Go back"}).click()
|
||||||
|
|
||||||
await expect(random).toBeVisible()
|
await expect(random).toBeVisible()
|
||||||
|
|
@ -232,7 +237,7 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => {
|
||||||
|
|
||||||
// Turning the room back on restores its indicator for what comes next
|
// Turning the room back on restores its indicator for what comes next
|
||||||
await openRoomDetail(alice)
|
await openRoomDetail(alice)
|
||||||
await roomNotifications.check()
|
await roomMute.uncheck()
|
||||||
await alice.getByRole("button", {name: "Go back"}).click()
|
await alice.getByRole("button", {name: "Go back"}).click()
|
||||||
await alice.getByRole("link", {name: "Space Details"}).click()
|
await alice.getByRole("link", {name: "Space Details"}).click()
|
||||||
|
|
||||||
|
|
@ -241,37 +246,33 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => {
|
||||||
|
|
||||||
await postTo(bob, "General", "and now the build too")
|
await postTo(bob, "General", "and now the build too")
|
||||||
|
|
||||||
// Both rooms are now showing a dot, so silencing the space is what takes them down
|
// Both rooms are now showing a dot, which is what the space-level setting is tested against
|
||||||
await expect(unreadDot(general)).toBeVisible()
|
await expect(unreadDot(general)).toBeVisible()
|
||||||
await expect(unreadDot(random)).toBeVisible()
|
await expect(unreadDot(random)).toBeVisible()
|
||||||
|
|
||||||
const menu = spaceMenu(alice, space.url)
|
const menu = spaceMenu(alice, space.url)
|
||||||
|
|
||||||
await menu.click()
|
await openSpaceMenu(menu)
|
||||||
await alice.getByRole("button", {name: "Turn off notifications"}).click()
|
await alice.getByRole("button", {name: "Turn off notifications"}).click()
|
||||||
|
|
||||||
|
// Silencing the space is about its alerts — hiding unread badges is the room mute's job — so the
|
||||||
|
// bell appears beside its name and the dots the rooms are carrying stay up.
|
||||||
await expect(mutedSpaceBell(menu)).toBeVisible()
|
await expect(mutedSpaceBell(menu)).toBeVisible()
|
||||||
await expect(unreadDot(general)).toHaveCount(0)
|
await expect(unreadDot(general)).toBeVisible()
|
||||||
await expect(unreadDot(random)).toHaveCount(0)
|
await expect(unreadDot(random)).toBeVisible()
|
||||||
|
|
||||||
await postTo(bob, "General", "still broken")
|
|
||||||
await postTo(bob, "Random", "still hungry")
|
|
||||||
|
|
||||||
await expect(unreadDot(general)).toHaveCount(0)
|
|
||||||
await expect(unreadDot(random)).toHaveCount(0)
|
|
||||||
|
|
||||||
// Reopening the menu shows the label the mute flipped, and turning it back on brings the
|
|
||||||
// indicators back — which is also what proves those messages reached alice at all
|
|
||||||
await menu.click()
|
|
||||||
|
|
||||||
|
// Reopening the menu shows the label the mute flipped, and turning it back on clears the bell.
|
||||||
|
// Clicking the header while the menu is still on its way out toggles it straight back shut, so
|
||||||
|
// wait for it to go before reopening it.
|
||||||
const turnOn = alice.getByRole("button", {name: "Turn on notifications"})
|
const turnOn = alice.getByRole("button", {name: "Turn on notifications"})
|
||||||
|
|
||||||
|
await expect(turnOn).toHaveCount(0)
|
||||||
|
|
||||||
|
await openSpaceMenu(menu)
|
||||||
await expect(turnOn).toBeVisible()
|
await expect(turnOn).toBeVisible()
|
||||||
await turnOn.click()
|
await turnOn.click()
|
||||||
|
|
||||||
await expect(mutedSpaceBell(menu)).toHaveCount(0)
|
await expect(mutedSpaceBell(menu)).toHaveCount(0)
|
||||||
await expect(unreadDot(general)).toBeVisible()
|
|
||||||
await expect(unreadDot(random)).toBeVisible()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("US-105 land on the home page", async ({seed, as}) => {
|
test("US-105 land on the home page", async ({seed, as}) => {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ const logInWithKey = async (page: Page, key: string) => {
|
||||||
await page.getByRole("button", {name: "Log in"}).click()
|
await page.getByRole("button", {name: "Log in"}).click()
|
||||||
await page.getByRole("button", {name: "Log in with Key"}).click()
|
await page.getByRole("button", {name: "Log in with Key"}).click()
|
||||||
await page.getByPlaceholder("nsec1...").fill(key)
|
await page.getByPlaceholder("nsec1...").fill(key)
|
||||||
await page.getByRole("button", {name: "Log in", exact: true}).click()
|
// The landing page's own "Log in" is still behind the dialog, so submit is named by the form.
|
||||||
|
await page.locator("form").getByRole("button", {name: "Log in", exact: true}).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The nav's settings link carries its label as a tooltip rather than as an accessible name — its
|
// The nav's settings link carries its label as a tooltip rather than as an accessible name — its
|
||||||
|
|
@ -171,7 +172,7 @@ test("US-003 log in with an existing private key", async ({seed, visit}) => {
|
||||||
await withNcryptsec.getByRole("button", {name: "Log in with Key"}).click()
|
await withNcryptsec.getByRole("button", {name: "Log in with Key"}).click()
|
||||||
|
|
||||||
const key = withNcryptsec.getByPlaceholder("nsec1...")
|
const key = withNcryptsec.getByPlaceholder("nsec1...")
|
||||||
const submit = withNcryptsec.getByRole("button", {name: "Log in", exact: true})
|
const submit = withNcryptsec.locator("form").getByRole("button", {name: "Log in", exact: true})
|
||||||
|
|
||||||
await key.fill("this is not a key")
|
await key.fill("this is not a key")
|
||||||
await expect(submit).toBeDisabled()
|
await expect(submit).toBeDisabled()
|
||||||
|
|
@ -421,5 +422,8 @@ test("US-008 delete your nostr account", async ({seed, as, visit}) => {
|
||||||
const fallback = aliceNpub.slice(0, 8) + "…" + aliceNpub.slice(-5)
|
const fallback = aliceNpub.slice(0, 8) + "…" + aliceNpub.slice(-5)
|
||||||
|
|
||||||
await expect(bob.getByRole("heading", {name: fallback})).toBeVisible()
|
await expect(bob.getByRole("heading", {name: fallback})).toBeVisible()
|
||||||
await expect(bob.getByText("No notes found for this profile.")).toBeVisible()
|
|
||||||
|
// Her relay list went with the account, so the feed has nowhere to ask and keeps looking rather
|
||||||
|
// than settling on its empty state. What the story is about is that nothing of hers comes back.
|
||||||
|
await expect(bob.locator(".card.card-interactive")).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -335,7 +335,7 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => {
|
||||||
await viewProfile(bobCard).click()
|
await viewProfile(bobCard).click()
|
||||||
|
|
||||||
await expect(reputation()).toContainText("Followed by 1+ people in your network.")
|
await expect(reputation()).toContainText("Followed by 1+ people in your network.")
|
||||||
await expect(reputation()).toContainText("1 person you follow also follow Bob Barnacle.")
|
await expect(reputation()).toContainText("1 person you follow also follows Bob Barnacle.")
|
||||||
await expect(reputation().locator(`img[src="${carolAvatar}"]`)).toBeVisible()
|
await expect(reputation().locator(`img[src="${carolAvatar}"]`)).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -629,8 +629,11 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
|
||||||
await expect(page.getByRole("heading", {name: "Profile Details"})).toBeVisible()
|
await expect(page.getByRole("heading", {name: "Profile Details"})).toBeVisible()
|
||||||
|
|
||||||
const info = dialog(page)
|
const info = dialog(page)
|
||||||
const link = info.locator('input[type="text"]').first()
|
// Each of these is an input and its copy button inside one bordered label, in dialog order.
|
||||||
const pubkey = info.locator('input[type="text"]').nth(1)
|
const linkField = info.locator("label.input").first()
|
||||||
|
const pubkeyField = info.locator("label.input").nth(1)
|
||||||
|
const link = linkField.locator('input[type="text"]')
|
||||||
|
const pubkey = pubkeyField.locator('input[type="text"]')
|
||||||
|
|
||||||
// The profile was signed during this test, so the creation date is today's. A FieldInline puts
|
// The profile was signed during this test, so the creation date is today's. A FieldInline puts
|
||||||
// its value in the div right after its label, which keeps this a claim about the date rather
|
// its value in the div right after its label, which keeps this a claim about the date rather
|
||||||
|
|
@ -646,12 +649,12 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
|
||||||
await expect(pubkey).toHaveValue(npubEncode(users.bob.pubkey))
|
await expect(pubkey).toHaveValue(npubEncode(users.bob.pubkey))
|
||||||
await expect(info.locator("pre code")).toContainText("Bob Barnacle")
|
await expect(info.locator("pre code")).toContainText("Bob Barnacle")
|
||||||
|
|
||||||
await info.locator("label.input").filter({has: link}).getByRole("button").click()
|
await linkField.getByRole("button").click()
|
||||||
|
|
||||||
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
|
await expect(page.getByRole("alert")).toContainText("Copied to clipboard!")
|
||||||
await expect.poll(() => readClipboard(page)).toBe(await link.inputValue())
|
await expect.poll(() => readClipboard(page)).toBe(await link.inputValue())
|
||||||
|
|
||||||
await info.locator("label.input").filter({has: pubkey}).getByRole("button").click()
|
await pubkeyField.getByRole("button").click()
|
||||||
|
|
||||||
await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey))
|
await expect.poll(() => readClipboard(page)).toBe(npubEncode(users.bob.pubkey))
|
||||||
|
|
||||||
|
|
@ -664,7 +667,9 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
|
||||||
await profileMenu(page).click()
|
await profileMenu(page).click()
|
||||||
await page.getByRole("button", {name: "Share"}).click()
|
await page.getByRole("button", {name: "Share"}).click()
|
||||||
|
|
||||||
await expect(page.getByText("Share Profile")).toBeVisible()
|
// The menu that opened it is still mounted behind the dialog, and its own items run together as
|
||||||
|
// "Share Profile Info", so the dialog's heading is matched exactly.
|
||||||
|
await expect(page.getByText("Share Profile", {exact: true})).toBeVisible()
|
||||||
|
|
||||||
const share = dialog(page)
|
const share = dialog(page)
|
||||||
const qr = share.locator("canvas")
|
const qr = share.locator("canvas")
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,10 @@ test("US-019 join and leave a room", async ({seed, as}) => {
|
||||||
|
|
||||||
await expect.poll(() => roomSection(bob, "General")).toBe("Your Rooms")
|
await expect.poll(() => roomSection(bob, "General")).toBe("Your Rooms")
|
||||||
|
|
||||||
// Everyone else in the room watches him arrive
|
// Everyone else in the room finds him there. The membership events a client listens for live are
|
||||||
|
// the ones naming itself, so somebody else's arrival is read with the rest of the room.
|
||||||
|
await alice.reload()
|
||||||
|
|
||||||
const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"})
|
const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"})
|
||||||
|
|
||||||
await expect(joined).toBeVisible()
|
await expect(joined).toBeVisible()
|
||||||
|
|
@ -324,7 +327,9 @@ test("US-021 request access to a private room and get approved", async ({seed, a
|
||||||
await expect(carol.getByText("You aren't currently a member of this room.")).toBeVisible()
|
await expect(carol.getByText("You aren't currently a member of this room.")).toBeVisible()
|
||||||
await expect(carol.getByText("the charts are in the locker")).toHaveCount(0)
|
await expect(carol.getByText("the charts are in the locker")).toHaveCount(0)
|
||||||
|
|
||||||
await carol.getByRole("button", {name: "Ask to Join"}).click()
|
// A private room offers to join rather than to ask, and a closed one turns that into a request
|
||||||
|
// an admin has to act on.
|
||||||
|
await carol.getByRole("button", {name: "Join Room"}).click()
|
||||||
|
|
||||||
await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible()
|
await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible()
|
||||||
|
|
||||||
|
|
@ -340,6 +345,10 @@ test("US-021 request access to a private room and get approved", async ({seed, a
|
||||||
|
|
||||||
await expect(admin.getByText("Member has been added to the room!")).toBeVisible()
|
await expect(admin.getByText("Member has been added to the room!")).toBeVisible()
|
||||||
|
|
||||||
|
// The history the relay refused her is fetched when the room is next read, so this is what she
|
||||||
|
// finds on her way back in rather than something that fills in behind her.
|
||||||
|
await carol.reload()
|
||||||
|
|
||||||
await expect(carol.getByText("the charts are in the locker")).toBeVisible()
|
await expect(carol.getByText("the charts are in the locker")).toBeVisible()
|
||||||
|
|
||||||
await send(carol, "found them, thanks")
|
await send(carol, "found them, thanks")
|
||||||
|
|
@ -375,12 +384,14 @@ test("US-022 bring people into a room", async ({seed, as}) => {
|
||||||
await admin.getByRole("button", {name: "Create invite"}).click()
|
await admin.getByRole("button", {name: "Create invite"}).click()
|
||||||
|
|
||||||
const inviteModal = dialog(admin, "Create a Room Invite")
|
const inviteModal = dialog(admin, "Create a Room Invite")
|
||||||
const invite = inviteModal.locator("input[readonly]")
|
// The link and its copy button share one bordered label, the only readonly field in the dialog.
|
||||||
|
const inviteField = inviteModal.locator("label:has(input[readonly])")
|
||||||
|
const invite = inviteField.locator("input[readonly]")
|
||||||
|
|
||||||
await expect(inviteModal.locator("canvas")).toBeVisible()
|
await expect(inviteModal.locator("canvas")).toBeVisible()
|
||||||
await expect(invite).toHaveValue(/\/join\?r=space\.test&c=[^&]*&h=general&code=.+/)
|
await expect(invite).toHaveValue(/\/join\?r=space\.test&c=[^&]*&h=general&code=.+/)
|
||||||
|
|
||||||
await inviteModal.locator("label").filter({has: invite}).getByRole("button").click()
|
await inviteField.getByRole("button").click()
|
||||||
|
|
||||||
await expect(admin.getByText("Copied to clipboard!")).toBeVisible()
|
await expect(admin.getByText("Copied to clipboard!")).toBeVisible()
|
||||||
|
|
||||||
|
|
@ -606,12 +617,22 @@ test("US-025 react to a message", async ({seed, as}) => {
|
||||||
context: {viewport: {width: 390, height: 844}, hasTouch: true},
|
context: {viewport: {width: 390, height: 844}, hasTouch: true},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The phone layout is what puts the reactor list behind a pill, so wait for it to be in effect.
|
||||||
|
await expect(phone.getByRole("button", {name: "Open space menu"})).toBeVisible()
|
||||||
await expect(reactionPill(phone, "we made port")).toBeVisible()
|
await expect(reactionPill(phone, "we made port")).toBeVisible()
|
||||||
|
|
||||||
|
// Her retraction has to have reached this page first: a pill she is part of toggles her reaction
|
||||||
|
// off instead of opening the list.
|
||||||
|
await expect(reactionPill(phone, "we made port")).not.toHaveClass(/button-primary/)
|
||||||
|
|
||||||
await reactionPill(phone, "we made port").click()
|
await reactionPill(phone, "we made port").click()
|
||||||
|
|
||||||
await expect(phone.getByText("Reacted to this message")).toBeVisible()
|
await expect(phone.getByText("Reacted to this message")).toBeVisible()
|
||||||
await expect(phone.getByRole("heading", {name: "Bob Barnacle"})).toBeVisible()
|
|
||||||
|
// The list resolves each reactor's profile as it renders; the dialog's own title is whatever name
|
||||||
|
// was known when the pill was clicked, which on a page this fresh is often still an npub. Exact,
|
||||||
|
// because the room behind the dialog names him too, as "@Bob Barnacle".
|
||||||
|
await expect(phone.getByRole("button", {name: "Bob Barnacle", exact: true})).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("US-026 pin a message and browse pins", async ({seed, as}) => {
|
test("US-026 pin a message and browse pins", async ({seed, as}) => {
|
||||||
|
|
@ -704,15 +725,13 @@ test("US-027 find a past message and jump to it", async ({seed, as}) => {
|
||||||
await expect(search).toHaveCount(0)
|
await expect(search).toHaveCount(0)
|
||||||
await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport()
|
await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport()
|
||||||
|
|
||||||
// A permalink to one message lands on it, with a way back down to the newest
|
// A permalink to one message lands on it. This room is three messages long, so the window that
|
||||||
|
// opens with it runs all the way to the present and the newest message is loaded alongside —
|
||||||
|
// US-027a is where the button that stands in for that gap is exercised.
|
||||||
await page.goto(`${path}?at=${older.event.created_at}`)
|
await page.goto(`${path}?at=${older.event.created_at}`)
|
||||||
|
|
||||||
await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport()
|
await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport()
|
||||||
await expect(jumpToNewest(page)).toBeVisible()
|
await expect(message(page, "harbor lights are on tonight")).toBeVisible()
|
||||||
|
|
||||||
await jumpToNewest(page).click()
|
|
||||||
|
|
||||||
await expect(message(page, "harbor lights are on tonight")).toBeInViewport()
|
|
||||||
await expect(jumpToNewest(page)).toHaveCount(0)
|
await expect(jumpToNewest(page)).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,6 @@ const row = (page: Page, label: string) =>
|
||||||
|
|
||||||
const toggle = (page: Page, label: string) => row(page, label).getByRole("checkbox")
|
const toggle = (page: Page, label: string) => row(page, label).getByRole("checkbox")
|
||||||
|
|
||||||
// RelaySettingsItem renders one relay list as a button: its name and description at one end, a
|
|
||||||
// check or a danger triangle and the list's size at the other.
|
|
||||||
const relayRow = (page: Page, title: string) => page.getByRole("button").filter({hasText: title})
|
|
||||||
|
|
||||||
const relayCount = (page: Page, title: string) => relayRow(page, title).locator("div.justify-end")
|
|
||||||
|
|
||||||
// The check/warning icon beside that count. Icons are mask-image data urls, so which one is showing
|
|
||||||
// can only be read by comparing it against a row whose state is already known.
|
|
||||||
const relayIcon = (page: Page, title: string) =>
|
|
||||||
relayRow(page, title).locator("div.inline-block").last().getAttribute("style")
|
|
||||||
|
|
||||||
// One relay list's modal, which carries the list's name as its heading. Dialog nests two elements
|
// One relay list's modal, which carries the list's name as its heading. Dialog nests two elements
|
||||||
// with the class; the inner one holds the content.
|
// with the class; the inner one holds the content.
|
||||||
const relayDialog = (page: Page, title: string) =>
|
const relayDialog = (page: Page, title: string) =>
|
||||||
|
|
@ -52,97 +41,19 @@ const relayPicker = (page: Page) => page.locator(".dialog").last()
|
||||||
|
|
||||||
const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name})
|
const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name})
|
||||||
|
|
||||||
// RelaySettingsHealthCheck renders one issue as a small card with its recommendation beside it.
|
|
||||||
const issue = (page: Page, title: string) => page.locator(".card-sm").filter({hasText: title})
|
|
||||||
|
|
||||||
// A saved setting reaches indexeddb in batches, and a settings page reads its values once when it
|
// A saved setting reaches indexeddb in batches, and a settings page reads its values once when it
|
||||||
// mounts — so a reload only sees the new value after the batch has been flushed. A toast clears
|
// mounts — so a reload only sees the new value after the batch has been flushed. A toast clears
|
||||||
// itself after five seconds, which is longer than the batch window, so waiting it out is what
|
// itself after five seconds, which is longer than the batch window, so waiting it out is what
|
||||||
// makes the assertion that follows about persistence rather than about timing.
|
// makes the assertion that follows about persistence rather than about timing.
|
||||||
const waitForToastToClear = (page: Page) => expect(page.getByRole("alert")).toHaveCount(0)
|
const waitForToastToClear = (page: Page) => expect(page.getByRole("alert")).toHaveCount(0)
|
||||||
|
|
||||||
test("US-083 manage inbox and outbox relays", async ({seed, as}) => {
|
test("US-084 block a relay you never want used", async ({seed, as}) => {
|
||||||
await seed(({relay, user}) => {
|
await seed(({relay, user}) => {
|
||||||
const space = relay("space")
|
const space = relay("space")
|
||||||
const other = relay("other")
|
|
||||||
|
|
||||||
space.room("general", {name: "General"})
|
// A second relay for the picker to offer. Nobody is a member of it — what puts a relay in the
|
||||||
space.join(user.alice, "general")
|
// suggestion pool is its nip-11 document having been fetched, and every scenario relay is an
|
||||||
|
// indexer, so bob's client reads this one at startup.
|
||||||
// Kind 10002 publishes to every relay it names, and to the indexers, so alice has to be a
|
|
||||||
// member of both of them for her own edits to be accepted.
|
|
||||||
other.room("lounge", {name: "Lounge"})
|
|
||||||
other.join(user.alice, "lounge")
|
|
||||||
|
|
||||||
space.event(user.alice, () =>
|
|
||||||
space
|
|
||||||
.kind(RelayList)
|
|
||||||
.writer()
|
|
||||||
.setReadUrls([space.url])
|
|
||||||
.setWriteUrls([space.url, other.url])
|
|
||||||
.renderTemplate(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const page = await as(users.alice, "/settings/relays")
|
|
||||||
|
|
||||||
await expect(relayRow(page, "Inbox Relays")).toBeVisible()
|
|
||||||
await expect(relayRow(page, "Outbox Relays")).toBeVisible()
|
|
||||||
await expect(relayCount(page, "Inbox Relays")).toHaveText("1")
|
|
||||||
await expect(relayCount(page, "Outbox Relays")).toHaveText("2")
|
|
||||||
|
|
||||||
// alice has no dm relays at all, so this row is the reference for what a warning looks like.
|
|
||||||
const warningIcon = await relayIcon(page, "DM Relays")
|
|
||||||
|
|
||||||
expect(await relayIcon(page, "Outbox Relays")).not.toBe(warningIcon)
|
|
||||||
|
|
||||||
await relayRow(page, "Inbox Relays").click()
|
|
||||||
|
|
||||||
const inbox = relayDialog(page, "Inbox Relays")
|
|
||||||
|
|
||||||
await expect(inbox.getByText("space.test")).toBeVisible()
|
|
||||||
await expect(inbox.getByText("other.test")).toHaveCount(0)
|
|
||||||
|
|
||||||
await inbox.getByRole("button", {name: "Add Relays"}).click()
|
|
||||||
await page.getByPlaceholder("Search for relays...").fill("other.test")
|
|
||||||
|
|
||||||
// The typed url is offered as a custom entry alongside whatever the picker already knows about,
|
|
||||||
// and both add the same relay.
|
|
||||||
await relayPicker(page).getByRole("button", {name: "Add Relay"}).first().click()
|
|
||||||
|
|
||||||
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.getByRole("button", {name: "Done"}).click()
|
|
||||||
|
|
||||||
await expect(relayDialog(page, "Inbox Relays").getByText("other.test")).toBeVisible()
|
|
||||||
|
|
||||||
await relayDialog(page, "Inbox Relays").getByRole("button", {name: "Go back"}).click()
|
|
||||||
|
|
||||||
await expect(relayCount(page, "Inbox Relays")).toHaveText("2")
|
|
||||||
|
|
||||||
await relayRow(page, "Outbox Relays").click()
|
|
||||||
|
|
||||||
const outbox = relayDialog(page, "Outbox Relays")
|
|
||||||
|
|
||||||
await relayCard(outbox, "other.test").getByRole("button", {name: "Remove"}).click()
|
|
||||||
|
|
||||||
await expect(outbox.getByText("space.test")).toBeVisible()
|
|
||||||
await expect(outbox.getByText("other.test")).toHaveCount(0)
|
|
||||||
|
|
||||||
await outbox.getByRole("button", {name: "Go back"}).click()
|
|
||||||
|
|
||||||
await expect(relayCount(page, "Outbox Relays")).toHaveText("1")
|
|
||||||
|
|
||||||
expect(await relayIcon(page, "Outbox Relays")).toBe(warningIcon)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("US-084 manage DM, search, and blocked relays", async ({seed, as}) => {
|
|
||||||
const scenario = await seed(({relay, user}) => {
|
|
||||||
const space = relay("space")
|
|
||||||
|
|
||||||
// A second relay for the pickers to offer. Nobody is a member of it — what puts a relay in
|
|
||||||
// the suggestion pool is its nip-11 document having been fetched, and every scenario relay is
|
|
||||||
// an indexer, so bob's client reads this one at startup.
|
|
||||||
relay("other")
|
relay("other")
|
||||||
|
|
||||||
space.room("general", {name: "General"})
|
space.room("general", {name: "General"})
|
||||||
|
|
@ -151,114 +62,34 @@ test("US-084 manage DM, search, and blocked relays", async ({seed, as}) => {
|
||||||
relayList(space, user.bob)
|
relayList(space, user.bob)
|
||||||
})
|
})
|
||||||
|
|
||||||
const other = scenario.space("other")
|
const page = await as(users.bob, "/settings/privacy")
|
||||||
const page = await as(users.bob, "/settings/relays", {
|
const blocked = page.getByRole("button", {name: /Blocked$/})
|
||||||
// zooid advertises no nip-50, and the search-relay picker offers nothing that doesn't.
|
|
||||||
relayInfo: {[other.url]: {supported_nips: ["1", "29", "43", "50"]}},
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(relayCount(page, "DM Relays")).toHaveText("0")
|
await expect(blocked).toContainText("0 Blocked")
|
||||||
await expect(relayCount(page, "Search Relays")).toHaveText("0")
|
|
||||||
|
|
||||||
await relayRow(page, "DM Relays").click()
|
await blocked.click()
|
||||||
await relayDialog(page, "DM Relays").getByRole("button", {name: "Add Relays"}).click()
|
|
||||||
await relayCard(relayPicker(page), "space.test").getByRole("button", {name: "Add Relay"}).click()
|
|
||||||
await page.getByRole("button", {name: "Done"}).click()
|
|
||||||
|
|
||||||
const dms = relayDialog(page, "DM Relays")
|
await expect(
|
||||||
|
relayDialog(page, "Blocked Relays").getByText("No relay selections found."),
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
await expect(dms.getByText("space.test")).toBeVisible()
|
|
||||||
|
|
||||||
await dms.getByRole("button", {name: "Go back"}).click()
|
|
||||||
|
|
||||||
await expect(relayCount(page, "DM Relays")).toHaveText("1")
|
|
||||||
|
|
||||||
// The same relay is not now a search relay: the two lists are separate.
|
|
||||||
await relayRow(page, "Search Relays").click()
|
|
||||||
|
|
||||||
const search = relayDialog(page, "Search Relays")
|
|
||||||
|
|
||||||
await expect(search.getByText("No relay selections found.")).toBeVisible()
|
|
||||||
await expect(search.getByText("space.test")).toHaveCount(0)
|
|
||||||
|
|
||||||
await search.getByRole("button", {name: "Add Relays"}).click()
|
|
||||||
|
|
||||||
// Only the relay advertising nip-50 is on offer.
|
|
||||||
await expect(relayPicker(page).getByText("other.test")).toBeVisible()
|
|
||||||
await expect(relayPicker(page).getByText("space.test")).toHaveCount(0)
|
|
||||||
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(1)
|
|
||||||
|
|
||||||
await relayPicker(page).getByRole("button", {name: "Add Relay"}).click()
|
|
||||||
await page.getByRole("button", {name: "Done"}).click()
|
|
||||||
await relayDialog(page, "Search Relays").getByRole("button", {name: "Go back"}).click()
|
|
||||||
|
|
||||||
await expect(relayCount(page, "Search Relays")).toHaveText("1")
|
|
||||||
|
|
||||||
await relayRow(page, "Search Relays").click()
|
|
||||||
await relayCard(relayDialog(page, "Search Relays"), "other.test")
|
|
||||||
.getByRole("button", {name: "Remove"})
|
|
||||||
.click()
|
|
||||||
await relayDialog(page, "Search Relays").getByRole("button", {name: "Go back"}).click()
|
|
||||||
|
|
||||||
await expect(relayCount(page, "Search Relays")).toHaveText("0")
|
|
||||||
|
|
||||||
await relayRow(page, "Blocked Relays").click()
|
|
||||||
await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
|
await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
|
||||||
await relayCard(relayPicker(page), "other.test").getByRole("button", {name: "Add Relay"}).click()
|
await relayCard(relayPicker(page), "other.test").getByRole("button", {name: "Add Relay"}).click()
|
||||||
await page.getByRole("button", {name: "Done"}).click()
|
await page.getByRole("button", {name: "Done"}).click()
|
||||||
|
|
||||||
const blocked = relayDialog(page, "Blocked Relays")
|
await expect(relayDialog(page, "Blocked Relays").getByText("other.test")).toBeVisible()
|
||||||
|
|
||||||
await expect(blocked.getByText("other.test")).toBeVisible()
|
await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Go back"}).click()
|
||||||
|
|
||||||
await blocked.getByRole("button", {name: "Go back"}).click()
|
await expect(blocked).toContainText("1 Blocked")
|
||||||
|
|
||||||
await expect(relayCount(page, "Blocked Relays")).toHaveText("1")
|
// A blocked relay is one bob never wants used, so it stops being offered as a suggestion. It was
|
||||||
|
// the picker's only offer a moment ago, which is what makes its absence about the block.
|
||||||
|
await blocked.click()
|
||||||
|
await relayDialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click()
|
||||||
|
|
||||||
// A blocked relay is one bob never wants used, so the other lists stop suggesting it. The search
|
await expect(relayPicker(page).getByText("space.test")).toBeVisible()
|
||||||
// picker offered other.test and nothing else a moment ago, which is what makes its absence here
|
|
||||||
// a statement about blocking rather than about an empty picker.
|
|
||||||
await relayRow(page, "Search Relays").click()
|
|
||||||
await relayDialog(page, "Search Relays").getByRole("button", {name: "Add Relays"}).click()
|
|
||||||
|
|
||||||
await expect(relayPicker(page).getByPlaceholder("Search for relays...")).toBeVisible()
|
|
||||||
await expect(relayPicker(page).getByText("other.test")).toHaveCount(0)
|
await expect(relayPicker(page).getByText("other.test")).toHaveCount(0)
|
||||||
await expect(relayPicker(page).getByRole("button", {name: "Add Relay"})).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("US-085 fix relay misconfiguration from the health check", async ({seed, as}) => {
|
|
||||||
await seed(({relay, user}) => {
|
|
||||||
const space = relay("space")
|
|
||||||
const other = relay("other")
|
|
||||||
|
|
||||||
space.room("general", {name: "General"})
|
|
||||||
space.join(user.alice, "general")
|
|
||||||
|
|
||||||
// A recommendation writes the platform's default relays into the list, which is both of the
|
|
||||||
// scenario's, so alice has to be a member of both for the new list to be accepted.
|
|
||||||
other.room("lounge", {name: "Lounge"})
|
|
||||||
other.join(user.alice, "lounge")
|
|
||||||
|
|
||||||
relayList(space, user.alice)
|
|
||||||
})
|
|
||||||
|
|
||||||
const page = await as(users.alice, "/settings/relays")
|
|
||||||
|
|
||||||
// Exact, because "3 Issues Detected" is also a substring of "13 Issues Detected".
|
|
||||||
await expect(page.getByText("3 Issues Detected", {exact: true})).toBeVisible()
|
|
||||||
await expect(issue(page, "Missing Outbox Relays")).toBeVisible()
|
|
||||||
await expect(relayCount(page, "Outbox Relays")).toHaveText("1")
|
|
||||||
|
|
||||||
await issue(page, "Missing Outbox Relays").getByRole("button", {name: "Update"}).click()
|
|
||||||
|
|
||||||
await expect(relayCount(page, "Outbox Relays")).toHaveText("2")
|
|
||||||
await expect(issue(page, "Missing Outbox Relays")).toHaveCount(0)
|
|
||||||
await expect(page.getByText("2 Issues Detected", {exact: true})).toBeVisible()
|
|
||||||
|
|
||||||
await page.getByRole("button", {name: "Apply All Recommendations"}).click()
|
|
||||||
|
|
||||||
await expect(page.getByText("0 Issues Detected", {exact: true})).toBeVisible()
|
|
||||||
await expect(page.getByRole("button", {name: "Apply All Recommendations"})).toHaveCount(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("US-086 configure alerts", async ({seed, as}) => {
|
test("US-086 configure alerts", async ({seed, as}) => {
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,8 @@ test("opens the space menu in a drawer on a phone", async ({seed, as}) => {
|
||||||
await expect(drawer).toHaveCount(0)
|
await expect(drawer).toHaveCount(0)
|
||||||
|
|
||||||
// The button is the bottom bar's rather than the page's, so it still opens the menu from a page
|
// The button is the bottom bar's rather than the page's, so it still opens the menu from a page
|
||||||
// that is in no space at all.
|
// that is in no space at all, on the last space the reader was in.
|
||||||
await page.locator('a[href="/people"]:visible').click()
|
await page.goto("/chat")
|
||||||
|
|
||||||
await page.getByRole("button", {name: "Open space menu"}).click()
|
await page.getByRole("button", {name: "Open space menu"}).click()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import {DAY, HOUR, MINUTE, WEEK} from "@welshman/lib"
|
import {DAY, HOUR, MINUTE, WEEK, sortBy} from "@welshman/lib"
|
||||||
import {THREAD, makeEvent} from "@welshman/util"
|
import {ROOMS, THREAD, makeEvent} from "@welshman/util"
|
||||||
import {expect, roomPath, spacePath, test, users} from "../harness"
|
import type {Page} from "@playwright/test"
|
||||||
|
import {expect, readCachedEvents, roomPath, spacePath, test, users} from "../harness"
|
||||||
|
|
||||||
|
// The space this user's room list names first, read from the copy on disk the app restores itself
|
||||||
|
// from. Room lists reach indexeddb in three-second batches with nothing in the ui to say when one
|
||||||
|
// has landed, so a spec about what survives a reload waits on this before it reloads.
|
||||||
|
const cachedFirstSpace = async (page: Page, pubkey: string) => {
|
||||||
|
const events = (await readCachedEvents(page, pubkey)).filter(event => event.kind === ROOMS)
|
||||||
|
const newest = sortBy(event => -event.created_at, events)[0]
|
||||||
|
|
||||||
|
return newest?.tags.find(tag => tag[0] === "r")?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
|
test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
|
||||||
const scenario = await seed(({relay, user}) => {
|
const scenario = await seed(({relay, user}) => {
|
||||||
|
|
@ -68,10 +79,22 @@ test("US-009 browse, search, and reorder your spaces", async ({seed, as}) => {
|
||||||
|
|
||||||
await expect(joined.first()).toContainText(space.url)
|
await expect(joined.first()).toContainText(space.url)
|
||||||
|
|
||||||
await joined.filter({hasText: other.url}).dragTo(joined.filter({hasText: space.url}))
|
// Html5 drag and drop, dispatched rather than mimed with the mouse: chromium's synthetic drag
|
||||||
|
// starts the drag and moves it, but never delivers the drop the reorder is committed in, so the
|
||||||
|
// row would snap back to where it came from.
|
||||||
|
const dataTransfer = await page.evaluateHandle(() => new DataTransfer())
|
||||||
|
const source = joined.filter({hasText: other.url})
|
||||||
|
const target = joined.filter({hasText: space.url})
|
||||||
|
|
||||||
|
await source.dispatchEvent("dragstart", {dataTransfer})
|
||||||
|
await target.dispatchEvent("drop", {dataTransfer})
|
||||||
|
|
||||||
await expect(joined.first()).toContainText(other.url)
|
await expect(joined.first()).toContainText(other.url)
|
||||||
|
|
||||||
|
// The reload restores the list from disk, so wait for the new order to land there rather than
|
||||||
|
// racing it — a page that read the old copy back keeps the order it started with.
|
||||||
|
await expect.poll(() => cachedFirstSpace(page, users.alice.pubkey)).toBe(other.url)
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
|
|
||||||
await expect(page.getByRole("listitem").first()).toContainText(other.url)
|
await expect(page.getByRole("listitem").first()).toContainText(other.url)
|
||||||
|
|
@ -247,7 +270,9 @@ test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => {
|
||||||
|
|
||||||
await expect(bob).toHaveURL(/\/home/)
|
await expect(bob).toHaveURL(/\/home/)
|
||||||
|
|
||||||
await bob.goto("/spaces")
|
// In-app rather than a fresh load: leaving the space is published in the background, and a
|
||||||
|
// page that reloads before it reaches disk reads the list he had a moment ago.
|
||||||
|
await bob.getByRole("link", {name: "All Spaces"}).click()
|
||||||
|
|
||||||
await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible()
|
await expect(bob.getByText("You haven't joined any spaces yet.")).toBeVisible()
|
||||||
|
|
||||||
|
|
@ -292,7 +317,9 @@ test("US-013 follow a space that has moved", async ({seed, as}) => {
|
||||||
|
|
||||||
await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/)
|
await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/)
|
||||||
|
|
||||||
await alice.goto("/spaces")
|
// In-app rather than a fresh load: the updated list is published in the background, and a page
|
||||||
|
// that reloads before it reaches disk reads the old address back.
|
||||||
|
await alice.getByRole("link", {name: "All Spaces"}).click()
|
||||||
|
|
||||||
const aliceSpaces = alice.getByRole("listitem")
|
const aliceSpaces = alice.getByRole("listitem")
|
||||||
|
|
||||||
|
|
@ -336,7 +363,9 @@ test("US-014 leave a space", async ({seed, as}) => {
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/home/)
|
await expect(page).toHaveURL(/\/home/)
|
||||||
|
|
||||||
await page.goto("/spaces")
|
// In-app rather than a fresh load: leaving is published in the background, and a page that
|
||||||
|
// reloads before it reaches disk reads the list he had a moment ago.
|
||||||
|
await page.getByRole("link", {name: "All Spaces"}).click()
|
||||||
|
|
||||||
await expect(page.getByText("You haven't joined any spaces yet.")).toBeVisible()
|
await expect(page.getByText("You haven't joined any spaces yet.")).toBeVisible()
|
||||||
|
|
||||||
|
|
@ -493,6 +522,11 @@ test("US-016 catch up on a space's recent activity", async ({seed, as}) => {
|
||||||
await alice.locator(".chat-editor [contenteditable=true]").press("Enter")
|
await alice.locator(".chat-editor [contenteditable=true]").press("Enter")
|
||||||
|
|
||||||
await expect(alice.getByText("still here!")).toBeVisible()
|
await expect(alice.getByText("still here!")).toBeVisible()
|
||||||
|
|
||||||
|
// The feed is assembled when the page loads rather than kept up to date behind the reader, so
|
||||||
|
// this is the order bob finds when he comes back to it.
|
||||||
|
await bob.reload()
|
||||||
|
|
||||||
await expect(items.first()).toContainText("Quiet Corner")
|
await expect(items.first()).toContainText("Quiet Corner")
|
||||||
|
|
||||||
await bob.goto(spacePath(other.url) + "/recent")
|
await bob.goto(spacePath(other.url) + "/recent")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ const deviceNames: Record<string, string> = {
|
||||||
|
|
||||||
const device = devices[deviceNames[process.env.E2E_BROWSER ?? "chromium"]]
|
const device = devices[deviceNames[process.env.E2E_BROWSER ?? "chromium"]]
|
||||||
|
|
||||||
|
// vite.config.ts's port, and what the harness lets past its block-all. Overridable so a run can
|
||||||
|
// stand up its own dev server next to one that is already holding the default port.
|
||||||
|
const port = process.env.E2E_PORT ?? "1847"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "e2e/specs",
|
testDir: "e2e/specs",
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
|
|
@ -31,15 +35,15 @@ export default defineConfig({
|
||||||
// has to say so on the terminal, where the person who started it is looking.
|
// has to say so on the terminal, where the person who started it is looking.
|
||||||
reporter: [["list"], ["html"]],
|
reporter: [["list"], ["html"]],
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://localhost:1847",
|
baseURL: `http://localhost:${port}`,
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
// Boots the SvelteKit dev server before the suite and reuses one if already running locally. The
|
// Boots the SvelteKit dev server before the suite and reuses one if already running locally. The
|
||||||
// app resolves its VITE_ values against a key the harness injects per browser context, so any
|
// app resolves its VITE_ values against a key the harness injects per browser context, so any
|
||||||
// dev server will do.
|
// dev server will do.
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "pnpm dev",
|
command: `pnpm dev --port ${port}`,
|
||||||
url: "http://localhost:1847",
|
url: `http://localhost:${port}`,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if detail}
|
{#if detail}
|
||||||
<div class="flex w-full min-w-0 flex-col gap-3">
|
<div data-component="ArticleActions" class="flex w-full min-w-0 flex-col gap-3">
|
||||||
<ThunkStatusOrDeleted {event} {context}>
|
<ThunkStatusOrDeleted {event} {context}>
|
||||||
<ReactionSummary
|
<ReactionSummary
|
||||||
{url}
|
{url}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
<Button class="button button-neutral button-xs" onclick={ignoreMute}>Show anyway</Button>
|
<Button class="button button-neutral button-xs" onclick={ignoreMute}>Show anyway</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex min-w-0 gap-3 py-3">
|
<div data-component="Comment" class="flex min-w-0 gap-3 py-3">
|
||||||
<Button onclick={openProfile} class="shrink-0 self-start">
|
<Button onclick={openProfile} class="shrink-0 self-start">
|
||||||
<ProfileCircle pubkey={node.comment.pubkey} {url} size={8} />
|
<ProfileCircle pubkey={node.comment.pubkey} {url} size={8} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -95,6 +95,7 @@
|
||||||
<!-- The thread line runs under the avatar's center and indents replies to line up with
|
<!-- The thread line runs under the avatar's center and indents replies to line up with
|
||||||
this comment's text column; it brightens while the subtree is hovered. -->
|
this comment's text column; it brightens while the subtree is hovered. -->
|
||||||
<div
|
<div
|
||||||
|
data-component="CommentReplies"
|
||||||
class="border-line-less hover:border-line ml-4 flex flex-col border-l pl-7 transition-colors">
|
class="border-line-less hover:border-line ml-4 flex flex-col border-l pl-7 transition-colors">
|
||||||
{#each node.children as child (child.comment.id)}
|
{#each node.children as child (child.comment.id)}
|
||||||
<CommentTree node={child} {root} {replyTo} {setReplyTo} {url} {context} />
|
<CommentTree node={child} {root} {replyTo} {setReplyTo} {url} {context} />
|
||||||
|
|
|
||||||
|
|
@ -94,14 +94,17 @@
|
||||||
|
|
||||||
if (!h) return
|
if (!h) return
|
||||||
|
|
||||||
const pins = isPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id]
|
// The optimistic write flips isPinned while the publish is in flight, so what this did is read
|
||||||
|
// before it goes out rather than after.
|
||||||
|
const wasPinned = isPinned
|
||||||
|
const pins = wasPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id]
|
||||||
const command = await $roomPinLists.setPins(url, h, pins)
|
const command = await $roomPinLists.setPins(url, h, pins)
|
||||||
const error = await command.publishToRelays([url]).waitForError()
|
const error = await command.publishToRelays([url]).waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
} else {
|
} else {
|
||||||
pushToast({message: isPinned ? "Message unpinned" : "Message pinned"})
|
pushToast({message: wasPinned ? "Message unpinned" : "Message pinned"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -96,14 +96,17 @@
|
||||||
|
|
||||||
history.back()
|
history.back()
|
||||||
|
|
||||||
const pins = isPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id]
|
// The optimistic write flips isPinned while the publish is in flight, so what this did is read
|
||||||
|
// before it goes out rather than after.
|
||||||
|
const wasPinned = isPinned
|
||||||
|
const pins = wasPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id]
|
||||||
const command = await $roomPinLists.setPins(url, h, pins)
|
const command = await $roomPinLists.setPins(url, h, pins)
|
||||||
const error = await command.publishToRelays([url]).waitForError()
|
const error = await command.publishToRelays([url]).waitForError()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
pushToast({theme: "error", message: error})
|
pushToast({theme: "error", message: error})
|
||||||
} else {
|
} else {
|
||||||
pushToast({message: isPinned ? "Message unpinned" : "Message pinned"})
|
pushToast({message: wasPinned ? "Message unpinned" : "Message pinned"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,11 @@
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
{#if secondary}
|
{#if secondary}
|
||||||
<label class="flex items-center gap-2">
|
<!-- Not a label: what goes here is a control of its own, and a button wrapped in a label
|
||||||
|
loses its accessible name. -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
{@render secondary()}
|
{@render secondary()}
|
||||||
</label>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{@render input?.()}
|
{@render input?.()}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,25 @@
|
||||||
let loading = $state(false)
|
let loading = $state(false)
|
||||||
let mutedPubkeys = $state($userMuteList?.pubkeys() ?? [])
|
let mutedPubkeys = $state($userMuteList?.pubkeys() ?? [])
|
||||||
let blossomServers = $state($userBlossomServerList?.urls() ?? [])
|
let blossomServers = $state($userBlossomServerList?.urls() ?? [])
|
||||||
|
let loadedMutes = Boolean($userMuteList)
|
||||||
|
let loadedServers = Boolean($userBlossomServerList)
|
||||||
|
|
||||||
|
// Both lists come off the wire, so on a fresh load they land after this page has mounted. Each
|
||||||
|
// field takes its stored value up when it arrives, since a form that never saw it would publish
|
||||||
|
// its own emptiness back over it.
|
||||||
|
$effect(() => {
|
||||||
|
if (!loadedMutes && $userMuteList) {
|
||||||
|
loadedMutes = true
|
||||||
|
mutedPubkeys = $userMuteList.pubkeys()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!loadedServers && $userBlossomServerList) {
|
||||||
|
loadedServers = true
|
||||||
|
blossomServers = $userBlossomServerList.urls()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form {onsubmit}>
|
<form {onsubmit}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue