From 5dc8f9ddaf3c0e588b3064a10b11f558bd0b3e1e Mon Sep 17 00:00:00 2001 From: Coracle-Bot Date: Thu, 24 Sep 2026 18:23:21 +0000 Subject: [PATCH] Sweep the multi-line comments out of src and e2e (#640) --- AGENTS.md | 2 +- e2e/harness/app/boot.ts | 23 +-- e2e/harness/app/cache.ts | 12 +- e2e/harness/app/nip07.ts | 11 +- e2e/harness/app/session.ts | 7 +- e2e/harness/app/webln.ts | 13 +- e2e/harness/faults.ts | 21 +-- e2e/harness/files.ts | 8 +- e2e/harness/index.ts | 77 +++------ e2e/harness/keys.ts | 15 +- e2e/harness/net/context.ts | 4 +- e2e/harness/net/http.ts | 156 +++++------------- e2e/harness/net/websocket.ts | 35 +--- e2e/harness/seed/openRelay.ts | 13 +- e2e/harness/seed/publish.ts | 20 +-- e2e/harness/seed/scenario.ts | 20 +-- e2e/harness/seed/space.ts | 41 ++--- e2e/harness/ui.ts | 52 ++---- e2e/harness/zooid/config.ts | 24 +-- e2e/harness/zooid/relay.ts | 90 +++------- e2e/harness/zooid/testRelay.ts | 9 +- e2e/harness/zooid/transport.ts | 37 +---- e2e/harness/zooid/types.ts | 14 +- e2e/specs/admin.spec.ts | 61 ++----- e2e/specs/articles-threads.spec.ts | 65 +++----- e2e/specs/auth.spec.ts | 3 +- e2e/specs/community.spec.ts | 65 +++----- e2e/specs/composer.spec.ts | 57 ++----- e2e/specs/content-rendering.spec.ts | 44 ++--- e2e/specs/delivery.spec.ts | 60 +++---- e2e/specs/dms.spec.ts | 101 ++++-------- e2e/specs/notifications.spec.ts | 140 +++++----------- e2e/specs/onboarding.spec.ts | 30 +--- e2e/specs/people.spec.ts | 75 +++------ e2e/specs/rooms.spec.ts | 90 +++------- e2e/specs/routing.spec.ts | 42 ++--- e2e/specs/settings.spec.ts | 30 +--- e2e/specs/space.spec.ts | 8 +- e2e/specs/spaces.spec.ts | 49 ++---- src/app.d.ts | 1 - src/app/access.ts | 9 +- src/app/actionItems.ts | 3 +- src/app/articles.ts | 3 +- src/app/calendar.ts | 9 +- src/app/call.ts | 60 +------ src/app/callEngine.ts | 65 ++------ src/app/chats.ts | 37 +---- src/app/commands.ts | 26 +-- src/app/components/CalendarAgenda.svelte | 13 +- src/app/components/CalendarDay.svelte | 4 +- src/app/components/CalendarMonth.svelte | 4 +- src/app/components/CalendarWeek.svelte | 3 +- src/app/components/CallBanner.svelte | 3 +- src/app/components/CallControlBar.svelte | 3 +- src/app/components/Chat.svelte | 10 +- src/app/components/ChatCompose.svelte | 3 +- src/app/components/ClassifiedActions.svelte | 3 +- src/app/components/CommandArgBar.svelte | 6 +- src/app/components/CommentCompose.svelte | 7 +- src/app/components/Content.svelte | 4 +- src/app/components/ContentMarkdown.svelte | 19 +-- src/app/components/ContentQuote.svelte | 5 +- src/app/components/DictationAction.svelte | 3 +- src/app/components/DictationButton.svelte | 15 +- src/app/components/EventReactions.svelte | 3 +- src/app/components/EventReply.svelte | 3 +- src/app/components/HomeHosting.svelte | 3 +- src/app/components/HomeNetwork.svelte | 9 +- src/app/components/InputProfilePicture.svelte | 3 +- src/app/components/KeyDownload.svelte | 3 +- src/app/components/LogInBunker.svelte | 3 +- src/app/components/MicLevelMeter.svelte | 5 +- src/app/components/ModalContainer.svelte | 4 +- src/app/components/PinItem.svelte | 3 +- src/app/components/PollCreate.svelte | 3 +- src/app/components/PrimaryNav.svelte | 3 +- src/app/components/PrimaryNavSpaces.svelte | 3 +- src/app/components/ProfileStatus.svelte | 3 +- src/app/components/QRCode.svelte | 3 +- src/app/components/ReactionSummary.svelte | 3 +- src/app/components/RelayAdd.svelte | 3 +- src/app/components/RoleEdit.svelte | 3 +- src/app/components/RoomChat.svelte | 59 ++----- src/app/components/RoomCompose.svelte | 3 +- src/app/components/RoomItemMenu.svelte | 3 +- src/app/components/RoomItemMenuMobile.svelte | 3 +- src/app/components/SearchBody.svelte | 3 +- src/app/components/SocketStatusToast.svelte | 3 +- src/app/components/SpaceMemberMethods.svelte | 3 +- src/app/components/SpaceMenuNavItems.svelte | 7 +- src/app/components/SpeechBanner.svelte | 3 +- src/app/components/ThunkPending.svelte | 3 +- src/app/components/VideoCallContent.svelte | 6 +- .../VoiceCallAudioSettingsDialog.svelte | 7 +- src/app/components/VoiceRoomItem.svelte | 3 +- src/app/components/VoiceRoomJoinDialog.svelte | 7 +- .../components/hosting/BillingPrompts.svelte | 3 +- .../components/hosting/PaymentDialog.svelte | 3 +- .../components/hosting/PaymentSetup.svelte | 6 +- src/app/components/hosting/RelayCreate.svelte | 3 +- .../components/hosting/RelayDetailCard.svelte | 6 +- src/app/content.ts | 3 +- src/app/core.ts | 23 +-- src/app/dictation.ts | 18 +- src/app/editor/EditorContent.svelte | 3 +- src/app/editor/index.ts | 29 +--- src/app/env.ts | 8 +- src/app/featured.ts | 3 +- src/app/feeds.ts | 104 ++++-------- src/app/goals.ts | 3 +- src/app/hosting.ts | 30 +--- src/app/inbox.ts | 6 +- src/app/keyboard.ts | 3 +- src/app/lightning.ts | 6 +- src/app/loading.ts | 3 +- src/app/logger.ts | 4 +- src/app/management.ts | 16 +- src/app/modal.ts | 8 +- src/app/notifications.ts | 5 +- src/app/pinboards.ts | 3 +- src/app/policies.ts | 17 +- src/app/pomade.ts | 3 +- src/app/push/adapters/android.ts | 3 +- src/app/reactions.ts | 3 +- src/app/render.ts | 3 +- src/app/repository.ts | 3 +- src/app/rooms.ts | 17 +- src/app/routes.ts | 10 +- src/app/session.ts | 14 +- src/app/settings.ts | 15 +- src/app/share.ts | 3 +- src/app/signer.ts | 3 +- src/app/social.ts | 39 ++--- src/app/speech.ts | 10 +- src/app/statuses.ts | 3 +- src/app/storage.ts | 33 +--- src/app/sync.ts | 7 +- src/app/thunks.ts | 13 +- src/app/title.ts | 3 +- src/app/uploads.ts | 9 +- src/lib/components/Button.svelte | 5 +- src/lib/components/Cv.svelte | 11 +- src/lib/components/DateTimeInput.svelte | 6 +- src/lib/components/DateTimeRangeInput.svelte | 6 +- src/lib/components/DragList.svelte | 6 +- src/lib/components/Icon.svelte | 3 +- src/lib/components/IconInput.svelte | 3 +- src/lib/components/Masonry.svelte | 4 +- src/lib/components/Spinner.svelte | 3 +- src/lib/components/Tippy.svelte | 8 +- src/lib/components/VirtualList.svelte | 12 +- src/lib/html.ts | 30 +--- src/lib/indexeddb.ts | 7 +- src/lib/test/env.ts | 6 +- src/lib/test/session.ts | 9 +- src/lib/util.ts | 5 +- src/routes/+layout.svelte | 25 +-- src/routes/settings/content/+page.svelte | 4 +- src/routes/settings/hosting/+page.svelte | 7 +- src/routes/spaces/+page.svelte | 3 +- src/routes/spaces/[relay]/+layout.svelte | 11 +- .../spaces/[relay]/articles/+page.svelte | 3 +- .../spaces/[relay]/calendar/+page.svelte | 4 +- .../spaces/[relay]/classifieds/+page.svelte | 3 +- .../spaces/[relay]/directory/+page.svelte | 6 +- src/routes/spaces/[relay]/goals/+page.svelte | 6 +- .../spaces/[relay]/library/+page.svelte | 3 +- src/routes/spaces/[relay]/polls/+page.svelte | 3 +- .../spaces/[relay]/threads/+page.svelte | 3 +- .../spaces/[relay]/threads/[id]/+page.svelte | 3 +- 170 files changed, 787 insertions(+), 2049 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3424aa68..8ee0e52c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ These are the things that most often get fixed by hand after the fact. Go throug them before handing work back. The full style reference is under Development Conventions below. -- **Comments** — only for genuinely surprising things: a workaround, a constraint imposed by a backend or platform, an invariant that isn't visible from the code in front of you. Never comment props, never restate what the next line does, never justify an ordinary decision. If a small refactor would make the comment stale, don't write it. +- **Comments** — a comment is one line, for a genuinely surprising thing: a workaround, a constraint imposed by a backend or platform, an invariant that isn't visible from the code in front of you. Never comment props, never restate what the next line does, never justify an ordinary decision. Where one line can't hold it, the argument belongs in the pull request rather than in the code. If a small refactor would make the comment stale, don't write it. - **Used once, inlined** — a derived value, helper, type, or named constant with a single use is indirection. Write `setTimeout(pollOnce, 3500)`, not a `POLL_INTERVAL` referenced twice in one file. Name something only when the name is what makes the code readable. - **Positive conditionals** — prefer `if (ready) { ... }` over `if (!ready) return`. Nesting is fine; when it gets deep that's the signal the function is doing too much, so split it. Many early returns belong in validation or pipeline functions, not everywhere else. - **Truthiness** — test values directly (`if (invoice.paid_at)`) instead of comparing against `null`/`undefined`, and put the truthy branch first in a ternary. diff --git a/e2e/harness/app/boot.ts b/e2e/harness/app/boot.ts index b6cdc348..f9e55162 100644 --- a/e2e/harness/app/boot.ts +++ b/e2e/harness/app/boot.ts @@ -7,24 +7,20 @@ import {injectEvents, injectSession} from "./session" // Must match TEST_ENV_KEY in src/lib/test/env.ts. const TEST_ENV_KEY = "__TEST_ENV__" -// Set the first time the app reads a value out of the injected env, the only evidence this side -// has that the hook in src/app/env.ts ran. +// Set the first time the app reads the injected env, the only evidence here that src/app/env.ts ran. const TEST_ENV_READ_KEY = "__TEST_ENV_READ__" export type BootOptions = { - // Every relay list the app reads at startup is pointed here, so it can only dial relays the - // scenario created. + // Every relay list the app reads at startup is pointed here, so it can only dial the scenario's. relays: string[] - // What a pubkey's own lists are resolved from, which is a relay of its own only when the - // scenario opened one. Defaults to `relays`. + // What a pubkey's own lists are resolved from, defaulting to `relays`. indexers?: string[] spaces?: string[] user?: TestUser // What this user's client already has in local storage, e.g. their room list. events?: TrustedEvent[] path?: string - // VITE_ values the scenario sets for itself, applied over the relay-derived ones below. Anything - // named here has to be something the test owns, or the test fails on a leak. + // VITE_ values the scenario sets for itself. Anything named here has to be something the test owns. env?: Record } @@ -58,8 +54,7 @@ export const boot = async ( VITE_DEFAULT_SPACES: spaces.join(","), VITE_PLATFORM_RELAYS: "", VITE_BLOCKED_RELAYS: "", - // Nothing serves this url, so a push bridge connection is reported as a leak instead of - // blending into the traffic of a relay the scenario did create. + // Nothing serves this url, so a push bridge connection is reported as a leak. VITE_PUSH_BRIDGE: "ws://localhost:1/", ...env, }, @@ -75,10 +70,7 @@ export const boot = async ( await page.goto(path) - // The root layout renders nothing until its async setup block resolves, so the shell appearing - // is the first point at which the app is running. With a session injected, wait for the signed-in - // nav instead. A session the app rejected renders the landing dialog, and failing on that here - // reads far better than the assertions it would break later. + // The root layout renders nothing until its async setup resolves, and a rejected session renders the landing dialog. const shell = page.locator(user ? ".primary-nav" : ".fl") for (let attempt = 0; ; attempt++) { @@ -94,8 +86,7 @@ export const boot = async ( } } - // src/app/env.ts reads every VITE_ value as it is imported, so by now the app has resolved them - // against either the env above or .env's real relays. + // src/app/env.ts reads every VITE_ value as it is imported, so by now the app has resolved them. const usedTestEnv = await page.evaluate( key => Boolean(Reflect.get(window, key)), TEST_ENV_READ_KEY, diff --git a/e2e/harness/app/cache.ts b/e2e/harness/app/cache.ts index 29c44c0f..ae067087 100644 --- a/e2e/harness/app/cache.ts +++ b/e2e/harness/app/cache.ts @@ -1,15 +1,10 @@ import type {Page} from "@playwright/test" import type {TrustedEvent} from "@welshman/util" -// Must match the database name and the `events` table in src/app/storage.ts, which are scoped to -// one identity. +// Must match the database name and the `events` table in src/app/storage.ts. const databaseName = (pubkey: string) => `flotilla-9gl-${pubkey}` -/** - * What this user's client has written to disk so far. Events reach indexeddb in three-second - * batches with nothing in the ui to say when one has landed, so a spec about what survives a - * restart waits on this before it reloads. - */ +/** What this user's client has written to disk. Events reach indexeddb in three-second batches. */ export const readCachedEvents = (page: Page, pubkey: string): Promise => page.evaluate(async name => { const open = await new Promise((resolve, reject) => { @@ -19,8 +14,7 @@ export const readCachedEvents = (page: Page, pubkey: string): Promise reject(request.error) }) - // An unversioned open creates the database when it is missing, so a client that has not written - // anything yet has no store to read. + // An unversioned open creates the database when it is missing, so a client with no writes has no store. if (!open.objectStoreNames.contains("events")) { open.close() diff --git a/e2e/harness/app/nip07.ts b/e2e/harness/app/nip07.ts index d9409981..4f76d9d9 100644 --- a/e2e/harness/app/nip07.ts +++ b/e2e/harness/app/nip07.ts @@ -2,8 +2,7 @@ import type {BrowserContext} from "@playwright/test" import type {StampedEvent} from "@welshman/util" import type {TestUser} from "../keys" -// The function playwright installs on window for the shim below to call into. The keys live in -// node, so a signer built in the page would not be the one seeding signs with. +// The keys live in node, so a signer built in the page would not be the one seeding signs with. const TEST_NIP07_KEY = "__TEST_NIP07__" type Nip07Call = @@ -11,13 +10,7 @@ type Nip07Call = | {method: "signEvent"; template: StampedEvent} | {method: "encrypt" | "decrypt"; scheme: "nip04" | "nip44"; pubkey: string; message: string} -/** - * A NIP-07 provider backed by a test identity's real signer, so an extension login produces - * signatures the relays accept, including the NIP-42 auth events a members-only relay demands. - * - * Both halves have to be installed before the page navigates. LogIn.svelte reads `window.nostr` - * while it renders, to decide whether to offer the button at all. - */ +/** A NIP-07 provider backed by a test identity's real signer, installed before the page navigates. */ export const injectNip07 = async (context: BrowserContext, user: TestUser) => { await context.exposeBinding(TEST_NIP07_KEY, (source, call: Nip07Call) => { if (call.method === "getPublicKey") { diff --git a/e2e/harness/app/session.ts b/e2e/harness/app/session.ts index 1646d42b..cf2a8dab 100644 --- a/e2e/harness/app/session.ts +++ b/e2e/harness/app/session.ts @@ -7,9 +7,7 @@ const TEST_SESSION_KEY = "__TEST_SESSION__" const TEST_EVENTS_KEY = "__TEST_EVENTS__" -// A nip01 session in the {method, data} shape @welshman/app's session handlers deserialize, so -// restoreSession can build a signer from it without the storage encoding a real login goes through. -// addInitScript runs before any page script, so this has to be called before navigating. +// The {method, data} shape @welshman/app's session handlers deserialize, installed before navigating. export const injectSession = (context: BrowserContext, user: TestUser) => context.addInitScript( ([key, session]) => { @@ -18,8 +16,7 @@ export const injectSession = (context: BrowserContext, user: TestUser) => [TEST_SESSION_KEY, {method: "nip01", data: {secret: user.secret}}] as const, ) -// The repository contents the app loads once the injected session is restored, the local cache a -// returning user would boot with. +// The local cache a returning user would boot with, loaded once the injected session is restored. export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) => context.addInitScript( ([key, value]) => { diff --git a/e2e/harness/app/webln.ts b/e2e/harness/app/webln.ts index 02e58cb0..46d2500d 100644 --- a/e2e/harness/app/webln.ts +++ b/e2e/harness/app/webln.ts @@ -1,22 +1,13 @@ import type {BrowserContext} from "@playwright/test" -// What `getInfo` answers with. `supports` is what src/app/components/WalletConnect.svelte gates the -// connection on, and the node's alias is what the wallet page names the connection by. +// `supports` is what WalletConnect.svelte gates the connection on, and the alias names it on screen. export type WebLnInfo = { supports?: string[] node?: {alias?: string; pubkey?: string} version?: string } -/** - * A WebLN provider on `window`, in the shape a browser extension installs. Connecting is a - * capability handshake and nothing more, so the whole provider answers from a literal in the page — - * paying an invoice and issuing one are past the boundary this harness stops at, and calling either - * here throws rather than pretending. - * - * Install it before the page navigates. WalletConnect reads `window.webln` while it renders, to - * decide whether to offer the button at all. - */ +/** A WebLN provider on `window`, installed before the page navigates: WalletConnect reads it while it renders. */ export const injectWebLn = (context: BrowserContext, info: WebLnInfo = {}) => context.addInitScript( $info => { diff --git a/e2e/harness/faults.ts b/e2e/harness/faults.ts index 35e75dc6..f433ec60 100644 --- a/e2e/harness/faults.ts +++ b/e2e/harness/faults.ts @@ -1,20 +1,13 @@ import {inspect} from "node:util" import type {BrowserContext, ConsoleMessage} from "@playwright/test" -// A CSP refusal reaches the console and nothing else, so a policy that has rotted past the script -// it names is invisible to every spec: app.html's requestIdleCallback shim was refused on every -// platform for a week with the suite green (#535). +// A CSP refusal reaches the console and nothing else, so a policy that has rotted is invisible (#535). const isRefusal = (text: string) => text.includes("Content Security Policy") -// Every test recreates the zooid container, chromium aborts what the page had in flight when the -// interfaces churn, and sveltekit reports a route chunk lost that way as an uncaught TypeError. It -// reaches nearly every spec — 256 of the 258 faults a full survey run raised — so it stays in the -// log and out of the fault set until #529 stops the churn. +// Recreating the container between tests aborts route chunks in flight, until #529 stops the churn. const isChunkLoss = (text: string) => text.includes("Failed to fetch dynamically imported module") -// Chrome reports a failed request as "Failed to load resource: the server responded with a status -// of 404 (Not Found)" and carries the url nowhere but the message's location, so a line built from -// the text alone cannot say which resource went missing. +// Chrome carries a failed request's url nowhere but the message's location. const locate = (message: ConsoleMessage) => { const text = message.text() const {url} = message.location() @@ -22,9 +15,7 @@ const locate = (message: ConsoleMessage) => { return url && !text.includes(url) ? `${text} ${url}` : text } -// Playwright builds this from the page's exception details, and a page that throws something other -// than an Error leaves it with neither message nor stack — which is how a fault used to reach the -// console log as a bare "uncaught:". +// A page that throws something other than an Error leaves playwright neither message nor stack. const describe = (error: Error) => error.stack || error.message || inspect(error) export type FaultWatch = { @@ -33,9 +24,7 @@ export type FaultWatch = { // The subset of it that means the app broke rather than the box being noisy. found: string[] observe(context: BrowserContext, who: string): void - // Throws when the app itself broke while the test ran: an uncaught exception, or code of ours the - // browser refused to run. A failed request or a noisy warning is neither — a dev server is full - // of both — so those stay in the log. + // Throws on an uncaught exception or code the browser refused to run, not on a failed request. assertNone(): void } diff --git a/e2e/harness/files.ts b/e2e/harness/files.ts index 7e5575bd..3d659dec 100644 --- a/e2e/harness/files.ts +++ b/e2e/harness/files.ts @@ -7,10 +7,7 @@ export type TestFile = { buffer: Buffer } -// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched -// instead of re-encoding it through a canvas, so the bytes the server hashes are the bytes chosen -// here and the url an upload resolves to is predictable from node. The base64 is what a spec hands -// to the page, since a Buffer does not survive the trip into evaluate(). +// Gif rather than png, since compressFileForUpload passes it through instead of re-encoding it. export const GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" export const GIF = Buffer.from(GIF_BASE64, "base64") @@ -20,8 +17,7 @@ export const WEBP = Buffer.from("UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA= export const gifFile = (name: string): TestFile => ({name, mimeType: "image/gif", buffer: GIF}) -// Every picker in the app opens the browser's own chooser, which is the only place a spec can hand -// it a file: the input behind it is never on screen. +// Every picker opens the browser's own chooser, and the input behind it is never on screen. export const chooseFile = async (page: Page, button: Locator, file: TestFile) => { const chooser = page.waitForEvent("filechooser") diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts index 2c2869f0..5537f10d 100644 --- a/e2e/harness/index.ts +++ b/e2e/harness/index.ts @@ -99,8 +99,7 @@ export type { } from "./net/http" export type {WebLnInfo} from "./app/webln" -// Mirrors encodeRelay in src/app/relays.ts. Importing it reaches the app's module graph, and with -// it sveltekit. +// Mirrors encodeRelay in src/app/relays.ts, which cannot be imported without pulling in sveltekit. const encodeRelay = (url: string) => encodeURIComponent( normalizeRelayUrl(url) @@ -112,51 +111,40 @@ export const spacePath = (url: string) => `/spaces/${encodeRelay(url)}` export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}` -// The path the app builds for a conversation: the other participants' pubkeys, sorted and joined -// with commas — see makeChatId in src/app/chats.ts. +// Mirrors makeChatId in src/app/chats.ts. export const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}` export const profilePath = (pubkey: string) => `/people/${npubEncode(pubkey)}` -// A literal as a pattern, for a url that carries a query string or a modal's hash alongside the -// path being matched, or a host whose dots would otherwise be wildcards. +// Matches a path literally, for one whose query string, hash or dots would otherwise be wildcards. export const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) // What a page is opened with, over and above the scenario's own relays. export type PageOptions = { - // Overrides the project's context options, for a spec that needs a viewport or a permission of - // its own. + // Overrides the project's context options, for a spec needing its own viewport or permission. context?: BrowserContextOptions - // VITE_ values applied over the ones derived from the scenario's relays, e.g. a platform space or - // the domain hosted relays are created under. See BootOptions in app/boot.ts. + // VITE_ values applied over the ones derived from the scenario's relays. See BootOptions in app/boot.ts. env?: Record // A NIP-07 provider signing as this user, for a login that goes through an extension. nip07?: TestUser // A WebLN provider on window, for a wallet that gets connected through an extension. webln?: WebLnInfo - // A blossom server, installed before the page boots. mockBlossom called on the page `as()` - // returns arrives after src/app/sync.ts has probed and cached a space's own url, so a spec whose - // server is one the app probes on load has to name it here instead. + // A blossom server installed before boot, for a url src/app/sync.ts probes and caches on load. blossom?: BlossomOptions // Fields merged over a relay's own nip-11 document, keyed by relay url. relayInfo?: RelayInfoOverrides - // What the hosting backend already knows about this user. Read `getHosting(page.context())` for - // the handle that changes it mid-test. + // What the hosting backend already knows about this user; getHosting changes it mid-test. hosting?: HostingFixtures - // Relay urls that take the socket and answer nothing, from the page's first connection onward. - // `silenceRelay(page.context(), url)` is the same fault applied mid-test, which takes effect on - // the next connection rather than this one. + // Relay urls that take the socket and answer nothing; silenceRelay does the same mid-test. silent?: string[] } export type Harness = { zooid: Zooid seed(build: (tools: SeedTools) => MaybeAsync): Promise - // A logged-in page for a user, in its own browser context, with its own storage and its own - // sockets into the relays every other user is talking to. + // A logged-in page for a user, in its own browser context. as(user: TestUser, path?: string, options?: PageOptions): Promise - // The same page with no session injected, which is the only way to watch a login or a logout - // happen. + // The same page with no session injected, which is the only way to watch a login or a logout. visit(path?: string, options?: PageOptions): Promise } @@ -172,8 +160,7 @@ export type HarnessWorkerFixtures = { } export const test = base.extend({ - // Playwright's own context, and the `page` fixture built on it, is unrouted, so a page born - // there boots the app against the relays baked into .env. + // Playwright's own context is unrouted, so a page born there boots against the relays in .env. context: async () => { throw new Error( "The built-in `context` and `page` fixtures reach the real network. Open a page with the " + @@ -181,19 +168,16 @@ export const test = base.extend({ "they navigate.", ) }, - // Playwright builds this one with `playwright.request.newContext()`, an http client in this - // process that belongs to no browser context, so `installHttpRoutes` cannot see it. + // Playwright builds this with request.newContext(), which belongs to no browser context. request: async () => { throw new Error( "The built-in `request` fixture makes http requests from node, where nothing intercepts " + "them. Anything the app fetches belongs in a mock installed by `as(user, path)`.", ) }, - // One container per worker, torn down when the worker ends. It is recreated between tests, in - // the teardown of the test that finishes rather than the setup of the one that starts. + // One container per worker, recreated in the teardown of the test that finishes. zooid: [ - // playwright reads a fixture's dependencies off this pattern, so it has to stay a pattern - // even when there are none. + // playwright reads a fixture's dependencies off this pattern, so it stays a pattern. // eslint-disable-next-line no-empty-pattern async ({}, use) => { const zooid = new Zooid() @@ -228,12 +212,7 @@ export const test = base.extend({ const {urls, indexerUrls, cache} = requireScenario() await zooid.settle() - // The project's own `use` first, so a viewport or device descriptor set in - // playwright.config.ts reaches the context rather than being dropped. - // - // context.route does not see a request a service worker makes, and sveltekit registers - // src/service-worker.js on every navigation in dev, so registration is blocked rather than - // left as the thing containment rests on. + // context.route does not see a request a service worker makes, and sveltekit registers one in dev. const context = await browser.newContext({ ...testInfo.project.use, serviceWorkers: "block", @@ -242,14 +221,10 @@ export const test = base.extend({ contexts.push({name: user?.name ?? "anonymous", context}) - // `use.trace` and the built-in reporting only cover contexts playwright made itself, so a - // failure in a context opened here arrives with the app's own account of it thrown away — - // which is how a spec that caught the app on its 500 page had nothing to say about why. + // use.trace and the built-in reporting only cover contexts playwright made itself. faults.observe(context, user?.name ?? "anonymous") - // Playwright matches the most recently registered route first and every mock falls through - // what it doesn't recognize, so the block-all goes in before the mocks, and all of it before - // the page navigates. + // Playwright matches the most recently registered route first, so the block-all goes in first. await installHttpRoutes(context) await installWebSocketRoutes(context, zooid) @@ -276,9 +251,7 @@ export const test = base.extend({ await injectWebLn(context, options.webln) } - // Headless Chromium reports `Notification.permission` as "denied" even where playwright has - // granted the permission at the browser level, so the grant is reflected into the API the app - // reads. + // Headless Chromium reports Notification.permission as "denied" whatever playwright granted. if (options.context?.permissions?.includes("notifications")) { await context.addInitScript(() => { Object.defineProperty(Notification, "permission", { @@ -311,28 +284,22 @@ export const test = base.extend({ }, }) - // Closed before anything is read off it: an $effect teardown reading a binding svelte has - // already cleared throws on unmount, and that is the fault the suite was blindest to. + // Closed before anything is read off it: an $effect teardown reading a cleared binding throws. for (const {context} of contexts) { await context.close() } - // Before the assertions below, which throw: a test that fails still owes the next one a - // container, and with every page already closed there is nothing left for the recreate's - // network churn to interrupt. + // Before the assertions below, which throw: a failing test still owes the next one a container. await zooid.reset() if (faults.found.length > 0 || testInfo.status !== testInfo.expectedStatus) { - // A path rather than a body: the list reporter truncates an inline attachment, and the - // nightly run on the box keeps test-results and nothing else. + // A path rather than a body, since the list reporter truncates an inline attachment. const consolePath = testInfo.outputPath("browser-console.log") await writeFile(consolePath, faults.log.join("\n")) await testInfo.attach("browser-console", {path: consolePath, contentType: "text/plain"}) - // What each user said to each relay and what came back. The console says what the app did - // with an event; this says whether it ever had one, which is the only way to tell a client - // that dropped a message from a relay that never sent it. + // Whether a user ever had an event, which tells a client that dropped one from a relay that never sent it. const transcriptPath = testInfo.outputPath("relay-transcript.log") await writeFile( diff --git a/e2e/harness/keys.ts b/e2e/harness/keys.ts index d7087140..59165a2f 100644 --- a/e2e/harness/keys.ts +++ b/e2e/harness/keys.ts @@ -9,8 +9,7 @@ export type TestUser = { signer: Nip01Signer } -// Every identity this process can sign as. zooid authenticates each write and refuses an event -// whose author is not the authenticated pubkey, so seeding looks a fixture's author up here. +// zooid refuses an event whose author is not the authenticated pubkey, so seeding looks the author up here. export const testUsersByPubkey = new Map() const makeUser = (name: string, secret: string): TestUser => { @@ -21,8 +20,7 @@ const makeUser = (name: string, secret: string): TestUser => { return user } -// The secrets never leave the test process. They are stable across runs, so a pubkey can be -// asserted on directly, and the leading nibbles name an author in a failed-assertion diff. +// Stable across runs, so a pubkey can be asserted on and its leading nibbles name an author in a diff. export const users = { alice: makeUser("alice", "a11ce00000000000000000000000000000000000000000000000000000000001"), bob: makeUser("bob", "b0b0000000000000000000000000000000000000000000000000000000000002"), @@ -30,15 +28,10 @@ export const users = { admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"), } -// secp256k1's group order. A secret is a scalar in [1, n), so the digest below is reduced into -// that range rather than rejected when it falls outside. +// secp256k1's group order. A secret is a scalar in [1, n), so the digest below is reduced into it. const CURVE_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") -/** - * An identity beyond the four above, named rather than listed. The secret is derived from the name, - * so the pubkey is as stable across runs, and minting one registers it: a scenario can seed for it - * like any other test user. - */ +/** An identity beyond the four above, derived from its name so its pubkey is stable, and registered on minting. */ export const makeTestUser = (name: string) => { const digest = BigInt("0x" + createHash("sha256").update(name).digest("hex")) const secret = ((digest % (CURVE_ORDER - 1n)) + 1n).toString(16).padStart(64, "0") diff --git a/e2e/harness/net/context.ts b/e2e/harness/net/context.ts index 21823751..08ba509a 100644 --- a/e2e/harness/net/context.ts +++ b/e2e/harness/net/context.ts @@ -1,8 +1,6 @@ import type {BrowserContext} from "@playwright/test" -// State the harness keeps per browser context, outside the browser: recorded traffic, blocked -// requests, the hosting fake's store. Reading one before the route installer that owns it has run -// is a spec calling things out of order, which the error names. +// Reading one before the route installer that owns it has run is a spec calling things out of order. export const makeContextStore = (installer: string) => { const byContext = new WeakMap() diff --git a/e2e/harness/net/http.ts b/e2e/harness/net/http.ts index 0f3d4bbe..9697fd7c 100644 --- a/e2e/harness/net/http.ts +++ b/e2e/harness/net/http.ts @@ -8,9 +8,7 @@ import {tenantByUrl} from "../zooid/config" import {requestZooid} from "../zooid/transport" import {makeContextStore} from "./context" -// Mirrors the service urls in src/app/env.ts and .env, which this process can't import because -// env.ts reads import.meta.env and pulls in Capacitor. These are route patterns rather than urls -// anything fetches, and every handler answers from memory. +// Mirrors the service urls in src/app/env.ts, which reads import.meta.env and pulls in Capacitor. const DUFFLEPUD_ORIGIN = "https://dufflepud.coracle.social" const PUSH_SERVER_ORIGIN = "https://nps.flotilla.social" const HOSTING_ORIGIN = "https://api.hosting.coracle.social" @@ -21,8 +19,7 @@ const PLAUSIBLE_ORIGIN = "https://plausible.coracle.social" // Transcription and speech both go here, against whichever key the user has saved. const OPENROUTER_ORIGIN = "https://openrouter.ai" -// Where the hosting api sends a browser to pay. `.test` resolves nowhere and the block-all aborts -// the navigation, so a spec sees the redirect without one leaving. +// .test resolves nowhere and the block-all aborts the navigation, so a spec sees the redirect. const CHECKOUT_ORIGIN = "https://checkout.test" // Relay-hosted livekit lives under a well-known path rather than an origin of its own. @@ -34,9 +31,7 @@ const PNG = Buffer.from( "base64", ) -// 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. +// The dev server. Traffic to it is the app loading itself, so it is the one host both layers pass. export const isDevServerUrl = (url: URL) => url.port === (process.env.E2E_PORT ?? "1847") && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) @@ -55,18 +50,11 @@ export type BlockedRequest = { const blockedStore = makeContextStore("installHttpRoutes") -/** - * Blocks every http request the app makes, except to the dev server and to a relay's own origin, - * which is forwarded to the container so that the nip-11 document and the nip-86 management api the - * app reads are the real relay's. Install this first. The per-service mocks below are registered - * later and therefore take priority, so a request that still reaches this handler is one nothing - * has mocked. - */ +/** Blocks every http request but the dev server's and a relay's own origin. Install this first. */ export const installHttpRoutes = (context: BrowserContext) => { const blocked = blockedStore.set(context, []) - // The dev server is left unrouted rather than matched and continued. A sveltekit page in dev is - // hundreds of module requests, and none of them is egress. + // A sveltekit page in dev is hundreds of module requests, and none of them is egress. return context.route( url => !isDevServerUrl(url), async route => { @@ -94,12 +82,7 @@ export const installHttpRoutes = (context: BrowserContext) => { ) } -/** - * Opt-in, for a spec that wants every request the app made to have been answered by something the - * scenario stood up. Some of what a page asks for is meant to be refused, such as the blossom probe - * against a relay with blossom off, so call this from a spec that has mocked what it exercises - * rather than from teardown. - */ +/** Opt-in: some of what a page asks for is meant to be refused, so call it from a spec rather than teardown. */ export const assertNoBlockedRequests = (context: BrowserContext) => { const blocked = blockedStore.get(context) @@ -116,14 +99,7 @@ export const assertNoBlockedRequests = (context: BrowserContext) => { // Fields to merge over a relay's own nip-11 document, keyed by its url. export type RelayInfoOverrides = Record -/** - * A relay's real document with a few fields replaced, such as a `redirect_to`, a `limitation`, or a - * NIP the relay does not implement. It is merged rather than fabricated because `self`, `pubkey`, - * `name` and `supported_nips` are what room state is trusted from, and a hand-written document - * breaks every room on the page. - * - * Install it before the page navigates. The document is read at startup and cached from then on. - */ +/** A relay's real document with a few fields replaced. It is read at startup, so install it before navigating. */ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverrides) => { const overrideByOrigin = new Map( Object.entries(overrides).map(([url, override]): [string, object] => [ @@ -140,8 +116,7 @@ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverr const host = hostByOrigin.get(origin) const override = overrideByOrigin.get(origin) - // The nip-86 management api posts to this same path, and what it answers decides what the - // admin ui offers, so only the document request is touched. + // The nip-86 management api posts to this same path, so only the document request is touched. if (request.method() === "GET" && host && override) { const {status, headers, body} = await requestZooid(host, "GET", "/", { ...request.headers(), @@ -151,8 +126,7 @@ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverr return route.fulfill({ status, - // khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's own - // headers are kept, all but the length of a body that is about to change. + // khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's headers are kept. headers: omit(["content-length"], headers), body: JSON.stringify({...JSON.parse(body.toString()), ...override}), }) @@ -163,11 +137,7 @@ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverr ) } -/** - * The analytics script src/app.html loads on every page. Answering with an empty body leaves - * `window.plausible` as the queueing shim src/app/analytics.ts installs, so pageviews accumulate in - * memory and nothing is ever sent. - */ +/** The analytics script src/app.html loads. An empty body leaves window.plausible as the queueing shim. */ export const mockAnalytics = (context: BrowserContext) => context.route(`${PLAUSIBLE_ORIGIN}/**`, route => route.fulfill({contentType: "application/javascript", body: ""}), @@ -183,12 +153,7 @@ export type DufflepudFixtures = { zappers?: {lnurl: string; info?: Omit}[] } -/** - * Dufflepud, whose origin is the one service url the app hard-codes rather than reading from a - * `VITE_` value. `as()` installs it with no fixtures, so a spec that needs one calls this again - * with its own. Playwright matches the most recently registered route first, so the spec's answers - * win over the empty defaults. - */ +/** Dufflepud, the one service url the app hard-codes. `as()` installs it with no fixtures, so a spec calls this again. */ export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtures = {}) => context.route(`${DUFFLEPUD_ORIGIN}/**`, route => { const {pathname} = new URL(route.request().url()) @@ -214,12 +179,10 @@ export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtur return route.fallback() }) -// The rate the harness writes its silence at. Nothing downstream reads it: the app decodes what it -// is answered and writes its own header from the result. +// The app decodes what it is answered and writes its own header from the result. const SPEECH_RATE = 24000 -// Silence as a wav, built rather than inlined so a spec can ask for a length and then assert the -// duration the player reads off it. +// Built rather than inlined, so a spec can ask for a length and assert the duration read off it. const silence = (seconds: number) => { const bytes = seconds * SPEECH_RATE * 2 const wav = Buffer.alloc(44 + bytes) @@ -240,16 +203,10 @@ const silence = (seconds: number) => { return wav } -// The format the app asks the real endpoint for. The harness has no mp3 encoder, so it answers a -// wav instead — the app decodes either, and only an answer a browser can decode exercises that. +// The harness has no mp3 encoder, so it answers a wav, which the app decodes either way. const SPEECH_FORMAT = "mp3" -/** - * OpenRouter's text to speech, answering every request with the same silence. The array it returns - * collects what the app asked to have read, in the order it asked. Asking for raw pcm is refused - * the way the real endpoint refuses an unknown format, since pcm is the one answer whose sample - * rate and sample format the app would have to guess at. - */ +/** OpenRouter's text to speech, answering every request with the same silence. Raw pcm is refused. */ export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3) => { const spoken: string[] = [] @@ -272,18 +229,14 @@ export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3) } export type Transcription = { - // The name each recording was uploaded under, in the order it was uploaded. OpenRouter picks a - // decoder off the extension, so this is how a spec reads what the recorder produced. + // OpenRouter picks a decoder off the extension, so this is how a spec reads what the recorder made. uploads: string[] - // Stops answering. A request that arrives while this is on is held open until `release`, which - // is what a spec needs to watch a transcription that is still out. + // Stops answering. A request that arrives while this is on is held open until `release`. hold(): void release(): void } -/** - * OpenRouter's speech to text, answering every recording with the same transcript. - */ +/** OpenRouter's speech to text, answering every recording with the same transcript. */ export const mockOpenRouterTranscription = async ( context: BrowserContext, text: string, @@ -317,9 +270,7 @@ export const mockOpenRouterTranscription = async ( } } -// Where an upload lands when nothing else is configured. getBlossomServer probes the space's own -// origin first — blossom is off in every tenant's toml, so that probe is meant to fail — then the -// user's kind-10063 list, and VITE_DEFAULT_BLOSSOM_SERVERS is what is left. +// getBlossomServer probes the space's own origin, then the user's kind-10063 list, then this. export const DEFAULT_BLOSSOM_ORIGIN = "https://blossom.primal.net" export type BlossomOptions = { @@ -327,27 +278,18 @@ export type BlossomOptions = { server: string } -/** - * What a spec can turn on. Each of these is off by default, so a scenario that only needs somewhere - * for an upload to land ignores all of it. - */ +/** What a spec can turn on. Each of these is off by default. */ export type BlossomHandle = { - // The same server, blobs included, in another user's context. A conversation's image is uploaded - // encrypted and decrypted by the recipient, so the bytes one end puts in have to be the bytes the - // other reads back. + // A conversation's image is uploaded encrypted, so one end's bytes have to be the other's. install(context: BrowserContext): Promise - // Holds every upload open until `release`, which makes the in-flight state a fact rather than a - // race against a mock that answers in a microtask. + // Holds every upload open until `release`, which makes the in-flight state a fact rather than a race. hold(): void release(): void // Refuses every upload probe from here on, with a reason the app has to show. refuse(reason: string): void } -/** - * A blossom server that keeps what it was given. An upload is hashed exactly as the real thing - * would be, so the descriptor it answers with points at a blob this mock can then serve back. - */ +/** A blossom server that keeps what it was given, hashing an upload exactly as the real thing would. */ export const mockBlossom = async (context: BrowserContext, {server}: BlossomOptions) => { const {origin} = new URL(server) const blobs = new Map() @@ -364,8 +306,7 @@ export const mockBlossom = async (context: BrowserContext, {server}: BlossomOpti const {pathname} = new URL(request.url()) if (pathname === "/upload") { - // BUD-06, which the app asks before spending an upload. A refusal has to expose its - // reason cross-origin or all the toast can say is the status code. + // BUD-06, which the app asks before spending an upload. A refusal exposes its reason cross-origin. if (method === "HEAD") { if (refusal) { return route.fulfill({ @@ -424,10 +365,7 @@ export const mockBlossom = async (context: BrowserContext, {server}: BlossomOpti return handle } -/** - * The push server from src/app/push/adapters/capacitor.ts. Only the native adapters talk to it, - * so a browser run reaches this only if the platform detection regresses. - */ +/** The push server. Only the native adapters talk to it, so a browser run reaching this is a regression. */ export const mockPushServer = (context: BrowserContext) => context.route(`${PUSH_SERVER_ORIGIN}/**`, route => { const [resource] = new URL(route.request().url()).pathname.split("/").filter(Boolean) @@ -437,8 +375,7 @@ export const mockPushServer = (context: BrowserContext) => return route.fulfill({json: {}}) } - // Registration is only usable if it comes back with both. The relay is told to post - // notifications to the callback rather than the client ever fetching it. + // The relay is told to post notifications to the callback rather than the client ever fetching it. const key = "test-push-subscription" return route.fulfill({json: {key, callback: `${PUSH_SERVER_ORIGIN}/callback/${key}`}}) @@ -447,26 +384,20 @@ export const mockPushServer = (context: BrowserContext) => return route.fallback() }) -// One record straight off the hosting api, whose shapes live in src/app/hosting.ts. They're left -// as loose objects here so that mocking a payment flow doesn't pull the app's module graph, and -// with it import.meta.env, into the node process. A relay record may also carry `members` and -// `activity`, and an invoice `items` and `bolt11`, which is where those endpoints answer from. +// One record off the hosting api, left loose so mocking a flow doesn't pull import.meta.env in. export type HostingRecord = Record -// The backend's state when the page opens. Everything after that is what the app wrote, plus -// whatever the handle below changed. +// The backend's state when the page opens. export type HostingFixtures = { plans?: HostingRecord[] - // Provisioning runs on every login and ignores what it gets back, so the empty default carries - // any spec that isn't actually exercising the hosting ui. + // Provisioning runs on every login and ignores what it gets back. tenant?: HostingRecord relays?: HostingRecord[] invoices?: HostingRecord[] draftInvoice?: HostingRecord } -// The backend changing its mind between two of the user's clicks, such as a custom domain that -// verifies or an invoice that gets paid. It is the half of those flows no click can reach. +// The backend changing its mind between two of the user's clicks, which no click can reach. export type HostingHandle = { setTenant(patch: HostingRecord): void setRelay(id: string, patch: HostingRecord): void @@ -477,12 +408,7 @@ const hostingStore = makeContextStore("mockHosting") export const getHosting = (context: BrowserContext) => hostingStore.get(context) -/** - * The hosting api as a small stateful fake. A write mutates the record it names and the next read - * sees it, which is what makes editing a space's details, deactivating it, changing its plan or - * saving a custom domain observable. Each browser context gets its own store, so one user's spaces - * are not another's. - */ +/** The hosting api as a small stateful fake. Each browser context gets its own store. */ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixtures = {}) => { const plans = fixtures.plans ?? [] const relays = new Map((fixtures.relays ?? []).map(relay => [String(relay.id), relay])) @@ -568,8 +494,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt custom_domain: "", custom_domain_verified: 0, ...body, - // A created space is opened straight away, so its url has to be one the container - // serves. The client names the domain from VITE_HOSTING_RELAY_DOMAIN. + // A created space is opened straight away, so its url has to be one the container serves. zooid_domain: body.zooid_domain || "test", } @@ -592,8 +517,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt return route.fulfill({json: {data: {activity: relay.activity ?? []}}}) } - // The dump is whatever the fixture left on the record, served as the download itself rather - // than in a json envelope. + // The dump is served as the download itself rather than in a json envelope. if (sub === "export") { return route.fulfill({ contentType: "application/x-ndjson", @@ -601,8 +525,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt }) } - // Storing events is the relay's own business, so the fake counts the lines and refuses the - // ones that don't look signed, which is the shape zooid reports problems in. + // The fake counts the lines and refuses the ones that don't look signed, as zooid reports them. if (sub === "import") { const lines = (request.postData() ?? "").split("\n").filter(line => line.trim()) const problems = lines.flatMap((line, index) => @@ -659,8 +582,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/invoices/${id}`}}}) } - // GET and reconcile both answer with the invoice as it now stands, which is how a spec marks - // one paid. Flip `paid_at` with the handle and let the dialog's next poll find it. + // GET and reconcile both answer with the invoice as it stands, which is how a spec marks one paid. return route.fulfill({json: {data: invoice}}) } @@ -671,8 +593,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt } export type LivekitOptions = { - // Where the client is told to connect. Point it at something the test owns, since the token this - // hands out is accepted by nothing else. + // Point it at something the test owns, since the token this hands out is accepted by nothing else. serverUrl: string token?: string } @@ -694,10 +615,7 @@ export const mockLivekit = (context: BrowserContext, {serverUrl, token}: Livekit }, ) -/** - * Serves a png for anything the browser is loading as an image, so a scenario's fixtures can - * reference avatar, banner, blob and poster urls without any of them being fetched. - */ +/** Serves a png for anything loaded as an image, so a fixture's avatar and blob urls are never fetched. */ export const mockImages = (context: BrowserContext) => context.route( url => !isDevServerUrl(url), diff --git a/e2e/harness/net/websocket.ts b/e2e/harness/net/websocket.ts index f8cc03b4..1a226abd 100644 --- a/e2e/harness/net/websocket.ts +++ b/e2e/harness/net/websocket.ts @@ -32,8 +32,7 @@ type Traffic = { const trafficStore = makeContextStore("installWebSocketRoutes") -// A relay that holds nothing. REQs get an immediate EOSE and events are accepted and dropped, so a -// leak fails on the assertion that names it rather than on a timeout three layers away. +// A relay that holds nothing, so a leak fails on the assertion naming it rather than on a timeout. const openEmptyRelay = (): RelayConnection => { let listener: (message: RelayMessage) => void = () => undefined @@ -52,9 +51,7 @@ const openEmptyRelay = (): RelayConnection => { } } -// A relay that takes the socket and then says nothing at all: no events, no eose, no closed. It is -// the fault a client cannot see, since a request it never answers is indistinguishable from one it -// is still working on, and the only way out is the caller's own deadline. +// A relay that takes the socket and says nothing, so the only way out is the caller's own deadline. const openSilentRelay = (): RelayConnection => ({ onMessage() {}, send() {}, @@ -100,15 +97,7 @@ const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => { route.onClose(() => connection.close()) } -/** - * The single interception point for relay traffic. It goes on the context rather than a page, so - * every page in it is covered including ones opened later, and it is safe to call before any page - * exists. - * - * Vite's hmr socket is the one url left alone. Everything else is answered from this process, and - * a url that is not one of the container's virtual relays is served by an empty relay and recorded - * as a leak. - */ +/** The single interception point for relay traffic. On the context, so it covers a page opened later. */ export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => { const traffic = trafficStore.set(context, { transcript: [], @@ -125,8 +114,7 @@ export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => export const getTranscript = (context: BrowserContext) => trafficStore.get(context).transcript -// Every event this context put on the wire, oldest first, with the relay it went to. One event -// published to three relays is three entries. +// Every event this context put on the wire, oldest first. One event on three relays is three entries. export const getPublished = (context: BrowserContext): PublishedEvent[] => getTranscript(context) .filter( @@ -134,28 +122,21 @@ export const getPublished = (context: BrowserContext): PublishedEvent[] => ) .map(({url, message}) => ({url, event: message[1] as TrustedEvent})) -// The same, narrowed to one kind, which is how a spec asks what the client published rather than -// what it rendered. +// The same, narrowed to one kind, for asking what the client published rather than what it rendered. export const getPublishedEvents = (context: BrowserContext, kind: number) => getPublished(context) .filter(({event}) => event.kind === kind) .map(({event}) => event) -// Makes a relay answer like one that never held anything, without its url becoming a leak. `serve` -// resolves a relay once, at open, so sockets already open keep theirs and the drop takes effect on -// the next connection. A reload is what gives it one. +// `serve` resolves a relay once, at open, so a socket already open keeps its relay until a reload. export const forgetRelay = (context: BrowserContext, url: string) => trafficStore.get(context).forgotten.add(normalizeRelayUrl(url)) -// Makes a relay answer nothing at all, without its url becoming a leak. Silence is all this relay -// is, so it needs no tenant behind it: the url never reaches the container, and naming it here is -// what says the app was meant to open it. Resolved at open, like `forgetRelay`, so a spec that -// wants a page to boot into the fault passes `silent` to `as` instead of calling this. +// Resolved at open, like forgetRelay, so a page that boots into the fault passes `silent` to `as`. export const silenceRelay = (context: BrowserContext, url: string) => trafficStore.get(context).silenced.add(normalizeRelayUrl(url)) -// Every frame in both directions, oldest first. Attach it to a failing test to see what the client -// actually said, and to whom. +// Every frame in both directions, oldest first. Attach it to a failing test. export const formatTranscript = (context: BrowserContext) => getTranscript(context) .map( diff --git a/e2e/harness/seed/openRelay.ts b/e2e/harness/seed/openRelay.ts index 07914dee..32e5c160 100644 --- a/e2e/harness/seed/openRelay.ts +++ b/e2e/harness/seed/openRelay.ts @@ -8,11 +8,7 @@ import type {TestUser} from "../keys" import {makePublisher} from "./publish" import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish" -/** - * One public relay's fixtures. Unlike a space it has no rooms, no members and nothing behind an - * `h` tag: it holds the things a client reaches for by pubkey rather than by space — a relay list, - * a follow list, a profile, a note. - */ +/** One public relay's fixtures. Unlike a space it holds what a client reaches for by pubkey rather than by space. */ export type SeededOpenRelay = { readonly name: OpenRelayName // Known before seeding runs, since a relay list has to name the relay a note is seeded on. @@ -21,14 +17,11 @@ export type SeededOpenRelay = { readonly relayLists: SeededEvent[] note(user: TestUser, content: string, createdAt?: number): SeededEvent profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent - // Where this user reads and writes. A relay a scenario expects the client to read from has to - // appear in the reader's own list: zooid answers no REQ without nip-42, and Flotilla only - // identifies to relays that list names. + // A relay a scenario expects the client to read from has to appear in the reader's own list. relayList(user: TestUser, urls: RelayListUrls, createdAt?: number): SeededEvent follows(user: TestUser, follows: TestUser[], createdAt?: number): SeededEvent event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent - // This relay's domain kinds, bound to a resolver that answers with its url, the same way a - // space's `kind()` does. + // This relay's domain kinds, bound to a resolver that answers with its url. kind, Q extends EventQuery>( factory: KindFactory, ): ConfiguredKind diff --git a/e2e/harness/seed/publish.ts b/e2e/harness/seed/publish.ts index f64ccce6..5260f5a7 100644 --- a/e2e/harness/seed/publish.ts +++ b/e2e/harness/seed/publish.ts @@ -5,20 +5,16 @@ import type {TestUser} from "../keys" // A queued write, drained in declaration order by `seed` in scenario.ts. export type Enqueue = (write: () => Promise) => void -// A handle to an event the scenario is going to publish. Seeding calls record what to write and -// return before anything is written, so the event is filled in when its turn in the queue comes up. +// Seeding calls record what to write and return before anything is written. export type SeededEvent = { readonly event: SignedEvent readonly id: string } -// An event to seed, either already rendered or built when its turn comes up. A domain writer needs -// a relay url to resolve its hints against, and a relay has none until the queue has drained, so -// anything built by one has to be deferred. +// A domain writer needs a relay url to resolve its hints against, so anything it builds is deferred. export type SeededTemplate = StampedEvent | (() => MaybeAsync) -// A nip-65 relay list, as the two sets a client reads off it: `write` is what an outbox-routed load -// for this pubkey resolves to, `read` is what a feed asks for that pubkey's context. +// `write` is what an outbox-routed load resolves to, `read` is what a feed asks for that pubkey. export type RelayListUrls = { read?: string[] write?: string[] @@ -35,17 +31,13 @@ export type PublisherOptions = { // The relay these events are seeded into, for the error a read-too-early raises. name: string enqueue: Enqueue - // The moment the scenario began. A fixture declared without a timestamp is stamped with it - // rather than with the wall clock, so two fixtures describing the same thing cannot land - // seconds apart and decide which of them wins. + // The moment the scenario began. A fixture with no timestamp is stamped with it, not the wall clock. startedAt: number - // How an event this process signs reaches the relay, deferred because a space's relay handle - // only exists once the queue has started draining. + // Deferred, since a space's relay handle only exists once the queue has started draining. sign: (user: TestUser, template: StampedEvent) => Promise } -// The queue every seeding call goes through: what to write is recorded now and published when -// `seed()` drains, and what it produced only reads back after that. +// What to write is recorded now and published when `seed()` drains. export const makePublisher = ({name, enqueue, startedAt, sign}: PublisherOptions) => { // Queues a write and hands back a getter for whatever it produced. const seeded = (write: () => Promise) => { diff --git a/e2e/harness/seed/scenario.ts b/e2e/harness/seed/scenario.ts index 8ea3d784..bad0abed 100644 --- a/e2e/harness/seed/scenario.ts +++ b/e2e/harness/seed/scenario.ts @@ -11,16 +11,13 @@ import type {SeededSpace} from "./space" import {seedOpenRelay} from "./openRelay" import type {SeededOpenRelay} from "./openRelay" -// A fixture timestamp, as an offset from the moment the scenario started. `at(2, HOUR)` is two -// hours before the test began, count-first like int and ago. +// A fixture timestamp as an offset from the scenario's start. `at(2, HOUR)` is two hours before it. export type At = (count: number, unit: number) => number export type SeedTools = { // Names a space the container already serves. Its policy is its toml in zooid/docker/config. relay: (name: SpaceName) => SeededSpace - // Names one of the public relays, which is where the follow graph lives: `indexer` is what a - // pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See - // ARCHITECTURE.md, "The follow graph". + // Names one of the public relays, where the follow graph lives. See ARCHITECTURE.md, "The follow graph". open: (name: OpenRelayName) => SeededOpenRelay user: typeof users at: At @@ -31,14 +28,11 @@ export type Scenario = { readonly at: At // The spaces this scenario seeded, which are the relays the app is handed as its own. readonly urls: string[] - // What a pubkey's own lists are resolved from: the open indexer when a scenario declared one, - // and the spaces otherwise, which is what a scenario that knows nothing about open relays gets. + // The open indexer when a scenario declared one, and the spaces otherwise. readonly indexerUrls: string[] space(name: SpaceName): SeededSpace open(name: OpenRelayName): SeededOpenRelay - // The events a returning user's client would already have on disk: their room list, and their - // relay list when the scenario seeded one. A relay won't serve the list that would tell - // authPolicy it may identify to it. See ARCHITECTURE.md, "Users and sessions". + // A relay won't serve the list that would tell authPolicy it may identify to it. cache(user: TestUser): SignedEvent[] } @@ -74,16 +68,14 @@ export const seed = async ( await build({relay, open, user: users, at}) - // Seeding is async and fixtures depend on one another, so the builder only records what to - // write. Draining the queue here publishes each fixture in the order it was declared. + // Fixtures depend on one another, so the builder only records what to write and this drains it. for (const write of writes) { await write() } const seeded = Array.from(spaces.values()) - // A room list is replaceable and covers every space at once, so it can only be written after - // all of them have been seeded. + // A room list is replaceable and covers every space, so it is written after all of them are seeded. const membershipsByPubkey = new Map() for (const space of seeded) { diff --git a/e2e/harness/seed/space.ts b/e2e/harness/seed/space.ts index 769bc8c8..bca2b47d 100644 --- a/e2e/harness/seed/space.ts +++ b/e2e/harness/seed/space.ts @@ -23,20 +23,16 @@ import type {TestUser} from "../keys" import {makePublisher} from "./publish" import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish" -// @welshman/domain has no writer for NIP-29 kind-9 messages, and none of its readers describe one, -// so this pairs the base writer with the base reader. The behavior tags it renders are everything a -// room message carries: `h` via setRoom, `q` and `p` via addQuote and addMention. +// @welshman/domain has no writer for NIP-29 kind-9 messages, so this pairs the base writer and reader. class MessageWriter extends EventWriter {} -// The kind-14 a direct message really is. It is never published, since each participant gets it -// inside a gift wrap, so this is what a spec asserts on. +// The kind-14 a direct message really is. It is never published, since each participant gets a wrap. export type SeededRumor = { readonly rumor: HashedEvent readonly id: string } -// A user's membership as their own client sees it, which the scenario turns into one room list -// per user once every space has been seeded. +// A user's membership as their own client sees it, turned into a room list once every space is seeded. export type SeededMembership = { user: TestUser rooms: string[] @@ -48,26 +44,19 @@ export type SeededSpace = { readonly memberships: SeededMembership[] room(h: string, options?: RoomOptions): void member(user: TestUser, h?: string): void - // Relay and room membership, plus a place in the user's own room list, which is what a user who - // joined this space through the ui ends up with. + // Relay and room membership, plus a place in the user's own room list. join(user: TestUser, ...rooms: string[]): void message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent - // Where this user reads and writes, this space by default. Outbox routing resolves everything - // about a person through their relay list — their profile, the events they authored, the wraps - // addressed to them — so a fixture is only loadable by somebody else once its author has one. + // Outbox routing resolves everything about a person through their relay list. relayList(user: TestUser, urls?: RelayListUrls, createdAt?: number): SeededEvent - // The kind-10050 that says where someone's direct messages go, this space by default. Having one - // is what makes a person reachable, so a story about messaging being off is a person without one. + // Having one is what makes a person reachable, so messaging being off is a person without one. messagingRelayList(user: TestUser, urls?: string[], createdAt?: number): SeededEvent event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent - // A nip-17 conversation. One kind-14 rumor, gift-wrapped once per participant including the - // sender, whose own copy is the half of the thread their client reads back. + // One kind-14 rumor, gift-wrapped once per participant including the sender. dm(from: TestUser, to: TestUser[], content: string, createdAt?: number): SeededRumor - // This space's domain kinds, bound to a resolver that answers with its url, so a writer built - // here renders its relay hints as this space. For everything `event()` takes a template for: - // `space.event(user, () => space.kind(Article).writer().setTitle("x").renderTemplate())`. + // This space's domain kinds, bound to a resolver that answers with its url. kind, Q extends EventQuery>( factory: KindFactory, ): ConfiguredKind @@ -76,16 +65,14 @@ export type SeededSpace = { export type SeedSpaceOptions = { zooid: Zooid enqueue: Enqueue - // The moment the scenario began. A fixture declared without a timestamp is stamped with it - // rather than with the wall clock. + // A fixture declared without a timestamp is stamped with the scenario's start, not the wall clock. startedAt: number name: SpaceName } export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => { const memberships: SeededMembership[] = [] - // Known before seeding runs, unlike the relay handle below, so a fixture on another relay can - // name this one. + // Known before seeding runs, unlike the relay handle below, so another relay's fixture can name it. const url = tenantUrl(name) let testRelay: Maybe @@ -138,9 +125,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): const message = (user: TestUser, h: string, content: string, createdAt = startedAt) => publish(() => relay().message(user, h, content, createdAt)) - // Flotilla replies in a room by quoting. Content.svelte renders a quote from the nostr uri in the - // content rather than from the q tag, so the uri is prepended as prependParent does in - // src/app/rooms.ts. + // Content.svelte renders a quote from the nostr uri rather than the q tag, as prependParent does. const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) => event( user, @@ -188,9 +173,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): const messagingRelayList = (user: TestUser, urls = [url], createdAt = startedAt) => event(user, () => kind(MessagingRelayList).writer().setUrls(urls).renderTemplate(), createdAt) - // Every wrap is published over the sender's own connection, since a gift wrap's author is an - // ephemeral key nobody in this process can authenticate as. zooid stores it anyway, authorizing a - // kind-1059 by the member named in its p tag. + // A gift wrap's author is an ephemeral key nobody here can authenticate as, so the sender sends it. const dm = (from: TestUser, to: TestUser[], content: string, createdAt = startedAt) => { const rumor = seeded(async () => { const writer = kind(DirectMessage).writer().setContent(content) diff --git a/e2e/harness/ui.ts b/e2e/harness/ui.ts index c26307a1..172ef91c 100644 --- a/e2e/harness/ui.ts +++ b/e2e/harness/ui.ts @@ -1,48 +1,34 @@ import {expect} from "@playwright/test" import type {Locator, Page} from "@playwright/test" -/** - * What a spec names on screen. A locator more than one spec reaches for belongs here, so a class or - * a label the app renames is one edit rather than six — and so the comment explaining why a - * locator is shaped the way it is has one copy to keep true. - */ +/** What a spec names on screen. A locator more than one spec reaches for belongs here. */ export const dialog = (page: Page, title: string) => page.getByRole("dialog", {name: title, exact: true}) -// The modal on top, for one with no heading of its own or one pushed over another rather than -// alongside it. +// The modal on top, for one with no heading of its own or one pushed over another. export const topDialog = (page: Page) => page.getByRole("dialog").last() -// A modal is mounted alongside the page it covers, so a page's own "Create" and the modal's submit -// are both in the dom at once. Anything said about the form is scoped to the modal's own to say -// which one is meant. +// A modal is mounted alongside the page it covers, so both forms are in the dom at once. export const modalForm = (page: Page, title: string) => page.locator("form").filter({has: page.getByRole("heading", {name: title})}) -// Named rather than counted into the join EventActions renders, since a card is free to put a join -// of its own above it — a calendar event's rsvp buttons are one. +// Named rather than counted, since a card is free to put a join of its own above it. export const emojiButton = (scope: Locator) => scope.getByRole("button", {name: "Add a reaction"}) -// The menu is the one action with no accessible name, and the last thing in the last join a card -// has. +// The menu is the one action with no accessible name, and the last thing in a card's last join. export const menuButton = (scope: Locator) => scope.locator(".join").getByRole("button").last() -// The picker is a web component with an open shadow root, so its search field and its results are -// reachable through it. Searching rather than browsing avoids depending on which category tab an -// emoji happens to live under. +// The picker is a web component with an open shadow root, and searching avoids its category tabs. export const pickEmoji = async (page: Page, opener: Locator, annotation: string) => { await opener.click() const picker = page.locator("emoji-picker").filter({visible: true}) - // Tippy keeps a hidden popover mounted through its fade — a quarter of a second during which the - // picker from the last card is still visible alongside this one — so wait for there to be one - // rather than reaching into whichever resolves first. + // Tippy keeps a hidden popover mounted through its fade, so the last card's picker is still up. await expect(picker).toHaveCount(1) - // A result's label is the emoji's name, its annotation and every shortcode joined together, so - // the annotation is matched rather than the whole of it. + // A result's label joins the emoji's name, its annotation and every shortcode. await picker.locator("input.search").fill(annotation) await picker .getByRole("option", {name: new RegExp(annotation)}) @@ -61,8 +47,7 @@ export const roomLink = (page: Page, name: string) => // One toast at a time — src/app/toast.ts holds a single writable — so this is the toast. export const toast = (page: Page) => page.getByRole("alert") -// FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row, with the -// label at one end and the control at the other. +// FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row. export const settingRow = (page: Page, label: string) => page.locator("div.items-center.justify-between").filter({hasText: label}) @@ -71,9 +56,7 @@ export const settingToggle = (page: Page, label: string) => export const composer = (page: Page) => page.locator(".chat-editor [contenteditable=true]") -// The editor is where the composer says whether it is ready. The send button is not there to ask -// while the composer is empty, since a dictation button stands in its place. Only the conversation -// composer has a disabled state; a room's is usable as soon as it renders. +// Only the conversation composer has a disabled state; a room's is usable as soon as it renders. export const composerEnabled = (page: Page) => expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "false") @@ -85,8 +68,7 @@ export const sendButton = (page: Page) => page.locator("button[data-tip$='enter export const timeline = (page: Page) => page.locator(".room__content") -// .room__content is column-reverse, so the message at the bottom of the room is the first one in -// the dom. +// .room__content is column-reverse, so the message at the bottom of the room is first in the dom. export const messages = (page: Page) => page.locator(".room__item") export const message = (page: Page, text: string) => messages(page).filter({hasText: text}) @@ -100,11 +82,7 @@ export const openMessageMenu = (page: Page, text: string) => export const bubble = (page: Page, text: string) => page.locator(".chat-bubble").filter({hasText: text}) -// Typing into a room's composer and sending it. The composer is waited for rather than assumed, -// since a room still rendering has none, and clicked into when the caret is somewhere else, since -// typing goes wherever it is. One that already holds the caret is typed into as it stands: clicking -// would collapse the selection, and selecting the whole of it is how an edit types over the message -// it replaces. +// Clicking a composer that already holds the caret would collapse the selection an edit types over. export const send = async (page: Page, content: string) => { const editor = composer(page) @@ -125,13 +103,11 @@ export const chatList = (page: Page) => page.locator(".secondary-nav .overflow-a // One conversation in the sidebar list is one button; nothing inside it is one. export const chatItems = (page: Page) => chatList(page).locator("button") -// The comment and thread composers are rich text rather than chat editors, so they carry a -// different one. +// The comment and thread composers are rich text rather than chat editors. export const noteEditor = (scope: Locator | Page) => scope.locator(".note-editor [contenteditable=true]") -// A date as the browser formatted it rather than as node would, so the locale and the timezone are -// the ones the app rendered with. The options mirror dateFormatter in @welshman/lib. +// A date as the browser formatted it. The options mirror dateFormatter in @welshman/lib. export const longDate = (page: Page, seconds: number) => page.evaluate( ts => diff --git a/e2e/harness/zooid/config.ts b/e2e/harness/zooid/config.ts index f56f5dcd..73a3fa45 100644 --- a/e2e/harness/zooid/config.ts +++ b/e2e/harness/zooid/config.ts @@ -1,32 +1,16 @@ -/** - * The virtual relays the container serves, and the only place their names are written down. - * - * zooid binds a config to a Host header and serves any number of them from one process, so another - * relay costs a toml in docker/config and nothing else. The names are a union rather than a string - * so that a scenario naming a relay that has no config fails to compile instead of hanging on a 404 - * from the dispatcher. - * - * Each host resolves nowhere, and `.test` is reserved by rfc 2606, so a url that escapes this - * process fails to connect rather than reaching a host. transport.ts covers why the container is - * told to call itself this rather than its loopback address. - */ +// The virtual relays the container serves. .test is reserved by rfc 2606, so none of them resolve. -// The relays a space is seeded on: members-only, with nip-29 groups on, which is what a Flotilla -// space is. +// The relays a space is seeded on: members-only, with nip-29 groups on. export const spaceTenants = { space: "space.test", other: "other.test", - // Policy space.toml cannot express at the same time. `closed` refuses a join without an invite, - // `unsigned` serves events with their signatures stripped, `delegated` gives every member one - // management method. + // Policy space.toml cannot express at the same time: no invite, stripped signatures, delegated management. closed: "closed.test", unsigned: "unsigned.test", delegated: "delegated.test", } as const -// Public relays with no groups, which is where anything outside a space lives: `indexer` is what a -// pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See -// ARCHITECTURE.md, "The follow graph". +// Public relays with no groups, where anything outside a space lives. See ARCHITECTURE.md, "The follow graph". export const openTenants = { indexer: "indexer.test", outbox: "outbox.test", diff --git a/e2e/harness/zooid/relay.ts b/e2e/harness/zooid/relay.ts index 923eeae0..330034a9 100644 --- a/e2e/harness/zooid/relay.ts +++ b/e2e/harness/zooid/relay.ts @@ -20,39 +20,27 @@ import type {PublishOptions} from "./types" import {connectToZooid, port, requestZooid} from "./transport" import type {ZooidConnection} from "./transport" -// The relay every test runs against, pinned by digest so a spec is checked against one zooid rather -// than whatever this machine last pulled. The tag is there to read; docker resolves the digest, and -// `docker pull` of a newer tag prints the digest to paste beside it. A zooid built from a checkout -// has no published digest, so ZOOID_IMAGE is how an unreleased one is tried. +// Pinned by digest so a spec runs against one zooid. ZOOID_IMAGE is how an unreleased build is tried. const image = process.env.ZOOID_IMAGE ?? "gitea.coracle.social/coracle/zooid:latest@sha256:7950843900cbb1c7c1f17de5524f6999badd1002cde94476855e90e67e0f2248" -// How long after a recreate chromium can still abort what a page has in flight. Every netlink -// event a create-and-destroy produces lands before `compose up --wait` returns — measured at 26 of -// them, all of them within a second, none after — but chromium coalesces interface changes for up -// to two seconds before acting on one, so the abort arrives well after the harness has moved on. +// How long after a recreate chromium can still abort what a page has in flight. const SETTLE = 2500 const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url)) const configSource = fileURLToPath(new URL("docker/config", import.meta.url)) -// What the container mounts as /app/config. zooid saves a relay's toml back whenever a nip-86 call -// edits that relay's name, description or icon. A read-only mount fails those calls, and mounting -// the repo's own directory would leave one test rewriting the fixtures the rest read, so it is -// handed a copy. The image is distroless, so the copy is staged here rather than in an entrypoint. +// zooid saves a relay's toml back on a nip-86 edit, so it is handed a copy rather than the repo's. const configDir = join(tmpdir(), "flotilla-e2e-zooid-config") const execFileAsync = promisify(execFile) -// execFile does not go through a shell, so a `docker` that exists only as an alias or a function is -// invisible to it however well it works when typed. Name the executable to drive with E2E_DOCKER in -// that case. +// execFile does not go through a shell, so a docker that exists only as an alias needs E2E_DOCKER. const dockerCommand = process.env.E2E_DOCKER ?? "docker" -// Whether something already answers on the port the container publishes, asked before this run -// brings its own up. +// Whether something already answers on the port the container publishes. const isRelayPortTaken = () => new Promise(resolve => { const socket = createConnection({port, host: "127.0.0.1"}) @@ -69,9 +57,7 @@ const isRelayPortTaken = () => socket.once("error", () => resolve(false)) }) -// Every invocation carries the config directory and the image, `down` included. The compose file -// names both without a default, so a call that left one out fails to interpolate rather than -// quietly mounting something else or running a relay nothing here chose. +// The compose file names both without a default, so a call that left one out fails to interpolate. const docker = (...args: string[]) => execFileAsync(dockerCommand, args, { env: {...process.env, ZOOID_CONFIG: configDir, ZOOID_IMAGE: image}, @@ -79,9 +65,7 @@ const docker = (...args: string[]) => const compose = (...args: string[]) => docker("compose", "-f", composeFile, ...args) -// Why the container cannot be driven, or undefined when it can. A cli that is not on this process's -// PATH and a daemon that is not running need different answers, so they are reported apart rather -// than as one boolean. +// Why the container cannot be driven, or undefined when it can. const probeDocker = async () => { try { await docker("compose", "version") @@ -105,9 +89,7 @@ const probeDocker = async () => { } } -// How far the container's clock is from this host's, in seconds, read off the Date header of the -// relay's own http answer. Positive means the container is behind, so an event the harness has just -// signed looks like it comes from the future. +// How far the container's clock is from this host's, in seconds. Positive means the container is behind. const getClockDrift = async (host: string) => { const {headers} = await requestZooid(host, "GET", "/", {accept: "application/nostr+json"}) @@ -116,9 +98,7 @@ const getClockDrift = async (host: string) => { } } -// nip-42 accepts an auth event within ten minutes of the relay's own clock (nip42.go), and under a -// container runtime's vm the two clocks belong to different machines. The relay's own one-line -// detail says nothing about how to fix that. +// nip-42 accepts an auth event within ten minutes of the relay's own clock (nip42.go). const describeClockDrift = (drift: number) => { const [minutes, direction] = drift > 0 ? [drift / 60, "behind"] : [Math.abs(drift) / 60, "ahead of"] @@ -151,8 +131,7 @@ const getTestUser = (pubkey: string) => { let problem: Maybe>> -// Asked once a worker, since docker does not come and go mid-run. The answer is also written to the -// terminal, because a skip reason otherwise reaches only the html report. +// Also written to the terminal, since a skip reason otherwise reaches only the html report. export const describeDockerProblem = () => (problem ??= probeDocker().then(reason => { if (reason) { @@ -164,14 +143,9 @@ export const describeDockerProblem = () => return reason })) -/** - * The relay every test runs against. One zooid container on loopback serves a virtual relay per - * entry in `tenants`, and each relay's policy is its toml in docker/config, the only place policy - * is written down. A scenario describes what is on a relay, never what the relay is. - */ +/** The relay every test runs against. One container serves a virtual relay per entry in `tenants`. */ export class Zooid { - // Every socket into the container, the client's and seeding's alike, is one this process opened, - // so this map is also the definition of a url that is not a leak. + // Every socket into the container is one this process opened, so this map also defines a url that is not a leak. relays = new Map( tenantNames.map(name => [ tenantUrl(name), @@ -189,8 +163,7 @@ export class Zooid { // When the last recreate's interface churn stops being able to abort a request. private settledAt = 0 - // Verifies docker rather than bringing the container up, which `reset` does. Repeat calls are - // free, so the fixture can call this per test. + // Verifies docker rather than bringing the container up, which reset does. Repeat calls are free. start = async () => { if (this.started) { return @@ -246,11 +219,7 @@ export class Zooid { } } - // Leaves a fresh container for whoever runs next. Recreating one churns the host's network - // interfaces — a veth pair torn down and rebuilt, the project's bridge losing carrier with it — - // and chromium aborts every request it has in flight when it acts on that, which reaches the app - // as a route chunk that failed to import and, with `ssr = false`, a 500 page. So this is called - // from teardown rather than setup, and everything after it counts towards `settle` below. + // Recreating churns the host's network interfaces, and chromium aborts what it has in flight. reset = async () => { this.closeSessions() @@ -267,9 +236,7 @@ export class Zooid { } } - // Waited out before a page is opened, which is as late as it can be left: teardown, the next - // test's fixtures and its seeding are all inside the window already, so most tests pay nothing - // here. + // As late as the wait can be left: teardown, the next test's fixtures and its seeding are inside it. settle = async () => { const remaining = this.settledAt - Date.now() @@ -289,8 +256,7 @@ export class Zooid { } } - // Fetching unasked is safe for a pinned reference: the pull can only produce the relay this suite - // was written against, and a machine that already has it never gets here. + // A pull of a pinned reference can only produce the relay this suite was written against. private fetchImage = async () => { console.warn(`\nFetching the zooid image this suite is pinned to:\n ${image}\n`) @@ -308,8 +274,7 @@ export class Zooid { } } - // Whatever the relay wrote before it died. Compose reports only that a container exited, so - // without this a startup failure is a status code and no reason. + // Compose reports only that a container exited, so without this a startup failure has no reason. private logs = () => compose("logs", "--no-color", "--tail", "50").then( ({stdout, stderr}) => [stdout, stderr].filter(Boolean).join("\n").trim(), @@ -322,13 +287,9 @@ export class Zooid { throw new Error(logs ? `${summary}\n\nzooid said:\n${logs}` : summary) } - // Storage is tmpfs with no volume mounted for it, so a new container is a new database and - // recreating is what makes this a reset. + // Storage is tmpfs with no volume mounted for it, so a new container is a new database. private up = async () => { - // A fresh copy on every recreate is what restores relay metadata a test edited through nip-86. - // The modes are permissive because the container runs as uid 65532 while the copy belongs to - // whoever ran the suite, and a runtime that keeps host ownership leaves zooid unable to write - // the file it was told to save. + // A fresh copy restores metadata a test edited through nip-86, and the container runs as uid 65532. await rm(configDir, {recursive: true, force: true}) await cp(configSource, configDir, {recursive: true}) await chmod(configDir, 0o777) @@ -348,8 +309,7 @@ export class Zooid { const deadline = Date.now() + ms(30) while (Date.now() < deadline) { - // A 404 means the dispatcher has no relay bound to that host yet, so the configs are still - // loading. Anything a relay itself answers means every tenant is ready. + // A 404 means the dispatcher has no relay bound to that host yet, so the configs are still loading. const isUp = await Promise.all( tenantNames.map(name => requestZooid(tenants[name], "GET", "/", {accept: "application/nostr+json"}).then( @@ -378,9 +338,7 @@ export class Zooid { ) } - // NIP-42 binds an identity to a connection, so each test user seeds over its own, per relay. The - // url signed into the auth event is the one the client uses, since that is what khatru rebuilds - // from the headers transport.ts sends. + // NIP-42 binds an identity to a connection, and khatru rebuilds the signed url from transport.ts's headers. private authenticate = async (host: string, user: TestUser) => { const key = `${host} ${user.pubkey}` const session = this.sessions.get(key) @@ -403,8 +361,7 @@ export class Zooid { const summary = `Failed to authenticate as ${user.name} on ${host}: ${detail}` const drift = await getClockDrift(host).catch(() => undefined) - // Nearly always the clock rather than the key. Every identity here is one zooid's toml names, - // and the signature is checked after the timestamp the relay compares against its own. + // Nearly always the clock: the signature is checked after the timestamp the relay compares against. if (drift !== undefined && Math.abs(drift) > int(10, MINUTE)) { throw new Error(`${summary}\n\n${describeClockDrift(drift)}`) } @@ -412,8 +369,7 @@ export class Zooid { throw new Error(summary) } - // Both halves of seeding, the auth that opens a connection and every event written over it, are - // answered with an OK naming the event that was sent. + // Both the auth that opens a connection and every event written over it are answered with an OK. private send = async (connection: ZooidConnection, message: ClientMessage, id: string) => { connection.send(message) diff --git a/e2e/harness/zooid/testRelay.ts b/e2e/harness/zooid/testRelay.ts index c43cb50a..a390d2a9 100644 --- a/e2e/harness/zooid/testRelay.ts +++ b/e2e/harness/zooid/testRelay.ts @@ -17,11 +17,7 @@ export type TestRelayOptions = { publish: (event: SignedEvent, options?: PublishOptions) => Promise } -// Seeding signs the events a real client would send and publishes them over a socket. Room -// administration is signed by `admin`, the only test identity the tomls grant can_manage. -// -// Every timestamp is the caller's. Reading the wall clock here would stamp two fixtures that -// describe the same thing seconds apart, which is enough to decide which of them wins. +// admin is the only test identity the tomls grant can_manage, and every timestamp is the caller's. export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay => { const event = async (user: TestUser, template: StampedEvent) => { const signed = await user.signer.sign(template) @@ -37,8 +33,7 @@ export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay event, publish, room: async (h, meta, createdAt) => { - // A relay stamps the metadata it derives from these ops with the op's own created_at, so - // creation has to be strictly older than the edit or the edit is dropped as stale. + // A relay stamps derived metadata with the op's created_at, so creation must be older than an edit. await event( users.admin, makeEvent(ROOM_CREATE, {tags: [["h", h]], created_at: createdAt - 1}), diff --git a/e2e/harness/zooid/transport.ts b/e2e/harness/zooid/transport.ts index fe796b82..b640f80b 100644 --- a/e2e/harness/zooid/transport.ts +++ b/e2e/harness/zooid/transport.ts @@ -4,21 +4,7 @@ import {parseJson} from "@welshman/lib" import type {ClientMessage, RelayMessage} from "@welshman/net" import type {RelayConnection} from "./types" -/** - * How the harness reaches the zooid container, and the only place that knows it has a loopback - * address at all. - * - * The client is given `wss://.test/` and never learns anything else. A relay selection - * drops a url that is local or insecure unless the caller opts in, which Flotilla never does - * (isLocalUrl and RelaySelection.getUrls in @welshman/util), so the container's own - * `ws://localhost:3334/` would leave the Router resolving every outbox load, profile and relay hint - * to nothing. - * - * The two headers below make the container answer to that name, and are what a tls terminator adds - * in front of a real deployment. zooid's dispatcher binds a config to a Host (cmd/relay/main.go), - * and khatru derives the url it checks nip-42 and nip-86 signatures against from that Host plus the - * forwarded proto (getBaseURL in khatru/relay.go). - */ +/** The only place that knows the container has a loopback address. A relay selection drops a local url. */ // Must match the published port in zooid/docker/compose.yaml. export const port = 3334 @@ -26,13 +12,11 @@ export const port = 3334 const headersFor = (host: string) => ({Host: host, "X-Forwarded-Proto": "https"}) export type ZooidConnection = RelayConnection & { - // Resolves with the first message the relay sends that matches. Seeding waits for its auth - // challenge and for the OK answering each write this way. + // Resolves with the first message the relay sends that matches. wait(match: (message: RelayMessage) => boolean): Promise } -// One connection to the container, as one of its virtual relays. Node's own WebSocket cannot carry -// a Host header, which fetch forbids, so the harness depends on `ws` for this alone. +// Node's own WebSocket cannot carry a Host header, which fetch forbids, so this depends on `ws`. export const connectToZooid = (host: string): ZooidConnection => { const socket = new WebSocket(`ws://127.0.0.1:${port}/`, {headers: headersFor(host)}) const listeners = new Set<(message: RelayMessage) => void>() @@ -52,8 +36,7 @@ export const connectToZooid = (host: string): ZooidConnection => { } }) - // The likeliest error here is a 404 from zooid's dispatcher, which means `tenants` in config.ts - // and the `host` in the tenant's toml have drifted apart. + // A 404 from zooid's dispatcher means `tenants` and the tenant toml's `host` have drifted apart. socket.on("error", error => console.error(`The zooid container refused a socket for ${host}: ${error.message}`), ) @@ -62,8 +45,7 @@ export const connectToZooid = (host: string): ZooidConnection => { onMessage: listener => { listeners.add(listener) }, - // A client may speak before the container has finished its handshake. The page's socket is open - // as soon as playwright has a route for it, well before this one is. + // A client may speak before the container has finished its handshake. send: message => { if (socket.readyState === WebSocket.OPEN) { write(message) @@ -92,8 +74,7 @@ export type ZooidResponse = { body: Buffer } -// One http request to the container, carrying the same two headers, so the nip-11 document and the -// nip-86 management api the browser reads are the real relay's. +// Carries the same two headers, so the nip-11 and nip-86 answers the browser reads are the relay's. export const requestZooid = ( host: string, method: string, @@ -109,8 +90,7 @@ export const requestZooid = ( host: "127.0.0.1", port, headers: { - // A pseudo-header describes the request line rather than the request, and node:http - // rejects a header name with a colon in it. + // A pseudo-header describes the request line, and node:http rejects a header name with a colon in it. ...Object.fromEntries( Object.entries(requestHeaders).filter(([name]) => !name.startsWith(":")), ), @@ -122,8 +102,7 @@ export const requestZooid = ( const responseHeaders: Record = {} for (const [name, value] of Object.entries(response.headers)) { - // Hop-by-hop headers describe the connection this answer arrived on, not the one the - // browser is waiting on, which playwright frames itself. + // Hop-by-hop headers describe the connection this answer arrived on, not the browser's. if (value && !["connection", "keep-alive", "transfer-encoding"].includes(name)) { responseHeaders[name] = Array.isArray(value) ? value.join(", ") : value } diff --git a/e2e/harness/zooid/types.ts b/e2e/harness/zooid/types.ts index 1171c192..8991515a 100644 --- a/e2e/harness/zooid/types.ts +++ b/e2e/harness/zooid/types.ts @@ -3,9 +3,7 @@ import type {ClientMessage, RelayMessage} from "@welshman/net" import type {TestUser} from "../keys" export type PublishOptions = { - // Which identity the seeding connection authenticates as. Defaults to the event's own author, - // which then has to be a test identity. A gift wrap is signed by an ephemeral key nothing in this - // process can authenticate as, so its connection belongs to the sender instead. + // Which identity the seeding connection authenticates as, defaulting to the event's own author. as?: TestUser } @@ -17,9 +15,7 @@ export type RoomOptions = { private?: boolean } -// A handle to one relay, plus the seeding affordances scenarios build on. Every seeding call -// publishes over a real socket rather than inserting into storage, so the relay stores exactly what -// it would have stored for a real client. +// Every seeding call publishes over a real socket, so the relay stores what it would for a client. export type TestRelay = { readonly name: string readonly url: string @@ -29,13 +25,11 @@ export type TestRelay = { member(user: TestUser, h: string | undefined, createdAt: number): Promise // Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs. event(user: TestUser, event: StampedEvent): Promise - // An event this process did not sign, sent over `as`'s connection. A gift wrap, whose author is - // the ephemeral key that wrapped it, is the case that needs this. + // An event this process did not sign, sent over `as`'s connection, which a gift wrap needs. publish(event: SignedEvent, options: PublishOptions): Promise } -// One client's connection to a relay. Every connection to a url reaches the same container, so one -// browser context observes another's writes over the wire. +// Every connection to a url reaches the same container, so one context observes another's writes. export type RelayConnection = { onMessage(listener: (message: RelayMessage) => void): void send(message: ClientMessage): void diff --git a/e2e/specs/admin.spec.ts b/e2e/specs/admin.spec.ts index 97ea6d05..2a0609e1 100644 --- a/e2e/specs/admin.spec.ts +++ b/e2e/specs/admin.spec.ts @@ -19,9 +19,7 @@ import { const ICON = gifFile("icon.gif") -// The plans the hosting backend offers. `free` is what RelayForm starts on, and `basic` is the paid -// one every upgrade story moves to. PricingTable names a plan by its member limit, which is the -// only part of a card that differs between the two. +// `free` is what RelayForm starts on. PricingTable names a plan by its member limit. const PLANS = [ {id: "free", name: "Free", amount: 0, hidden: false, members: 50, blossom: false, livekit: false}, { @@ -29,7 +27,7 @@ const PLANS = [ name: "Basic", amount: 500, hidden: false, - // Plan["members"] is number | null upstream; null is how the hosting API spells "unlimited". + // Plan["members"] is number | null upstream, and null is how the hosting API spells unlimited. // eslint-disable-next-line no-restricted-syntax members: null, blossom: true, @@ -37,8 +35,7 @@ const PLANS = [ }, ] -// One relay as the hosting api serves it. Every field RelayDetailCard reads is present, since it -// calls .trim() and .replace() on several of them. +// Every field RelayDetailCard reads is present, since it calls .trim() and .replace() on several. const hostedRelay = (overrides: Record = {}) => ({ id: "relay-1", tenant_pubkey: users.alice.pubkey, @@ -65,26 +62,18 @@ const hostedRelay = (overrides: Record = {}) => ({ ...overrides, }) -// Tippy mounts a menu the first time it is opened and leaves it in the dom when it hides, so a page -// that has opened two of them holds both — only the one on screen is visible. Exact, because -// "Edit role" and "Edit roles" are two different menus in the same directory. +// Tippy leaves a menu in the dom when it hides. Exact, since "Edit role" and "Edit roles" differ. const menuItem = (page: Page, name: string) => page.getByRole("button", {name, exact: true}).filter({visible: true}) -// Every space-level admin action hangs off the space menu, which opens from the header button in -// the secondary nav. That button is labeled with the space's name and its host, and only the host -// survives a rename. +// That button is labeled with the space's name and its host, and only the host survives a rename. const openSpaceMenu = (page: Page) => page.getByRole("button", {name: /space\.test/}).click() -// SpaceMember covers its card with a button whose aria-label is baked from the profile display at -// first render — before the profile has loaded — so the card is found by the name it shows rather -// than by that label. It is the only interactive card in the directory, and the member's own menu -// is the last button inside it. +// The card's aria-label is baked at first render, so the card is found by the name it shows. const memberCard = (page: Page, name: string) => page.locator(".card-interactive").filter({hasText: name}) -// Exact, because several permission names are a substring of another — "Ban members" of "Unban -// members". +// Exact, because several permission names are a substring of another: "Ban members" of "Unban members". const permission = (scope: Locator, name: string) => scope.getByRole("checkbox", {name, exact: true}) @@ -93,8 +82,7 @@ const openEventMenu = (card: Locator) => menuButton(card).click() const articleCard = (page: Page, title: string) => page.locator('[data-component="ArticleItem"]').filter({hasText: title}) -// A FieldInline puts its control in the div immediately after its label, which is how one row of -// the hosting card is told apart from the others in the same grid. +// A FieldInline puts its control in the div immediately after its label. const setting = (page: Page, label: string) => page.locator("label").filter({hasText: label}).locator("xpath=following-sibling::div") @@ -126,8 +114,7 @@ test("US-092 edit a space's profile and featured content", async ({seed, as}) => await editor.getByRole("button", {name: "Add an image"}).click() - // The upload's file input is hidden inside its own label, so the chooser is opened by clicking - // the label rather than by writing to the input. Picking a file dismisses the picker. + // The upload's file input is hidden inside its own label, so the chooser opens by clicking it. const picker = dialog(admin, "Add an image") const chooser = admin.waitForEvent("filechooser") @@ -321,8 +308,7 @@ test("US-094 invite people to a space", async ({seed, as}) => { space.join(user.bob, "general") space.profile(user.bob, {name: "Bob Barnacle"}) - // Somebody who belongs to a different space, so adding her here is a change to this space's - // member list rather than something the scenario already seeded. + // Somebody who belongs to a different space, so adding her here is a change to this one. other.room("lounge", {name: "Lounge"}) other.join(user.admin, "lounge") other.join(newcomer, "lounge") @@ -332,8 +318,7 @@ test("US-094 invite people to a space", async ({seed, as}) => { const other = scenario.space("other") - // Arriving through the other space is what loads Nadia's profile: the invite dialog's search - // reads the profiles this client already holds. + // The invite dialog's search reads the profiles this client already holds. const page = await as(users.admin, roomPath(other.url, "lounge"), { context: {permissions: ["clipboard-read", "clipboard-write"]}, }) @@ -577,8 +562,7 @@ test("US-097 work through the action-items queue", async ({seed, as}) => { const space = relay("space") space.room("general", {name: "General"}) - // A room the relay will not admit anyone to on their own say-so, so a join request stays - // pending instead of being granted the moment it lands. + // A room the relay will not admit anyone to on their own say-so, so a join request stays pending. space.room("vault", {name: "Vault", closed: true, private: true}) space.join(user.admin, "general", "vault") space.join(user.alice, "general") @@ -740,8 +724,7 @@ test("US-130 share out admin permissions", async ({seed, as}) => { const admins = dialog(admin, "Admins") - // The owner holds every method implicitly, so the relay leaves them out of listmethodassignees - // and the client names them from the space's nip 11 pubkey instead. + // The owner holds every method implicitly, so the relay leaves them out of listmethodassignees. await expect(admins.locator(".badge").filter({hasText: "Owner"})).toBeVisible() await expect(admins.getByText("Nobody else has been given management permissions.")).toBeVisible() @@ -802,8 +785,7 @@ test("US-098 browse and create hosted spaces", async ({seed, as}) => { const page = await as(users.alice, "/settings/hosting", { hosting: {plans: PLANS}, - // A space created here is opened at wss://./, so the domain has to be one - // the container serves and the subdomain has to name one of its tenants. + // A space created here is opened at wss://./, which has to name a tenant. env: {VITE_HOSTING_RELAY_DOMAIN: "test"}, }) @@ -987,8 +969,7 @@ test("US-101 point a custom domain at a hosted relay", async ({seed, as}) => { space.room("general", {name: "General"}) space.join(user.alice, "general") - // Where the space ends up once the domain verifies, so the client has somewhere real to follow - // it to. + // Where the space ends up once the domain verifies. other.room("general", {name: "General"}) other.join(user.alice, "general") }) @@ -1062,9 +1043,7 @@ test("US-102 pause a relay and settle the bill", async ({seed, as}) => { hosting: { plans: PLANS, relays: [hostedRelay({plan_id: "basic"})], - // Declared oldest first, which is not the order payment history reads in. Invoice's - // paid_at/voided_at/method are number | null / InvoiceMethod | null upstream, so null is - // how the hosting API spells "hasn't happened yet". + // Declared oldest first, which is not the order payment history reads in. null is "not yet". /* eslint-disable no-restricted-syntax */ invoices: [ { @@ -1168,9 +1147,7 @@ test("US-102 pause a relay and settle the bill", async ({seed, as}) => { const history = page.locator(".card").filter({hasText: "Payment History"}).first() const invoices = history.getByRole("listitem") - // The whole period, not just its start: the two invoices meet at a month boundary, so either - // date on its own reads the same on both of them. Formatted by the browser rather than by node, - // so the locale is the one the app rendered with — see dayLabel in dms.spec.ts. + // The two invoices meet at a month boundary, so either date on its own reads the same on both. const period = ({start, end}: {start: number; end: number}) => page.evaluate( ([from, to]) => @@ -1221,9 +1198,7 @@ test("US-122 export and import a hosted relay's data", async ({seed, as}) => { await expect(modal.getByText("line 2: invalid event")).toBeVisible() }) -// The space menu button is labeled with the space's host, the way `openSpaceMenu` relies on for -// space.test. delegated.test grants every member one management method, so `supportedmethods` comes -// back with a single entry for a member and the whole list for admin, who owns the relay. +// delegated.test grants every member one management method, so `supportedmethods` differs by user. const openDelegatedMenu = (page: Page) => page.getByRole("button", {name: /delegated\.test/}).click() diff --git a/e2e/specs/articles-threads.spec.ts b/e2e/specs/articles-threads.spec.ts index 1a1f6dd8..30141a8c 100644 --- a/e2e/specs/articles-threads.spec.ts +++ b/e2e/specs/articles-threads.spec.ts @@ -26,13 +26,11 @@ type Seeded = {readonly id: string; readonly event: SignedEvent} const PARTY = "🎉" -// The comment and thread-reply composers are the only forms on their pages carrying a rich text -// editor. +// The comment and thread-reply composers are the only forms on their pages with a rich text editor. const composerForm = (page: Page) => page.locator("form").filter({has: page.locator(".note-editor")}) -// RoomCompose's join is the upload button and then the compose menu, which is where an article or -// a thread written from inside a room is started. +// RoomCompose's join is the upload button and then the compose menu. const openComposeMenu = (page: Page) => page .locator("form") @@ -56,36 +54,28 @@ const expectReactionRoundTrip = async (page: Page, scope: Locator, opener: Locat await expect(pill).toHaveCount(0) } -// formatTimestamp renders a short date and a short time, so the date half of it is what a spec can -// name without pinning a format. Formatted by the browser rather than by node, so the locale and the -// timezone are the ones the app rendered with — see dayLabel in dms.spec.ts. +// Formatted by the browser rather than by node, so the locale and the timezone are the app's. const shortDate = (page: Page, seconds: number) => page.evaluate( ts => new Intl.DateTimeFormat(undefined, {dateStyle: "short"}).format(new Date(ts * 1000)), seconds, ) -// The card is a div carrying an overlay link, so it is found by its component rather than by a -// role — its own contents include a profile button and the room and action links. +// The card is a div carrying an overlay link, so it is found by its component rather than a role. 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. +// A comment is a flat block rather than a card, and the marker sits on its own row, not its replies. const comment = (page: Page, text: string) => page.locator('[data-component="Comment"]').filter({hasText: text}) -// Clicked near its top-left corner rather than at its centre: the link is an overlay covering the -// whole card, and a card whose footer wraps onto a second line puts that interactive row under the -// centre point, where it swallows the click. +// Clicked near its top-left: a card whose footer wraps puts an interactive row under the centre. const openArticle = async (page: Page, title: string) => { await articleCards(page) .filter({hasText: title}) .getByRole("link", {name: title, exact: true}) .click({position: {x: 20, y: 20}}) - // The card's own action bar carries a data-component the article page carries too, and the list - // is still on screen while the route loads, so a spec that names one straight after this click - // gets the card's. The article body is only on the page it navigated to. + // The card's own action bar carries the same data-component, and the list is still on screen. await expect(page.locator("article header").getByRole("heading", {name: title})).toBeVisible() } @@ -278,8 +268,7 @@ test("US-038 browse, filter, and read articles", async ({seed, as}) => { await expect(garden).toContainText("A short teaser about gardens.") await expect(garden).toContainText(await shortDate(page, at(4, HOUR))) - // A card with more topics than fit on one line wraps them, rather than widening its action row - // until the reactions and the action menu fall off the card's edge. + // A card with more topics than fit on one line wraps them rather than widening its action row. const winter = articleCards(page).filter({hasText: "Winter Reading"}) const winterBox = (await winter.boundingBox())! const winterActions = (await winter.locator('[data-component="ArticleActions"]').boundingBox())! @@ -358,13 +347,10 @@ test("US-039 comment on an article", async ({seed, as}) => { await noteEditor(composerForm(bob)).pressSequentially("The soil chapter is the good one.") 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 comment. + // The composer holds what was typed until the relay confirms it, so the page carries this text twice. await expect(comment(bob, "The soil chapter is the good one.")).toBeVisible() - // A comment on a room event is a room event, so it carries the room the root lives in. Without - // that tag the relay doesn't see it as part of the group, and neither its access rules nor a - // room deletion ever reach it. + // A comment on a room event is a room event, so without the room tag the relay sees no group. await expect .poll(() => getPublishedEvents(bob.context(), COMMENT).map(event => tagValue(tagSpec("h"), event.tags)), @@ -413,8 +399,7 @@ test("US-039 comment on an article", async ({seed, as}) => { await composer.locator('[data-tip="Add an image"]').click() await (await chooser).setFiles({name: "bed.gif", mimeType: "image/gif", buffer: GIF}) - // The attachment carries an uploading marker from the moment the request goes out until the - // blossom descriptor comes back and replaces its blob url. + // The attachment carries an uploading marker until the blossom descriptor replaces its blob url. await expect(composer.locator(".tiptap-object")).not.toHaveClass(/tiptap-uploading/) await composer.getByRole("button", {name: "Comment"}).click() @@ -437,8 +422,7 @@ test("US-040 react to a post with an emoji", async ({seed, as}) => { space.profile(user.alice, {name: "Alice Anderson"}) space.profile(user.bob, {name: "Bob Barker"}) - // A profile's notes are loaded through its author's outbox relays, so alice needs a relay list - // for her note to be findable at all. + // A profile's notes are loaded through its author's outbox relays, so alice needs a relay list. space.relayList(user.alice) const article = space.event( @@ -561,9 +545,7 @@ 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. 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. + // The room's copy is published after the composer has moved on, and a page that unloads takes it. await expect.poll(() => getPublishedEvents(page.context(), MESSAGE)).toHaveLength(1) await page.goto(roomPath(url, "lounge")) @@ -578,8 +560,7 @@ test("US-041 publish an article from a room", async ({seed, as}) => { await openArticle(page, "Repotting in Winter") - // A card says which room an article was posted in; the article's own page carries that in its - // page bar instead. + // A card says which room an article was posted in; the article's own page carries it in the page bar. const roomLink = pageBar(page).getByRole("link", {name: /#\s*Lounge/}) await expect(roomLink).toBeVisible() @@ -600,8 +581,7 @@ test("US-042 start a thread and see it filed under its room", async ({seed, as}) space.profile(user.alice, {name: "Alice Anderson"}) space.profile(user.bob, {name: "Bob Barker"}) - // An older topic with replies, so the board's reply count and last-post time are statements - // about the thread rather than about an empty row. + // An older topic with replies, so the reply count and last-post time are about a thread. const topic = space.event( user.bob, () => @@ -670,8 +650,7 @@ test("US-042 start a thread and see it filed under its room", async ({seed, as}) .locator("section") .filter({has: page.getByRole("heading", {name: "General", exact: true})}) - // Each board creates its own threads, so the room comes from the button that was clicked rather - // than from a picker. + // Each board creates its own threads, so the room comes from the button that was clicked. await general.getByRole("button", {name: "Create", exact: true}).click() const fromThreads = modalForm(page, "Create a Thread") @@ -724,8 +703,7 @@ test("US-043 reply to a thread and to a specific post", async ({seed, as}) => { at(3, HOUR), ) - // Twenty replies is exactly the window a thread opens with, and the last of them is alice's, - // so her OP badge has to survive bob's own reply pushing the oldest one out of it. + // Twenty replies is exactly the window a thread opens with, and the last of them is alice's. for (let i = 1; i <= 20; i++) { space.event( i === 20 ? user.alice : i % 2 === 0 ? user.carol : user.bob, @@ -777,8 +755,7 @@ test("US-043 reply to a thread and to a specific post", async ({seed, as}) => { ) .toEqual(["lounge"]) - // His is the twenty first reply, so the oldest one drops out of the window, and the opening - // post stays above whatever the window holds. + // His is the twenty first reply, so the oldest one drops out of the window. const showEarlier = bob.getByRole("button", {name: "Show earlier replies"}) await expect(showEarlier).toBeVisible() @@ -880,8 +857,7 @@ test("US-044 navigate a long thread", async ({seed, as}) => { const showEarlier = bob.getByRole("button", {name: "Show earlier replies"}) - // Every assertion below is about which of the replies are in the window, so wait until they - // have all arrived. + // Every assertion below is about which of the replies are in the window, so wait for all of them. await expect(bob.getByText("41 replies")).toBeVisible() // The thread opens on its newest twenty replies, with the opening post above them. @@ -955,8 +931,7 @@ test("US-045 turn a chat message into a thread", async ({seed, as}) => { const composer = modalForm(page, "Create a Thread") const nevent = nip19.neventEncode({id: promoted.id, kind: MESSAGE, relays: [url]}) - // The seeded entity is parsed, so the composer shows the editor's chip for it rather than - // the raw uri — which is also what makes the thread carry a q tag for the message. + // The seeded entity is parsed, which is also what makes the thread carry a q tag for the message. await expect(noteEditor(composer)).toContainText(`${nevent.slice(0, 16)}...`) await composer.getByPlaceholder("What is this thread about?").fill("Deploy failures") diff --git a/e2e/specs/auth.spec.ts b/e2e/specs/auth.spec.ts index 52a42166..43714c88 100644 --- a/e2e/specs/auth.spec.ts +++ b/e2e/specs/auth.spec.ts @@ -37,7 +37,6 @@ test("authenticates over nip-42 before a members-only relay serves anything", as pubkey: users.alice.pubkey, }) - // The whole point of intercepting the transport rather than swapping in an adapter: until the - // client proves who it is, the relay hands it nothing at all. + // Until the client proves who it is, the relay hands it nothing at all. expect(firstEventAt).toBeGreaterThan(authenticatedAt) }) diff --git a/e2e/specs/community.spec.ts b/e2e/specs/community.spec.ts index 97f19c99..88ca51f4 100644 --- a/e2e/specs/community.spec.ts +++ b/e2e/specs/community.spec.ts @@ -47,22 +47,17 @@ import type {TestUser} from "../harness" // A handle to a seeded event, which only reads once seed() has drained its queue. type Seeded = {readonly id: string; readonly event: SignedEvent} -// A shelf is a card and its menu button side by side, so the menu is reached through the wrapper -// the two share. +// A shelf is a card and its menu button side by side, so the menu is reached through their wrapper. const shelfCard = (page: Page, title: string) => page.getByRole("button", {name: new RegExp(title)}).locator("xpath=..") -// A list item is one big link, but Button swallows both the default and the propagation of every -// click it handles, and the middle of a card is usually one of those — a poll's radio, a goal's -// zap button, a listing's image. Opening a card by its title lands on inert text instead. +// Button swallows both the default and the propagation of a click, and the middle of a card is one. const openCard = (card: Locator, title: string) => card.getByText(title).click() -// One option of a poll, which PollOption renders as a small card carrying its label, its count and -// its progress bar. +// PollOption renders an option as a small card carrying its label, its count and its progress bar. const pollOption = (page: Page, label: string) => page.locator(".card-sm").filter({hasText: label}) -// The selections of every poll response this page put on the wire, oldest first. A multiple choice -// vote goes out once the delay window closes, so this is where "both publish" is visible. +// A multiple choice vote goes out once the delay window closes, so "both publish" is visible here. const pollResponses = (page: Page, pollId: string) => getTranscript(page.context()) .filter( @@ -104,9 +99,7 @@ test("US-046 create and browse a calendar event", async ({seed, as}) => { at(1, HOUR), ) - // Ten events that have already happened, so the list is taller than the viewport and "opens - // scrolled to the next upcoming event" is a statement about where it sits rather than about a - // list that fits on one screen anyway. + // Ten events that have already happened, so the list is taller than the viewport. for (let i = 1; i <= 10; i++) { addEvent(`Past Meetup ${String(i).padStart(2, "0")}`, at((11 - i) * 4, DAY)) } @@ -127,8 +120,7 @@ test("US-046 create and browse a calendar event", async ({seed, as}) => { await expect(cards).toHaveCount(12) - // The calendar opens on the first event that hasn't happened yet, with the oldest one scrolled - // out of the way above it. + // The calendar opens on the first event that hasn't happened yet. await expect(card("Autumn Fair")).toBeInViewport() await expect(card("Past Meetup 01")).not.toBeInViewport() @@ -136,9 +128,7 @@ test("US-046 create and browse a calendar event", async ({seed, as}) => { const composer = modalForm(page, "Create an Event") - // Field renders its label and its input as siblings rather than wiring them together, so the - // form's two writable text fields are taken in document order: title, then location. The date - // range's own input is readonly, which is what leaves those two. + // Field renders its label and input as siblings, so the writable fields are taken in document order. const textInputs = composer.locator('input[type="text"]:not([readonly])') const title = textInputs.first() const location = textInputs.last() @@ -154,8 +144,7 @@ test("US-046 create and browse a calendar event", async ({seed, as}) => { await composer.getByRole("button", {name: "Save Event"}).click() await expect(page.getByRole("alert")).toContainText("Please provide start and end times.") - // The picker takes two clicks for a range: the first is the start, the second the end, each at - // the time its own time input carries — noon and one, for a range that starts empty. + // The picker takes two clicks for a range, each at the time its own time input carries. const target = new Date() target.setMonth(target.getMonth() + 1, 15) @@ -217,8 +206,7 @@ test("US-047 manage your own calendar event", async ({seed, as}) => { await openCard(page.getByRole("link").filter({hasText: "Harvest Supper"}), "Harvest Supper") - // The hero card (date, header, meta, actions) is always the first feature card on the page; - // the About tab renders its own feature card below it for the description. + // The hero card is always the first feature card on the page; the About tab renders one below it. const eventCard = page.locator(".card.z-feature").first() await expect(page.getByRole("heading", {name: "Harvest Supper", exact: true})).toBeVisible() @@ -251,8 +239,7 @@ test("US-047 manage your own calendar event", async ({seed, as}) => { await confirmDelete.click() - // The badge is an optimistic local write, so it says nothing about the relay. The confirmation - // stays up until the retraction has been published, which is what makes it safe to reload. + // The badge is an optimistic local write, and the confirmation stays up until the retraction is out. await expect(eventCard.getByText("Deleted", {exact: true})).toBeVisible() await expect(confirmDelete).toHaveCount(0) @@ -275,8 +262,7 @@ test("US-048 create a poll and vote on it", async ({seed, as}) => { space.profile(user.alice, {name: "Alice Anderson"}) space.profile(user.bob, {name: "Bob Barker"}) - // The multiple choice half of the story is about voting rather than about creating, and a - // wire assertion needs option ids known up front, so these two are fixtures. + // A wire assertion needs option ids known up front, so these two are fixtures. snacks = space.event( user.alice, () => @@ -460,12 +446,10 @@ test("US-049 a closed poll shows final results only", async ({seed, as}) => { }) test("US-050 create a funding goal and track its progress", async ({seed, as}) => { - // The lightning address on bob's profile, and the lnurl endpoint the client resolves it to. A - // receipt is only counted once that endpoint answers with the zapper that signed it. + // A receipt is only counted once the lnurl endpoint answers with the zapper that signed it. const lud16 = "bob@zap.test" const lnurl = getLnUrl(lud16)! - // A zap receipt is signed by the recipient's lightning provider rather than by either party to - // the zap, so the provider is an identity of its own — and one zooid will take a write from. + // A zap receipt is signed by the recipient's lightning provider rather than by either party. const provider = makeTestUser("zapper") let running!: Seeded @@ -483,8 +467,7 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) = space.kind(Profile).writer().update({name: "Bob Barker", lud16}).renderTemplate(), ) - // Two and a half days old, so "how long it has been running" rounds to three whatever second - // of the run this renders on. + // Two and a half days old, so how long it has been running rounds to three whenever this renders. running = space.event( user.bob, () => @@ -513,10 +496,7 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) = at(30, HOUR), ) - // What a wallet publishes once an invoice is paid: the payer's own zap request carried as the - // receipt's description, signed by the provider the recipient's lnurl names. A receipt whose - // invoice disagrees with the amount its request asked for is thrown away by the client, so the - // two are rendered from the same number. + // A receipt whose invoice disagrees with the amount its request asked for is thrown away. const contribute = (from: TestUser, sats: number, createdAt: number) => space.event( provider, @@ -533,8 +513,7 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) = created_at: createdAt, }) - // What was paid is read straight out of the invoice's human-readable part, where an `n` - // is a tenth of a sat, so that prefix is all of a bolt11 that has to be real. + // What was paid is read out of the invoice's human-readable part, where an `n` is a tenth of a sat. return space .kind(ZapReceipt) .writer() @@ -614,8 +593,7 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) = "3", ) - // Registered after the page was opened, so it answers ahead of the empty dufflepud `as()` - // installs, and before the navigation below, since a zapper is looked up once per page load. + // Registered after the page opened, so it answers ahead of the empty dufflepud `as()` installs. await mockDufflepud(page.context(), { zappers: [ { @@ -773,8 +751,7 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => { at(3, HOUR), ) - // Three comments already there, so bob's fills the four the page shows without asking and - // alice's tips it past them. + // Three comments already there, so bob's fills the four the page shows and alice's tips it past. for (let i = 1; i <= 3; i++) { space.event( user.carol, @@ -942,8 +919,7 @@ test("US-053 browse and search the library", async ({seed, as}) => { await expect(alice.getByText("This shelf doesn't have any links yet.")).toBeVisible() - // The library is the whole space's, so an ordinary member is offered the same controls the - // shelves were made with. + // The library is the whole space's, so an ordinary member is offered the same controls. await expect(alice.getByRole("button", {name: "Create Shelf"})).toBeVisible() await expect(alice.getByRole("button", {name: "Add a link"})).toBeVisible() @@ -1140,8 +1116,7 @@ test("US-055 create community content from a room", async ({seed, as}) => { await expect(page.getByRole("heading", {name: "Create a Poll"})).toHaveCount(0) - // The room gets a quote of the poll rather than a second post written by hand. A room item is - // itself a role=button tap target wrapping its content, so the quote is the inner of the two. + // A room item is itself a role=button wrapping its content, so the quote is the inner of the two. const quote = page.getByRole("button").filter({hasText: "Pizza or tacos?"}).last() await expect(quote).toBeVisible() diff --git a/e2e/specs/composer.spec.ts b/e2e/specs/composer.spec.ts index a25751f7..adf4d42e 100644 --- a/e2e/specs/composer.spec.ts +++ b/e2e/specs/composer.spec.ts @@ -29,14 +29,11 @@ const roomUploadButton = (page: Page) => page.locator(".room__compose-inner .joi const chatUploadButton = (page: Page) => page.locator("button[data-tip='Add an image']") -// Both things the composer puts above itself — the message being replied to and the editing -// indicator — are the same bordered strip, each with a single button in it: its X. +// The reply preview and the editing indicator are the same bordered strip, each with its own X. const banner = (page: Page, text: string) => page.locator(".room__compose .border-l-2").filter({hasText: text}) -// Both of these are DataTransfer dispatches rather than anything native: prosemirror reads the file -// straight off the event, and playwright's own dispatchEvent builds a plain Event, which would drop -// the dataTransfer the handler needs. +// prosemirror reads the file off the event, and playwright's dispatchEvent drops the dataTransfer. const dropImage = (editor: Locator, name: string) => editor.evaluate( (node, {name, data}) => { @@ -91,8 +88,7 @@ test("US-056 autocomplete a mention or a room reference", async ({seed, as}) => space.relayList(user.bob) space.message(user.bob, "general", "morning all", at(2, HOUR)) - // Someone whose name matches the same term but who belongs to a different space, so the - // dropdown has a non-member to rank below this space's own members. + // Someone matching the same term from a different space, to rank below this space's own members. other.room("lounge", {name: "Lounge"}) other.join(user.alice, "lounge") other.join(outsider, "lounge") @@ -104,9 +100,7 @@ test("US-056 autocomplete a mention or a room reference", async ({seed, as}) => const space = scenario.space("space") const other = scenario.space("other") - // Alice arrives through the other space so that the outsider's profile is in her client before - // she composes: a profile reaches her by being rendered, and his never renders in the space she - // is about to type in. + // A profile reaches her by being rendered, and his never renders in the space she is typing in. const page = await as(users.alice, roomPath(other.url, "lounge")) // Exactly, since the join notice above his message carries his name too, as "@Bobbin Amaranth". @@ -114,8 +108,7 @@ test("US-056 autocomplete a mention or a room reference", async ({seed, as}) => timeline(page).getByRole("button", {name: "Bobbin Amaranth", exact: true}), ).toBeVisible() - // A space's nav item is labeled with the name its nip-11 document reports, and the two tenants - // report "space" and "other". + // A space's nav item is labeled with the name its nip-11 document reports. await page.locator('.primary-nav [data-tip="space"]').click() await page.locator(".secondary-nav").getByRole("link", {name: "General"}).click() @@ -224,8 +217,7 @@ test("US-057 attach and send an image", async ({seed, as}) => { await expect(composer(alice)).toContainText("pasted.gif") await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0) - // The same thing in a conversation. Its composer stays disabled until the recipient's messaging - // relays have been read, which is what waiting on the composer waits out. + // Its composer stays disabled until the recipient's messaging relays have been read. await alice.goto(chatPath(users.bob.pubkey)) await bob.goto(chatPath(users.alice.pubkey)) @@ -235,19 +227,15 @@ test("US-057 attach and send an image", async ({seed, as}) => { await expect(composer(alice)).toContainText("selfie.gif") - // The file node appears the moment it is attached, which is also the moment `uploading` goes - // true — and submit returns without a word while it is. Encryption makes that window wide enough - // to press enter into, so wait the upload out rather than losing the message to it. + // Submit returns without a word while `uploading` is true, and encryption makes that window wide. await expect(sendButton(alice)).toBeEnabled() await composer(alice).press("Enter") - // A conversation's image is uploaded encrypted, so the recipient fetches the ciphertext and - // decrypts it into a blob url rather than pointing an at the server. + // A conversation's image is uploaded encrypted, so the recipient decrypts it into a blob url. await expect(bob.locator('.chat-bubble img[src^="blob:"]')).toBeVisible() - // Back to the room on a fresh page, so no toast the conversation raised is still standing and the - // one asserted below can only be the rejected upload's own. + // Back to the room on a fresh page, so no toast the conversation raised is still standing. await alice.goto(path) await expect(timeline(alice).getByText("morning all")).toBeVisible() @@ -285,8 +273,7 @@ test("US-058 drafts survive navigating away", async ({seed, as}) => { space.join(user.bob, "general") space.message(user.bob, "general", "morning all", at(2, HOUR)) - // Alice's own kind-10050 is what lets the Messages nav item navigate rather than stop to ask - // her to enable chat; bob's is what enables the conversation's composer. + // Her kind-10050 lets the Messages nav navigate; his enables the conversation's composer. for (const person of [user.alice, user.bob]) { space.profile(person, {name: person.name}) space.relayList(person) @@ -294,8 +281,7 @@ test("US-058 drafts survive navigating away", async ({seed, as}) => { } }) - // Drafts live in memory, so every move here is an in-app navigation — a reload would clear them - // whether or not they were kept. + // Drafts live in memory, so every move here is an in-app navigation. const page = await as(users.alice, chatPath(users.bob.pubkey)) const editor = composer(page) const rooms = page.locator(".secondary-nav") @@ -331,8 +317,7 @@ 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. + // The composer is remounted around a restored draft, so put the caret in it before sending. await editor.click() await editor.press("Enter") @@ -449,8 +434,7 @@ test("US-125 dictate a message", async ({seed, as}) => { const alice = await as(users.alice, roomPath(url, "general")) const transcription = await mockOpenRouterTranscription(alice.context(), "the tide turns at six") - // Recording asks for nothing. Asking for a transcript with no key saved asks for one, the same - // prompt reading a message out loud uses. + // Recording asks for nothing. Asking for a transcript with no key saved asks for one. const action = await record(alice) await action.getByRole("button", {name: "Transcribe it"}).click() @@ -467,22 +451,19 @@ test("US-125 dictate a message", async ({seed, as}) => { await expect(composer(alice)).toContainText("the tide turns at six") - // OpenRouter picks its decoder off the extension, so what the recorder produced has to reach it - // under a name that names the format. + // OpenRouter picks its decoder off the extension, so the upload has to name the format. expect(transcription.uploads).toEqual([expect.stringMatching(/^dictation\.\w+$/)]) await composer(alice).press("Enter") await expect(timeline(alice)).toContainText("the tide turns at six") - // A transcription outlives the composer that asked for it: the room it was recorded in can be - // left while the request is still out, and the transcript waits for whichever composer is next. + // A transcription outlives the composer that asked for it, and waits for whichever comes next. transcription.hold() await (await record(alice)).getByRole("button", {name: "Transcribe it"}).click() - // In-app rather than a fresh load: a dictation is held by the app rather than by the composer - // that started one, so reloading the page is losing it rather than leaving it. + // A dictation is held by the app rather than by the composer, so reloading the page is losing it. await roomLink(alice, "Lounge").click() await expect(composer(alice)).toBeVisible() @@ -507,8 +488,7 @@ test("US-126 send a voice note", async ({seed, as}) => { await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN}) - // Leaving the question unanswered throws the recording away, so nothing is uploaded and the - // composer is where it was. + // Leaving the question unanswered throws the recording away. await (await record(alice)).getByRole("button", {name: "Discard"}).click() await expect(dialog(alice, "Transcribe or send?")).toHaveCount(0) @@ -521,7 +501,6 @@ test("US-126 send a voice note", async ({seed, as}) => { await composer(alice).press("Enter") - // The imeta on the message says the upload is audio, which is what gives it a player rather - // than a link. + // The imeta on the message says the upload is audio, which gives it a player rather than a link. await expect(timeline(alice).locator(`audio[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible() }) diff --git a/e2e/specs/content-rendering.spec.ts b/e2e/specs/content-rendering.spec.ts index b8879412..69f02ed3 100644 --- a/e2e/specs/content-rendering.spec.ts +++ b/e2e/specs/content-rendering.spec.ts @@ -7,20 +7,16 @@ import {expect, roomPath, spacePath, test, users} from "../harness" // A handle to a seeded event, which only reads once seed() has drained its queue. type Seeded = {readonly id: string} -// A bolt11 invoice as @welshman/content recognizes one: only behind a `lightning:` scheme, and -// what the chip renders and copies is what follows the scheme. +// @welshman/content takes an invoice only behind a `lightning:` scheme, and renders what follows. const INVOICE = "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq" + "ypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3" + "agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9" -// An event id no fixture publishes. A quote card is in its loading state until the quoted event -// arrives, and only one that never arrives holds it there long enough to assert: the relay is a -// container on loopback, so a quote it can answer resolves before a locator has resolved. +// The relay is a container on loopback, so only a quote that never arrives holds the loading state. const UNKNOWN_EVENT_ID = "6f1ac4b0d2e37f5981c6ab4e2d0937fc85be1a2d3c4f5061728394a5b6c7d8e9" -// A cashu token keeps its own scheme in the value, and needs fifty-odd payload characters after -// it before the parser will take it. +// A cashu token keeps its own scheme in the value, and needs fifty-odd payload characters after it. const CASHU = "cashu:cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJpZCI6IjAwOWExZjI5MzI1M2U0MWUiLCJhbW91bnQiOjIs" + "InNlY3JldCI6ImFhYSIsIkMiOiJiYmIifV0sIm1pbnQiOiJodHRwczovL21pbnQudGVzdCJ9XX0=" @@ -52,8 +48,7 @@ test("US-060 reveal a flagged sensitive message", async ({seed, as}) => { const {url} = scenario.space("space") const page = await as(users.alice, roomPath(url, "general")) - // Scoped to bob's own message, so "in place of his text" is a claim about this message rather - // than about the room as a whole. + // Scoped to bob's own message, so this is a claim about it rather than about the room. const message = page.locator(`[data-event="${flagged.id}"]`) const warning = message.getByText('flagged by the author as "spoilers"') @@ -110,8 +105,7 @@ test("US-061 expand a long post", async ({seed, as}) => { await expect(page.getByText(paragraphs[11])).toBeVisible() - // The whole card is a link into the article, so expanding in place means the click never - // reached it. + // The whole card is a link into the article, so expanding in place means the click never reached. expect(page.url()).toBe(before) }) @@ -196,8 +190,7 @@ test("US-063 preview a shared link", async ({seed, as}) => { const {url} = scenario.space("space") - // The room is opened by hand below rather than by as(): a preview is fetched once per url and - // memoized for the life of the page, so the backend has to be standing before the first render. + // A preview is fetched once per url and memoized for the life of the page. const page = await as(users.alice, "/") let servePreview = () => {} @@ -206,8 +199,7 @@ test("US-063 preview a shared link", async ({seed, as}) => { servePreview = resolve }) - // Registered after as(), so it answers ahead of the harness's own dufflepud. Holding the one - // preview open makes the loading state a fact rather than a race. + // Registered after as(), so it answers ahead of the harness's own dufflepud. await page.context().route( requestUrl => requestUrl.pathname === "/link/preview", async route => { @@ -246,8 +238,7 @@ test("US-063 preview a shared link", async ({seed, as}) => { await expect(card.getByText("Everything new in this release.")).toBeVisible() await expect(card.locator(previewImage)).toBeVisible() - // The same url mid-sentence, now that the preview it would have shown is known to resolve: a - // compact link, and none of the card. + // The same url mid-sentence, now that the preview it would have shown is known to resolve. await expect(inline.getByRole("link", {name: "example.test/announcement"})).toBeVisible() await expect(inline.getByText("Flotilla ships v1")).toHaveCount(0) await expect(inline.locator(previewImage)).toHaveCount(0) @@ -323,8 +314,7 @@ test("US-065 see quoted and embedded content", async ({seed, as}) => { space.message(user.bob, "general", `filler message number ${i}`, at(300 - i * 5, MINUTE)) } - // Carol says something in the room too, so her profile is loaded by the time her name has to - // render on a card quoting her thread. + // Carol says something in the room too, so her profile is loaded before her name has to render. space.message(user.carol, "general", "posted a roadmap", at(140, MINUTE)) const topic = space.event( @@ -357,9 +347,7 @@ test("US-065 see quoted and embedded content", async ({seed, as}) => { const threadMessage = page.locator(`[data-event="${threadQuote.id}"]`) - // The state a quote card is in until the quoted event arrives. It is asserted on the quote - // nothing can answer rather than on the thread below, which resolves off an open socket to - // loopback and is as likely to be a card by the time this runs as a placeholder. + // Asserted on the quote nothing can answer, since the thread resolves off a socket to loopback. const unresolvableMessage = page.locator(`[data-event="${unresolvable.id}"]`) await expect(unresolvableMessage.getByText("Loading event...")).toBeVisible() @@ -379,8 +367,7 @@ test("US-065 see quoted and embedded content", async ({seed, as}) => { await expect(originalMessage).toBeInViewport() - // The quoted thread's card, once it has loaded: the profile circle beside the message carries - // the same border classes, so the title is what says which of them is meant. + // The profile circle beside the message carries the same border classes, so the title says which. const quotedThread = threadMessage.locator(".border.border-solid").filter({ hasText: "Roadmap for Q3", }) @@ -421,8 +408,7 @@ test("US-066 see distinctive inline tokens", async ({seed, as}) => { // Bob posts too, so his profile is loaded against this space before he is mentioned. space.message(user.bob, "general", "morning all", at(50, MINUTE)) - // The shortcode's image comes off the message's own emoji tag, which is what a client writes - // when someone picks a custom emoji. + // The shortcode's image comes off the message's own emoji tag. tokens = space.event( user.carol, makeEvent(MESSAGE, { @@ -443,8 +429,7 @@ test("US-066 see distinctive inline tokens", async ({seed, as}) => { await expect(message.getByText("#nostr", {exact: true})).toHaveClass(/link-content/) await expect(message.getByAltText(":partyparrot:")).toBeVisible() - // An img contributes no text, so this is the "instead of the raw text" half of the story: with - // no emoji tag to resolve, the shortcode is rendered verbatim. + // An img contributes no text, so with no emoji tag to resolve the shortcode is rendered verbatim. await expect(message.getByText(":partyparrot:")).toHaveCount(0) const inlineCode = message.locator("code").filter({hasText: "npm install"}) @@ -502,8 +487,7 @@ test("US-067 copy a shared invoice or token", async ({seed, as}) => { await expect(toast).toContainText("Copied to clipboard!") expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(INVOICE) - // Both copies raise the same toast, so this one is dismissed rather than waited out — otherwise - // the second assertion would pass on the first toast. + // Both copies raise the same toast, so this one is dismissed rather than waited out. await toast.getByRole("button").click() await expect(toast).toHaveCount(0) diff --git a/e2e/specs/delivery.spec.ts b/e2e/specs/delivery.spec.ts index c929eea9..17adb610 100644 --- a/e2e/specs/delivery.spec.ts +++ b/e2e/specs/delivery.spec.ts @@ -34,15 +34,13 @@ import type {SeededSpace, TestUser} from "../harness" const articlePath = (url: string, address: string) => `${spacePath(url)}/articles/${encodeURIComponent(address)}` -// Outbox routing resolves everything about a person through their relay list, so a person here is -// a membership, a profile and a kind-10002. +// Outbox routing resolves a person through their relay list, so a person here has a kind-10002. const seedPerson = (space: SeededSpace, user: TestUser, name: string) => { space.profile(user, {name}) space.relayList(user) } -// A tippy is appended to the layout's own target rather than beside its trigger, and it keeps its -// content mounted after it hides, so the visible card there is the popover that was just opened. +// A tippy is appended to the layout's own target and keeps its content mounted after it hides. const detail = (page: Page) => page.locator(".tippy-target .card").filter({visible: true}) // A comment is a flat block in the comment tree rather than a card, named by its text. @@ -71,16 +69,13 @@ const writeComment = async (page: Page, body: string) => { const form = 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. + // The editor takes focus itself once it has mounted, and typing before that puts the caret back. await expect(noteEditor(form)).toBeFocused() await noteEditor(form).pressSequentially(body) await form.getByRole("button", {name: "Comment"}).click() } -// The send delay is a user setting rather than a page's own state, so it is set the way a person -// sets it. Reading it back off a freshly loaded page is what says the client has taken it up: the -// slider is initialised from the settings store on mount. +// The slider is initialised from the settings store on mount, so a fresh page says the client took it. const setSendDelay = async (page: Page, seconds: number) => { const slider = page.locator("input[type=range]") @@ -158,8 +153,7 @@ test("US-068 watch a delayed send, and cancel it", async ({seed, as}) => { await expect(message(alice, "wrong room, sorry")).toHaveCount(0) await expect(toast(alice)).toHaveCount(0) - // Sent after the cancelled one and delayed by as long, so bob having this means the window the - // cancelled one would have left in has been and gone. + // Sent after the cancelled one and delayed by as long, so its window has been and gone. await send(alice, "still here") await expect(message(bob, "still here")).toBeVisible() @@ -175,8 +169,7 @@ test("US-068 watch a delayed send, and cancel it", async ({seed, as}) => { await toast(alice).getByRole("button", {name: "Cancel"}).click() - // That was her only message to him, so the conversation goes with it rather than staying in - // the list with nothing left to name it by. + // That was her only message to him, so the conversation goes with it. await expect(chatItems(alice)).toHaveCount(0) await send(alice, "actually, hi") @@ -202,8 +195,7 @@ test("US-069 see why a message failed to deliver", async ({seed, as}) => { } space.messagingRelayList(user.alice) - // Bob's client says his messages go to both relays, but he is not a member of the second, so a - // wrap addressed to him is stored by one and refused by the other. + // Bob's client names both relays, but he is a member of one, so the other refuses a wrap for him. space.messagingRelayList(user.bob, [space.url, other.url]) space.message(user.bob, "general", "morning all", at(2, HOUR)) @@ -211,8 +203,7 @@ test("US-069 see why a message failed to deliver", async ({seed, as}) => { const {url} = scenario.space("space") - // A room the relay never created, which is where a link to a room that has since been deleted - // lands. Everything else about the space still works, so only this one publish is refused. + // A room the relay never created, which is where a link to a room that has since been deleted lands. const ghost = roomPath(url, "archive") const alice = await as(users.alice, ghost) @@ -224,8 +215,7 @@ test("US-069 see why a message failed to deliver", async ({seed, as}) => { await expect(failure).toBeVisible() - // The text is still hers to see, and the publish is finished and was refused, so there is - // nothing left for bob to receive. + // The text is still hers to see, and the publish is finished and was refused. await expect(message(alice, "anyone here?")).toContainText("anyone here?") await expect(message(bob, "anyone here?")).toHaveCount(0) @@ -269,13 +259,10 @@ test("US-070 retry a failed relay", async ({seed, as}) => { const ghost = roomPath(url, "archive") const alice = await as(users.alice, ghost) - // Opened now rather than once the room exists: seeding again below replaces the scenario, and a - // page opened from that one carries no room list — which is the only thing telling authPolicy it - // may answer a members-only relay's challenge, so bob would never get to read anything. + // Seeding again replaces the scenario, and a page opened from that one carries no room list. const bob = await as(users.bob, roomPath(url, "general")) - // A delay is what makes the retry's own "Sending..." a state rather than an instant, so this - // watches a retry the way the person who asked for it does. + // A delay is what makes the retry's own "Sending..." a state rather than an instant. await setSendDelay(alice, 5) await alice.goto(ghost) @@ -290,13 +277,11 @@ test("US-070 retry a failed relay", async ({seed, as}) => { await expect(toast(alice)).toContainText("Sending...") - // The room still does not exist, so this attempt is refused too: the toast goes without ever - // having said the message was sent, and the message is still marked failed. + // The room still does not exist, so this attempt is refused too. await expect(toast(alice)).toHaveCount(0) await expect(failure).toBeVisible() - // The admin creates the room the message was addressed to. Seeding again is the only way the - // relay changes its mind about something it has already refused. + // Seeding again is the only way the relay changes its mind about something it has already refused. await seed(({relay}) => { relay("space").room("archive", {name: "Archive"}) }) @@ -335,14 +320,12 @@ test("US-071 content posts show delivery status in place", async ({seed, as}) => const {url} = scenario.space("space") const quiet = scenario.space("other").url - // A space whose relay this browser will not open a socket to, which is what a post that sits - // unconfirmed looks like from the inside: it is sent, and nothing comes back. + // A space whose relay this browser will not open a socket to, so a post is sent and nothing returns. const alice = await as(users.alice, `${spacePath(url)}/articles`, { env: {VITE_BLOCKED_RELAYS: quiet}, }) - // A comment leaves after the send delay the way a chat message does, so the window in which its - // Cancel link is live is hers to set. + // A comment leaves after the send delay the way a chat message does. await setSendDelay(alice, 1) await alice.goto(`${spacePath(url)}/articles`) @@ -367,9 +350,7 @@ 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.") - // 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. + // Nothing was refused, so there is no toast; the action bar says the relay never answered. const stuck = articleActions(alice) await expect(stuck.getByText("Failed to send!")).toBeVisible() @@ -482,8 +463,7 @@ test("US-072 a deleted post is marked deleted", async ({seed, as}) => { 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 it offered before. + // This page is the article's own view, so it stays and is marked deleted rather than vanishing. await expect(article.getByText("Deleted", {exact: true})).toBeVisible() await expect(article.getByRole("button", {name: "Add a reaction"})).toHaveCount(0) }) @@ -502,8 +482,7 @@ test("US-073 a multi-part message reports one status", async ({seed, as}) => { space.messagingRelayList(user.alice) space.messagingRelayList(user.bob) - // Carol's client names a relay she does not belong to, so every part of a message to her is - // stored by one of her two relays and refused by the other. + // Carol's client names a relay she does not belong to, so one of the two refuses each part. space.messagingRelayList(user.carol, [space.url, other.url]) }) @@ -519,8 +498,7 @@ test("US-073 a multi-part message reports one status", async ({seed, as}) => { await composer(alice).pressSequentially("here is the harbour") await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), gifFile("harbour.gif")) - // The editor names the file the moment it is attached, so the name alone does not mean the - // upload is done — and a submit while it is still running is dropped on the floor. + // The editor names the file the moment it is attached, and a submit while it uploads is dropped. await expect(composer(alice)).toContainText("harbour.gif") await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0) await expect(sendButton(alice)).toBeEnabled() diff --git a/e2e/specs/dms.spec.ts b/e2e/specs/dms.spec.ts index 1bce434c..7b4ed955 100644 --- a/e2e/specs/dms.spec.ts +++ b/e2e/specs/dms.spec.ts @@ -26,11 +26,7 @@ import { } from "../harness" import type {SeededRumor, SeededSpace, TestUser} from "../harness" -// Everything about a person is resolved through their relay list — their profile, their messaging -// relays, the wraps addressed to them — and a scenario with no fallbacks resolves an author with -// no kind-10002 to no relays at all. So a person here is a membership, a profile and a relay list. -// Membership is also what lets a gift wrap addressed to them be stored: zooid authorizes a -// kind-1059 by the member named in its p tag. +// A person here is a membership, a profile and a relay list, since everything resolves through one. const seedPerson = (space: SeededSpace, user: TestUser, name: string, ...rooms: string[]) => { space.join(user, ...rooms) space.profile(user, {name}) @@ -46,8 +42,7 @@ const chatTab = (page: Page, name: string) => // ChatItem's unread mark is a bare dot with no text of its own. const unreadDots = (scope: Locator) => scope.locator(".rounded-full.bg-primary") -// The "..." menu on a profile header, which is the only ghost circle button either the profile -// page or the profile modal renders. +// The only ghost circle button either the profile page or the profile modal renders. const profileMenu = (scope: Page | Locator) => scope.locator("button.button-circle.button-ghost") // A modal's body is the only scroll container carrying its title. @@ -56,25 +51,21 @@ const modalBody = (page: Page, title: string) => .locator(".scroll-container") .filter({has: page.getByRole("heading", {name: title, exact: true})}) -// Both things the composer puts above itself — the message being replied to and the editing -// indicator — are the same bordered strip. +// The reply preview and the editing indicator are the same bordered strip. const composePreview = (page: Page) => page.locator(".room__compose .border-l-2") -// A conversation names its messages by id rather than by text: the same words are sent more than -// once in these stories, and only the id says which bubble is which. +// The same words are sent more than once in these stories, so only the id says which bubble is which. const message = (page: Page, id: string) => page.locator(`[data-event="${id}"]`) const enablePrompt = (page: Page) => page.getByRole("heading", {name: "Enable direct messaging?"}) -// The composer stays disabled until every recipient's messaging relays have been read, so waiting -// on it is part of sending rather than a wait for a wait's sake. +// The composer stays disabled until every recipient's messaging relays have been read. const sendDm = async (page: Page, content: string) => { await composerEnabled(page) await send(page, content) } -// A suggestion carries the pubkey it selects as its label, so the name is what gets typed and the -// pubkey is what identifies the row that comes back. +// A suggestion carries the pubkey it selects as its label, so the pubkey identifies the row. const startChat = async (page: Page, people: {term: string; user: TestUser}[]) => { await page.getByRole("button", {name: "Start New Chat"}).click() @@ -83,9 +74,7 @@ const startChat = async (page: Page, people: {term: string; user: TestUser}[]) = for (const {term, user} of people) { const suggestion = page.locator(`.tiptap-suggestions__item[aria-label="${user.pubkey}"]`) - // Typed a keystroke at a time rather than filled: the suggestion list is rebuilt off the term - // changing, so a term that arrives all at once is matched against whatever the profile search - // held at that instant and never again. + // The suggestion list is rebuilt off the term changing, so a term arriving at once is matched once. await page.getByPlaceholder("Search for profiles...").pressSequentially(term, {delay: 150}) await expect(suggestion).toContainText(term) @@ -96,14 +85,11 @@ const startChat = async (page: Page, people: {term: string; user: TestUser}[]) = await page.getByRole("button", {name: "Create Chat"}).click() } -// A message's actions live behind a hover popover on a pointer device and behind a modal of named -// buttons on a touch one, so a spec that wants to name them opens a touch context. +// A message's actions are a hover popover on a pointer device and named buttons on a touch one. const openMessageMenu = (page: Page, id: string) => message(page, id).locator(".chat-bubble").click() -// A day divider and a chat item's stamp, formatted by the browser rather than by node, so the -// locale and the timezone are the ones the app rendered with. The options mirror dateFormatter and -// dateTimeFormatter in @welshman/lib. +// Formatted by the browser rather than by node. The options mirror dateFormatter in @welshman/lib. const dayLabel = (page: Page, seconds: number) => page.evaluate( ts => @@ -122,10 +108,7 @@ const stampLabel = (page: Page, seconds: number) => seconds, ) -// What this user's client has written to disk, of one kind. Events reach indexeddb in three-second -// batches with nothing in the ui to say when one has landed, so a spec that takes the relay away -// and reloads has to read the cache first — otherwise it passes or fails on the batch window rather -// than on what was persisted. +// Events reach indexeddb in three-second batches, so a spec that reloads has to read the cache first. const cachedContent = async (page: Page, pubkey: string, kind: number) => (await readCachedEvents(page, pubkey)) .filter(event => event.kind === kind) @@ -149,16 +132,13 @@ test("US-029 start a one-on-one chat", async ({seed, as}) => { space.messagingRelayList(user.alice) space.messagingRelayList(user.bob) - // A conversation the three of them are already in. It puts bob's profile in her client before - // she goes looking for him, without being the one-on-one she is about to start. + // A conversation the three of them are already in, which puts bob's profile in her client. space.dm(user.carol, [user.alice, user.bob], "welcome aboard, both of you", at(3, HOUR)) }) const page = await as(users.alice, "/chat") - // A group conversation's item in the list names only the first of its participants, so his name - // rather than his npub in its header is the client saying it has his profile, which is what the - // search she is about to type into is built from. + // A group conversation's item in the list names only the first of its participants. await chatItems(page).filter({hasText: "welcome aboard"}).click() await expect(pageBar(page)).toContainText("Bob Barnacle") @@ -230,8 +210,7 @@ test("US-030 start a group chat", async ({seed, as}) => { const page = await as(users.alice, "/chat") - // Their names rather than their npubs is the client saying it has both profiles, which is what - // the search she is about to type into is built from. + // Their names rather than their npubs is the client saying it has both profiles. await expect(chatItems(page).filter({hasText: "just us two"})).toContainText("Bob Barnacle") await expect(chatItems(page).filter({hasText: "hello from carol"})).toContainText("Carol Cutter") @@ -507,8 +486,7 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as}) hers = space.dm(user.alice, [user.bob], "first attempt", at(10, MINUTE)) }) - // A touch context, which is what puts a message's actions behind named buttons rather than - // behind a row of icons in a hover popover. + // A touch context, which puts a message's actions behind named buttons rather than a hover popover. const alice = await as(users.alice, chatPath(users.bob.pubkey), {context: {hasTouch: true}}) const bob = await as(users.bob, chatPath(users.alice.pubkey)) @@ -549,8 +527,7 @@ 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. The menu - // leads with React and Reply and keeps the rest behind a disclosure. + // 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() @@ -579,8 +556,7 @@ test("US-035 reply to, edit, and react to a direct message", async ({seed, as}) const picker = alice.locator("emoji-picker").filter({visible: true}) - // A result's label is the emoji, its annotation and every shortcode joined together, so the - // annotation is what gets matched rather than the whole of it. + // A result's label joins the emoji, its annotation and every shortcode. await picker.locator("input.search").fill("party popper") await picker.getByRole("option", {name: /party popper/}).click() @@ -611,8 +587,7 @@ test("US-036 receive a new conversation live", async ({seed, as}) => { space.messagingRelayList(user.alice) space.messagingRelayList(user.bob) - // A conversation each of them already has. A list with something in it is how each page says - // its own end of the sync is up, before the one that has to arrive live is sent. + // A list with something in it is how each page says its own end of the sync is up. space.dm(user.carol, [user.alice], "see you monday", at(4, HOUR)) space.dm(user.carol, [user.bob], "you too", at(4, HOUR)) }) @@ -634,8 +609,7 @@ test("US-036 receive a new conversation live", async ({seed, as}) => { await sendDm(bob, "starting a chat with you") - // His own copy first, so a send that lost keystrokes to something else on the page fails here - // rather than thirty seconds later as a message that never reached her + // His own copy first, so a send that lost keystrokes fails here rather than thirty seconds later. await expect(bubble(bob, "starting a chat with you")).toBeVisible() // Her list picks the conversation up on its own @@ -656,8 +630,7 @@ test("US-128 keep messages from strangers out of your conversations", async ({se const space = relay("space") const inbox = relay("other") - // Bob she follows. Carol she knows only through the space they are both in, which is the - // weakest thing that still counts as knowing someone. + // Carol she knows only through the space they are both in, the weakest thing that counts as knowing. seedPerson(space, user.alice, "Alice Anchor") seedPerson(space, user.bob, "Bob Barnacle") seedPerson(space, user.carol, "Carol Cutter") @@ -667,9 +640,7 @@ test("US-128 keep messages from strangers out of your conversations", async ({se space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(), ) - // Dave and Eve write from a relay alice reads her messages on and has not joined, as US-108 - // does. Membership of it is what lets a wrap addressed to someone be stored there, and it - // never reaches a room list, so writing from it vouches for nobody. + // Membership of a relay never reaches a room list, so writing from it vouches for nobody. space.messagingRelayList(user.alice, [space.url, inbox.url]) inbox.member(user.alice) inbox.member(dave) @@ -684,8 +655,7 @@ test("US-128 keep messages from strangers out of your conversations", async ({se inbox.dm(user.alice, [eve], "I am, who is this?", at(3, HOUR)) }) - // Only her space indexes, as in US-108: the socket a client opens before it knows what a relay - // is for never identifies itself to it, and the inbox relay serves an anonymous reader nothing. + // The socket a client opens before it knows what a relay is for never identifies itself to it. const page = await as(users.alice, "/chat", { env: {VITE_INDEXER_RELAYS: scenario.space("space").url}, }) @@ -720,19 +690,13 @@ test("US-108 read messages from a relay you only use for messages", async ({seed seedPerson(space, user.bob, "Bob Barnacle") space.messagingRelayList(user.bob) - // Alice's inbox is a relay she has nothing else to do with: not a space she has joined, and - // not one of her read or write relays. Membership of it is only what lets a wrap addressed to - // her be stored there — it never reaches her room list — so her messaging relay list is the - // one thing that can vouch for her, and the relay serves her nothing until it does. + // Her messaging relay list is the one thing that vouches 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. + // This relay authorizes a wrap by the member its p tag names, so bob has to be one too. 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. + // Tagged verbatim rather than through setUrls, which normalizes on the way in. space.event(user.alice, () => space .kind(MessagingRelayList) @@ -744,9 +708,7 @@ 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)) }) - // 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. + // Only her space indexes, so the inbox relay is dialled for her messages and nothing else. const page = await as(users.alice, "/chat", { env: {VITE_INDEXER_RELAYS: scenario.space("space").url}, }) @@ -778,16 +740,13 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => { his = space.dm(user.bob, [user.alice], "the tide charts are up", at(2, HOUR)) hers = space.dm(user.alice, [user.bob], "thakns", at(1, HOUR)) - // The control on the relay having really forgotten. A room message comes off the same relay as - // the wraps and is the kind of thing a client reads back off the wire every time, so it is what - // a conversation that survives is being distinguished from. + // A room message comes off the same relay and is read back off the wire every time. space.message(user.bob, "general", "boat is in the water", at(2, HOUR)) }) const url = scenario.space("space").url - // A touch context, which is what puts Edit Message behind a named button. Editing is the only way - // the ui takes a direct message back, and the delete it publishes is the second half of the story. + // A touch context, which is what puts Edit Message behind a named button. const alice = await as(users.alice, roomPath(url, "general"), {context: {hasTouch: true}}) await expect(alice.locator(".room__item").filter({hasText: "boat is in the water"})).toBeVisible() @@ -809,8 +768,7 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => { await expect(bubble(alice, "thanks")).toBeVisible() await expect(alice.locator(".chat-bubble").filter({hasText: "thakns"})).toHaveCount(0) - // 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. + // A reaction travels gift-wrapped the way the messages do, so it is kept or lost with them. await openMessageMenu(alice, his.id) await alice.getByRole("button", {name: "React"}).click() @@ -821,8 +779,7 @@ test("US-109 keep a conversation you have already read", async ({seed, as}) => { await expect(message(alice, his.id)).toContainText("🎉") - // The edit, the delete that retracted the original and the reaction all reach disk in batches, so - // waiting for them there is waiting for the whole of what the reload is about to read back. + // The edit, the delete and the reaction all reach disk in batches, which is what the reload reads. await expect .poll(() => cachedContent(alice, users.alice.pubkey, DIRECT_MESSAGE)) .toEqual(expect.arrayContaining(["the tide charts are up", "thanks"])) diff --git a/e2e/specs/notifications.spec.ts b/e2e/specs/notifications.spec.ts index 239d8801..33e1d846 100644 --- a/e2e/specs/notifications.spec.ts +++ b/e2e/specs/notifications.spec.ts @@ -24,54 +24,39 @@ import { } from "../harness" import type {SeededEvent, SeededSpace, TestUser} from "../harness" -// The unread indicator, which every surface renders the same way: a small primary-colored dot in -// the corner of the thing it belongs to. RelaySummary's "you're a member" check is the same shape -// at h-5 w-5, so the size is part of what says which one this is. +// A small primary-colored dot in the corner. RelaySummary's member check is the same shape at h-5 w-5. const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary") -// 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. +// An icon is a css mask built from a data url, so a room only renders this 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 -// other absolutely-positioned dot in that button is the admin action-items one, which is opacity-0. +// The only other absolutely-positioned dot in that button is the admin action-items one, at opacity-0. const mutedSpaceBell = (header: Locator) => header.locator("div.opacity-50") -// PrimaryNavItemSpace carries the relay's name as a tooltip rather than as an accessible name — its -// icon is a masked svg with no alt text — and it has an onclick, so PrimaryNavItem renders it as a -// button rather than a link. The name comes from nip-11; until that document lands the tooltip is -// the relay's host instead, and each tenant's toml names it after itself, so both start the same. +// The name comes from nip-11; until that document lands the tooltip is the relay's host instead. const spaceNavItem = (page: Page, name: string) => page.locator(`.primary-nav [data-tip^="${name}"]`) -// The phone's bottom bar opens the space menu in a drawer; the desktop rail has no equivalent, -// since the menu is always on screen there. +// The desktop rail has no equivalent, since the menu is always on screen there. const spaceMenuNavItem = (page: Page) => page.getByRole("button", {name: "Open space menu"}) // The space menu's header, the one button in the secondary nav carrying the relay's address. const spaceMenu = (page: Page, url: string) => page.locator(".secondary-nav").getByRole("button", {name: pathPattern(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. +// The menu closes on the next mouseup, which a click with no gap between press and release reaches. const openSpaceMenu = (menu: Locator) => menu.click({delay: 200}) -// Every story here goes on to assert something that depends on the message having landed, so -// posting is only finished once it has rendered. +// Every story here depends on the message having landed, so posting is finished once it renders. const post = async (page: Page, content: string) => { await send(page, content) await expect(message(page, content)).toBeVisible() } -// RoomItem's hover actions are icons with no accessible names, in a fixed order: zap, emoji, reply, -// edit (only on your own recent message), menu. +// RoomItem's hover actions are icons with no accessible names, in a fixed order: zap, emoji, reply, edit, menu. const replyToMessage = (page: Page, text: string) => messageActions(page, text).nth(2).click() -// Chromium's own notifications are invisible to a test, and a tab playwright drives is never -// hidden, so both of the things the adapter reads are stubbed on the page. It takes the global at -// notify time, which is what lets this land after boot. +// Chromium's own notifications are invisible to a test, and a tab playwright drives is never hidden. const captureNotifications = async (page: Page) => { const notifications: {title: string; body: string}[] = [] @@ -101,17 +86,14 @@ const captureNotifications = async (page: Page) => { return notifications } -// The page bar names the room, so waiting for it is what says the composer below now belongs to -// the room that was just opened rather than to the one being torn down. +// The page bar names the room, so waiting for it says the composer belongs to the room just opened. const postTo = async (page: Page, room: string, content: string) => { await roomLink(page, room).click() await expect(pageBar(page)).toContainText(room) await post(page, content) } -// Outbox routing resolves everything about a person through their relay list: settings are -// published to the relays it names, and a gift wrap only reaches somebody who has said where their -// messages go. +// Settings are published to the relays a person's list names, and a gift wrap needs one too. const seedRelays = (space: SeededSpace, user: TestUser) => { space.relayList(user) space.messagingRelayList(user) @@ -126,8 +108,7 @@ test("US-103 see and clear unread indicators", async ({seed, as}) => { space.join(user.alice, "general", "random") space.join(user.bob, "general", "random") - // History bob wrote himself, so the space has something in it while nothing in it is unread - // for him — his own messages never raise an indicator. + // History bob wrote himself, since his own messages never raise an indicator. space.message(user.bob, "general", "anyone around?", at(2, HOUR)) }) @@ -145,15 +126,13 @@ test("US-103 see and clear unread indicators", async ({seed, as}) => { // Bob is sitting on the spaces page the whole time, so the dot arrives without a navigation await expect(unreadDot(navItem)).toBeVisible() - // Inside the space, the dot points at the room the message landed in. A space's room list only - // exists in its own menu, so this is the one indicator the rail can't show. + // A space's room list only exists in its own menu, so this is the one indicator the rail can't show. await navItem.click() const general = roomLink(bob, "General") const random = roomLink(bob, "Random") - // Both rooms are on screen first, so a room with no dot is a room raising none rather than a - // row that never rendered + // Both rooms are on screen first, so a room with no dot is one raising none rather than not rendered. await expect(random).toBeVisible() await expect(unreadDot(general)).toBeVisible() await expect(unreadDot(random)).toHaveCount(0) @@ -189,8 +168,7 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => { const space = scenario.space("space") - // Silencing a whole space is only offered once push notifications are on, so that is where alice - // has to start. + // Silencing a whole space is only offered once push notifications are on. const alice = await as(users.alice, "/settings/alerts", { context: {permissions: ["notifications"]}, }) @@ -209,8 +187,7 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => { await expect(alice).toHaveURL(pathPattern(roomPath(space.url, "general"))) - // 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. + // Mute is the stronger of the two settings there: it forces notifications off and hides badges. await openRoomDetail(alice) const roomMute = settingRow(alice, "Mute").getByRole("checkbox") @@ -261,15 +238,12 @@ test("US-104 mute a room or a whole space", async ({seed, as}) => { 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. + // Hiding unread badges is the room mute's job, so the dots the rooms are carrying stay up. await expect(mutedSpaceBell(menu)).toBeVisible() 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. + // Clicking the header while the menu is still on its way out toggles it straight back shut. const turnOn = alice.getByRole("button", {name: "Turn on notifications"}) await expect(turnOn).toHaveCount(0) @@ -351,8 +325,7 @@ test("US-116 read the home dashboard", async ({seed, as}) => { await expect(conversation).toContainText("General") await expect(unreadDot(conversation)).toBeVisible() - // A space's threads, events and classifieds are counted per space in Activity rather than listed - // as conversations, so the inbox stays a list of messages + // Threads, events and classifieds are counted per space in Activity rather than listed as conversations. const activity = page.getByRole("link").filter({hasText: "1 thread"}) await expect(page.getByRole("heading", {name: "Activity"})).toBeVisible() @@ -388,8 +361,7 @@ test("US-117 read the network feed on home", async ({seed, as}) => { space.join(user.alice, "general") space.join(user.bob, "general") - // The feed asks each follow's write relays for their notes, so a note is only reachable - // through a relay list naming one. + // The feed asks each follow's write relays for their notes, so a note needs a relay list naming one. seedRelays(space, user.alice) seedRelays(space, user.bob) @@ -427,8 +399,7 @@ test("US-117 read the network feed on home", async ({seed, as}) => { await expect(page.getByRole("heading", {name: "Network"})).toBeVisible() await expect(page.getByText(note)).toBeVisible() - // The feed is notes only: a reply is counted on the note it answers rather than drawn - // underneath it, and it never gets a card of its own. + // The feed is notes only: a reply is counted on the note it answers and gets no card of its own. await expect(page.getByRole("button", {name: "1 reply", exact: true})).toBeVisible() await expect(page.getByText(reply)).toHaveCount(0) @@ -457,18 +428,14 @@ test("US-117 read a follow who is in none of your spaces", async ({seed, as}) => space.room("general", {name: "General"}) space.join(user.alice, "general") - // Alice reads from her space and the indexer. Bob's relay is somewhere she writes and nowhere - // she reads, which is enough for her client to identify to it -- zooid answers no REQ without - // nip-42, and Flotilla only identifies to relays her own lists name -- while leaving it out of - // the read urls a feed's context is asked of. + // Bob's relay is somewhere she writes and nowhere she reads, which is enough to identify to it. indexer.relayList(user.alice, { read: [space.url, indexer.url], write: [space.url, indexer.url, outbox.url], }) indexer.follows(user.alice, [user.bob]) - // Bob is in none of her spaces and writes nowhere she reads, so his relay list is the only - // thing that can point the feed at his notes. + // Bob is in none of her spaces, so his relay list is the only thing that can point the feed at him. indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url]}) outbox.profile(user.bob, {name: "Bob Barker"}) @@ -491,8 +458,7 @@ test("US-117 read a follow who is in none of your spaces", async ({seed, as}) => await expect(network.getByText(note)).toBeVisible() await expect(network.getByText("Bob Barker")).toBeVisible() - // The reply is on bob's relay too, and alice reads from her space rather than from that relay, - // so a context asked of her own read relays comes back with nothing. + // Alice reads from her space rather than bob's relay, so a context asked of hers comes back empty. await expect(network.getByRole("button", {name: "1 reply", exact: true})).toBeVisible() // Her space never held any of it. @@ -558,8 +524,7 @@ test("US-117 read a network feed whose relays answer from different depths", asy }) test("US-117 read the network feed when one relay never answers", async ({seed, as}) => { - // Nothing serves this url, and nothing needs to: the fault is a relay that takes the socket and - // then says nothing, which is all the spec asks of it. + // Nothing serves this url: the fault is a relay that takes the socket and then says nothing. const stalled = "wss://stalled.test/" const note = "the drawbridge has been stuck open since noon" @@ -571,9 +536,7 @@ test("US-117 read the network feed when one relay never answers", async ({seed, space.room("general", {name: "General"}) space.join(user.alice, "general") - // The relays the feed will ask are somewhere she writes and nowhere she reads, which is what - // lets her client identify to them: zooid answers no REQ without nip-42, and Flotilla only - // identifies to relays her own lists name. + // Somewhere she writes and nowhere she reads, which is what lets her client identify to them. indexer.relayList(user.alice, { read: [space.url, indexer.url], write: [space.url, indexer.url, outbox.url, stalled], @@ -592,8 +555,7 @@ test("US-117 read the network feed when one relay never answers", async ({seed, .locator("section") .filter({has: page.getByRole("heading", {name: "Network"})}) - // A span releases the events it found once it is done waiting, so a span that waits on every - // relay it asked is a span one silent relay holds empty. + // A span releases what it found once it is done waiting, so one silent relay holds it empty. await expect(network.getByText(note)).toBeVisible({timeout: 20_000}) await expect(network.getByText("Bob Barker")).toBeVisible() }) @@ -618,14 +580,12 @@ test("US-106 share text into the app", async ({seed, as}) => { const space = scenario.space("space") - // A client only unwraps direct messages once its owner has opened chat, so a conversation the - // share dialog can offer is one alice has already seen. + // A client only unwraps direct messages once its owner has opened chat. const page = await as(users.alice, "/chat") await expect(page.getByText("are you around?").first()).toBeVisible() - // The text arrives in the query string rather than through a native share intent, which is the - // same thing src/routes/share reads either way. + // The text arrives in the query string rather than through a native share intent. await page.goto(`/share?text=${encodeURIComponent(shared)}`) const share = dialog(page, "Share") @@ -681,8 +641,7 @@ test("US-107 open a nostr link", async ({seed, as}) => { await expect(page.getByRole("heading", {name: "Event Details"})).toBeVisible() - // EventInfo renders the link on mount rather than during setup, so wait for it rather than - // copying an empty field + // EventInfo renders the link on mount rather than during setup. const eventLink = settingRow(page, "Event Link") await expect(eventLink.getByRole("textbox")).toHaveValue(/^nostr:nevent1/) @@ -731,8 +690,7 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as const space = scenario.space("space") const other = scenario.space("other") - // A phone has no room list on screen while a room is open, so the bottom bar is the only place - // activity elsewhere can surface + // A phone has no room list on screen while a room is open. const bob = await as(users.bob, roomPath(space.url, "general"), { context: {viewport: {width: 390, height: 844}, hasTouch: true}, }) @@ -757,16 +715,14 @@ test("US-110 see another space's unread activity from a phone", async ({seed, as await expect(message(bob, "the server is on fire")).toBeVisible() - // Reading the other space empties the bar, even though bob's own space still has an unread room - // — that one is the space's business, and its own indicators carry it + // Bob's own space still has an unread room, which is that space's own business. await bob.goto(spacePath(space.url)) await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible() await expect(unreadDot(menuButton)).toHaveCount(0) }) -// SpaceMenuNavItems offers a content type once the space has an event of that kind or something -// under it is unread, so this link appearing at all is what says the seeded content loaded. +// SpaceMenuNavItems offers a content type once the space has an event of that kind or an unread. const contentNavItem = (page: Page, name: string) => page.locator(".secondary-nav").getByRole("link", {name}) @@ -830,9 +786,7 @@ test("US-112 see which threads are unread", async ({seed, as}) => { await expect(his).toBeVisible() - // syncChecked marks the landed-on page read 300ms later and latestActivityByPath is throttled to - // a second, so a dot the list is about to clear stays up well past the click. Nothing on screen - // reports the tick — the nav dot goes down on the route change either way — so wait it out. + // syncChecked marks the page read 300ms later and latestActivityByPath is throttled to a second. await bob.waitForTimeout(1500) await expect(unreadDot(hers)).toBeVisible() @@ -870,9 +824,7 @@ test("US-113 see which threads are unread on a phone", async ({seed, as}) => { const space = scenario.space("space") - // ThreadBoard swaps its table for a list of links when the board is too narrow for the table, - // and the two branches render the thread separately, so a dot on one says nothing about the - // other. + // ThreadBoard swaps its table for a list of links when the board is narrow, and renders each apart. const bob = await as(users.bob, `${spacePath(space.url)}/threads`, { context: {viewport: {width: 390, height: 844}, hasTouch: true}, }) @@ -882,16 +834,14 @@ test("US-113 see which threads are unread on a phone", async ({seed, as}) => { await expect(his).toBeVisible() - // Same tick and throttle as US-112: a dot read before both have run is one the list may still be - // about to clear. + // Same tick and throttle as US-112: a dot read before both have run may still be about to clear. await bob.waitForTimeout(1500) await expect(unreadDot(hers)).toBeVisible() await expect(unreadDot(his)).toHaveCount(0) }) -// Classifieds stands in for the five boards whose items are cards rather than rows — they all -// render the same UnreadDot off the same content path, and only the corner it sits in differs. +// Classifieds stands in for the five boards whose items are cards, which differ only in the corner. test("US-114 see which listings are unread", async ({seed, as}) => { const scenario = await seed(({relay, user, at}) => { const space = relay("space") @@ -953,8 +903,7 @@ test("US-123 find a section whose newest item is older than the sync window", as space.join(user.alice, "general") space.join(user.bob, "general") - // Older than the month the space sync asks for, so the nav can only know about it by asking - // the relay what the space holds rather than by reading what has loaded. + // Older than the month the space sync asks for, so the nav can only know by asking the relay. seedPoll(space, user.alice, "which sextant should we buy", at(60, DAY)) }) @@ -981,8 +930,7 @@ test("US-124 reach a badge raised by content the space doesn't have", async ({se space.join(user.bob, "general") other.join(user.bob, "general") - // A comment on a poll nothing holds — the relay kept the comment and dropped its subject. - // It counts toward the space either way, so the section it belongs to has to be reachable. + // A comment on a poll nothing holds: the relay kept the comment and dropped its subject. space.event( user.alice, () => @@ -1016,8 +964,7 @@ test("US-124 reach a badge raised by content the space doesn't have", async ({se await expect(bob.getByText("No polls found.")).toBeVisible() - // Reading it is the end of it: with nothing unread and no poll to list, the space stops - // offering the section at all + // With nothing unread and no poll to list, the space stops offering the section at all. await roomLink(bob, "General").click() await expect(pollsNav).toHaveCount(0) @@ -1037,8 +984,7 @@ test("US-120 read what a notification says", async ({seed, as}) => { const space = scenario.space("space") - // Push notifications are off until they are asked for, and the tab has to be in the background - // before one is raised at all. + // The tab has to be in the background before a push notification is raised at all. const alice = await as(users.alice, "/settings/alerts", { context: {permissions: ["notifications"]}, }) @@ -1064,9 +1010,7 @@ test("US-120 read what a notification says", async ({seed, as}) => { await expect(message(bob, "sunday, the notice is at")).toBeVisible() - // A reply prepends the message it answers, so its first line is an entity and says nothing about - // the reply. The preview is the words bob wrote, with the url named by its host rather than - // spelled out, and the quote of alice's message tags her, so it reads as a mention. + // A reply prepends the message it answers, so its first line is an entity rather than the reply. await expect .poll(() => notifications.at(-1)) .toEqual({ diff --git a/e2e/specs/onboarding.spec.ts b/e2e/specs/onboarding.spec.ts index 0ff7df88..33491844 100644 --- a/e2e/specs/onboarding.spec.ts +++ b/e2e/specs/onboarding.spec.ts @@ -11,8 +11,7 @@ const gate = (page: Page) => page.getByRole("heading", {name: "Welcome to Flotil const nsecFor = (user: TestUser) => nsecEncode(hexToBytes(user.secret)) -// Landing → "Log in" → "Log in with Key" → paste → submit, which is the only way to watch a -// session come into existence: a session injected by `as()` is re-applied on every navigation. +// The only way to watch a session come into existence: one `as()` injects is re-applied on every navigation. 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() @@ -21,14 +20,11 @@ const logInWithKey = async (page: Page, key: string) => { 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 -// icon is a masked svg with no alt text — so it is addressed by where it goes. +// The nav's settings link carries its label as a tooltip, so it is addressed by where it goes. const openSettings = (page: Page) => page.locator('.primary-nav a[href="/settings/profile"]').click() -// The profile page's Public Key field. A nip01 login also renders a masked Private Key input right -// below it, so `getByRole("textbox")` on this page is two elements — the npub is the readonly one -// that isn't a password. +// A nip01 login renders a masked Private Key too, so the npub is the readonly non-password one. const npubField = (page: Page) => page.locator('input[readonly]:not([type="password"])') test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => { @@ -38,8 +34,7 @@ test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => { const {url} = scenario.space("space") - // A room url rather than the root: the gate stands in front of the whole app, so where the - // visitor landed makes no difference. + // A room url rather than the root: the gate stands in front of the whole app. const page = await visit(roomPath(url, "general")) await expect(gate(page)).toBeVisible() @@ -111,9 +106,7 @@ test("US-002 sign up by generating a new key", async ({seed, visit}) => { await expect(gate(page)).toHaveCount(0) await expect(page.getByText("Back Up Your Key")).toBeVisible() - // A space's nav item is a button rather than a link — PrimaryNavItemSpace passes an onclick, and - // PrimaryNavItem renders a Button whenever it has one — so it is addressed by the tooltip it - // carries, which is the relay's nip-11 name, and clicking it is what says which space it is. + // A space's nav item is a button carrying the relay's nip-11 name as its tooltip. const spaceItem = page.locator('.primary-nav [data-tip="space"]') await expect(spaceItem).toBeVisible() @@ -238,8 +231,7 @@ test("US-003 log in with an existing private key", async ({seed, visit}) => { await openSettings(withNcryptsec) await expect(npubField(withNcryptsec)).toHaveValue(aliceNpub) - // A second browser context with its own storage: bob's session is his own, not a second view - // of alice's. + // A second browser context with its own storage: bob's session is his own. const asBob = await visit() await logInWithKey(asBob, nsecFor(users.bob)) @@ -287,8 +279,7 @@ test("US-005 log in with a remote signer", async ({seed, visit}) => { const bunker = page.getByPlaceholder("bunker://") const next = page.getByRole("button", {name: "Next"}) - // The relay named here belongs to no scenario, so a connection attempt would be recorded as a - // leak and fail this test rather than passing unnoticed. + // The relay named here belongs to no scenario, so a connection attempt is recorded as a leak. await bunker.fill("bunker://not-a-signer-pubkey?relay=wss://nowhere.test/") await next.click() @@ -459,15 +450,12 @@ test("US-008 delete your nostr account", async ({seed, as, visit}) => { const bob = await as(users.bob, `/people/${npubEncode(users.alice.pubkey)}`) - // zooid honours the kind-62 right-to-vanish, so alice's profile — including the "[deleted]" name - // the app blanks it to first — is gone rather than renamed. Bob sees the npub fallback the app - // shows for anyone with no profile: displayPubkey, which is npub.slice(0,8)+"…"+npub.slice(-5). + // zooid honours the kind-62 right-to-vanish, so the profile is gone rather than renamed. const aliceNpub = npubEncode(users.alice.pubkey) const fallback = aliceNpub.slice(0, 8) + "…" + aliceNpub.slice(-5) await expect(bob.getByRole("heading", {name: fallback})).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. + // Her relay list went with the account, so the feed has nowhere to ask and keeps looking. await expect(bob.locator(".card.card-interactive")).toHaveCount(0) }) diff --git a/e2e/specs/people.spec.ts b/e2e/specs/people.spec.ts index ac6ede0e..f3e72c26 100644 --- a/e2e/specs/people.spec.ts +++ b/e2e/specs/people.spec.ts @@ -23,14 +23,12 @@ import type {TestUser} from "../harness" // A handle to a seeded event, which only reads once seed() has drained its queue. type Seeded = {readonly id: string; readonly event: SignedEvent} -// The profile page keeps its Reputation and Spaces panels in a sidebar that only exists above -// tailwind's xl breakpoint, and the default 1280 viewport sits exactly on it. +// The Reputation and Spaces panels only exist above tailwind's xl breakpoint, where 1280 sits. const DESKTOP = {viewport: {width: 1440, height: 900}} const CLIPBOARD = {...DESKTOP, permissions: ["clipboard-read", "clipboard-write"]} -// uploadFile appends the extension when the descriptor's url carries none, which the mock's never -// does. +// uploadFile appends the extension when the descriptor's url carries none, as the mock's never does. const uploadedUrl = (body: Buffer, extension: string) => `${DEFAULT_BLOSSOM_ORIGIN}/${createHash("sha256").update(body).digest("hex")}.${extension}` @@ -41,16 +39,13 @@ const shortNpub = (user: TestUser) => { return npub.slice(0, 8) + "…" + npub.slice(-5) } -// The page's own region, so an assertion about an avatar isn't satisfied by the copy of it the -// nav renders. +// The page's own region, so an assertion about an avatar isn't satisfied by the copy the nav renders. const pageContent = (page: Page) => page.locator(".page__content") -// ProfileTrust and ProfileSharedSpaces are rendered twice — stacked for narrow viewports, and in -// the sidebar — so anything said about them is scoped to the copy that is actually on screen. +// ProfileTrust and ProfileSharedSpaces are rendered twice, stacked for narrow viewports and here. const sidebar = (page: Page) => page.locator("aside") -// A space path redirects to the space's entry room as the page mounts, and modal.ts closes every -// open modal on navigation, so a dialog opened before the redirect lands is thrown away with it. +// A space path redirects as the page mounts, and modal.ts closes every open modal on navigation. const enteredSpace = (page: Page) => expect(page).toHaveURL(new RegExp("/spaces/[^/]+/.")) // Search is a dialog the nav opens rather than a page of its own. @@ -71,8 +66,7 @@ const profileMenu = (page: Page) => page.locator("button.button-circle.button-gh const viewProfile = (card: Locator) => card.getByRole("link", {name: "View Profile"}).first() test("US-074 find a person", async ({seed, as}) => { - // Sixty of them, so that the ten the dialog lists are visibly the best matches rather than - // everyone who matched. + // Sixty of them, so the ten the dialog lists are visibly the best matches rather than everyone. const searchers = Array.from({length: 60}, (_, i) => makeTestUser(`searcher-${i}`)) const searcherName = (i: number) => `Searcher ${String(i).padStart(2, "0")}` const searcherAvatar = (i: number) => `https://images.test/searcher-${i}.png` @@ -90,8 +84,7 @@ test("US-074 find a person", async ({seed, as}) => { space.join(user.alice, "general") space.join(user.bob, "general") - // Bob is the one person whose name shares no token with the others, so narrowing the term is - // visibly a filter rather than a reordering. + // Bob is the one person whose name shares no token with the others. space.profile(user.bob, { name: "Bob Barnacle", about: "Dockside cook and keeper of the ship's cat.", @@ -113,8 +106,7 @@ test("US-074 find a person", async ({seed, as}) => { await openSearch(page) await term.fill("Searcher") - // Which of them ranks first is fuse's business, so a result is described by what every - // result carries rather than by which one it turned out to be. + // Which of them ranks first is fuse's business. const first = cards.first() await expect(first).toBeVisible() @@ -150,8 +142,7 @@ test("US-075 view someone's profile", async ({seed, as}) => { space.join(user.alice, "general") space.join(user.bob, "general") - // A second space alice does not belong to, so "Member" is a claim about the overlap rather - // than about every space bob is in. + // A second space alice does not belong to, so "Member" is a claim about the overlap. other.room("lounge", {name: "Lounge"}) other.join(user.bob, "lounge") @@ -183,8 +174,7 @@ test("US-075 view someone's profile", async ({seed, as}) => { // Carol belongs to no space at all, which is what the panel's empty state is about. space.member(user.carol) space.profile(user.carol, {name: "Carol Cutter"}) - // An expired status is one its author asked relays to stop serving, so it is not what she is - // up to any more. + // An expired status is one its author asked relays to stop serving. space.event( user.carol, makeEvent(STATUS, { @@ -294,15 +284,13 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => { space.relayList(user.bob) space.relayList(user.carol) - // Carol already follows bob, so alice following carol is the one thing that has to happen - // through the ui for his standing to move. + // Carol already follows bob, so alice following carol is the one thing that goes through the ui. space.event(user.carol, () => space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(), ) }) - // Every hop here is a link click rather than a navigation, so the follow alice publishes stays - // in the client that published it. + // Every hop here is a link click rather than a navigation, so the follow stays in one client. const page = await as(users.alice, spacePath(scenario.space("space").url), {context: DESKTOP}) const term = searchTerm(page) const cards = searchResults(page) @@ -326,8 +314,7 @@ test("US-077 see web-of-trust standing build up", async ({seed, as}) => { await viewProfile(bobCard).click() await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`)) - // Word-bounded: a plain "0 / 100" is also a substring of "10 / 100" and "20 / 100", which are - // exactly the readings this is supposed to rule out. + // Word-bounded: a plain "0 / 100" is also a substring of "10 / 100" and "20 / 100". await expect(reputation()).toContainText(/\b0 \/ 100\b/) await expect(reputation()).toContainText("This user is not well known in your network.") @@ -465,8 +452,7 @@ test("US-079 read a person's notes", async ({seed, as}) => { makeEvent(NOTE, {content: "Anyone seen the tide charts?", created_at: at(4, HOUR)}), ) - // Older than the feed's first window, so the pin is fetched by id while the feed only - // reaches it by paging — the order in which one note arrives down both routes. + // Older than the feed's first window, so the pin is fetched by id while the feed only pages to it. pinned = space.event( user.alice, makeEvent(NOTE, {content: "PINNED how to read a tide chart", created_at: at(2, MONTH)}), @@ -514,9 +500,7 @@ test("US-079 read a person's notes", async ({seed, as}) => { await expect(newest).toBeVisible() await expect(newest.getByText("Alice Anderson")).toBeVisible() await expect(newest.locator(`img[src="${avatar}"]`)).toBeVisible() - // The story asks for a relative timestamp; the app renders formatTimestamp — a short date plus a - // clock time — the same way every other content item does (thread items, chat items), so that - // shared convention is what a note's stamp reads as here. + // The app renders formatTimestamp, a short date plus a clock time, as every content item does. await expect(newest).toContainText(/\d{1,2}\/\d{1,2}\/\d{2,4}/) await expect(list.filter({hasText: "REPLY"})).toHaveCount(0) @@ -527,13 +511,10 @@ test("US-079 read a person's notes", async ({seed, as}) => { await expect(list.nth(1)).toContainText("NEWEST") await expect(list.nth(2)).toContainText("MIDDLE") - // Nothing is scrolled here: the feed keeps widening its window until the page is full, which is - // what "loads older notes automatically" means. + // Nothing is scrolled here: the feed keeps widening its window until the page is full. await expect(list.filter({hasText: "OLDEST"})).toBeVisible() - // A note alice publishes while bob is looking. Flotilla has no composer for a kind-1 note, so - // it goes out through the app's own primitives in her signed-in session, over her socket to the - // space — which is the half of this the story is about. + // Flotilla has no composer for a kind-1 note, so it goes out through the app's own primitives. const alice = await as(users.alice, spacePath(url), {context: DESKTOP}) await alice.evaluate( @@ -556,8 +537,7 @@ test("US-079 read a person's notes", async ({seed, as}) => { [url, "LIVE straight off the deck"] as const, ) - // The profile feed fetches on load rather than subscribing live, so bob sees it the next time he - // opens the page — where it lands above every unpinned note, below the pin. + // The profile feed fetches on load rather than subscribing live. await page.reload() await expect(list.filter({hasText: "LIVE"})).toBeVisible() @@ -594,10 +574,7 @@ test("US-080 preview a profile from anywhere", async ({seed, as}) => { const {url} = scenario.space("space") const page = await as(users.alice, `${spacePath(url)}/directory`, {context: DESKTOP}) - // SpaceMember covers its whole card with one button, whose aria-label is interpolated from - // `$profiles.display(pubkey).get()` — a plain call, so the label keeps whatever the name was at - // first render, which is the npub the card falls back to before the profile has loaded. The - // name it *displays* comes from a store and does update, so the card is found by that. + // The card's aria-label keeps the npub it fell back to at first render, so it is found by the name. const preview = page .locator(".card.card-interactive") .filter({hasText: "Bob Barnacle"}) @@ -669,9 +646,7 @@ test("US-081 inspect and share a profile", async ({seed, as}) => { 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 - // than about anything else in the dialog that happens to carry four digits. + // A FieldInline puts its value in the div right after its label. const createdAt = info .locator("label") .filter({hasText: "Created At"}) @@ -701,8 +676,7 @@ test("US-081 inspect and share a profile", async ({seed, as}) => { await profileMenu(page).click() await page.getByRole("button", {name: "Share"}).click() - // 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. + // The menu that opened it is still mounted behind the dialog, and its items run together. await expect(page.getByText("Share Profile", {exact: true})).toBeVisible() const share = topDialog(page) @@ -748,9 +722,7 @@ test("US-082 mute an account", async ({seed, as}) => { const badge = page.locator(".badge").filter({hasText: "Bob Barnacle"}) const save = page.getByRole("button", {name: "Save Changes"}) - // Typed rather than filled, and slower than the search's own debounce: the suggestion list is - // rebuilt per keystroke, and the profile it is searching for only arrives from the relay once a - // pause in the typing has let the query go out. + // Typed slower than the search's own debounce, since the profile only arrives after a pause. await page .getByPlaceholder("Search for profiles...") .pressSequentially("Bob Barnacle", {delay: 700}) @@ -767,8 +739,7 @@ test("US-082 mute an account", async ({seed, as}) => { await expect(page.getByRole("alert")).toContainText("Your settings have been saved!") - // Through the nav rather than a fresh navigation, so what is on screen is what the client that - // just published the mute believes. + // Through the nav rather than a fresh navigation, so this is what the publishing client believes. const openBobsProfile = async () => { await openSearch(page) await searchTerm(page).fill("Barnacle") diff --git a/e2e/specs/rooms.spec.ts b/e2e/specs/rooms.spec.ts index 44bcc985..9aedea77 100644 --- a/e2e/specs/rooms.spec.ts +++ b/e2e/specs/rooms.spec.ts @@ -30,9 +30,7 @@ import type {SeededEvent, SeededSpace, TestUser} from "../harness" // The room's way back to the live end, up only while the bottom of the container isn't it. const jumpToNewest = (page: Page) => page.getByRole("button", {name: "Jump to newest"}) -// Each of these menus hides itself once the pointer leaves it, which is how one is dismissed -// without clicking an item. The popper sits against the right hand end of the message, so the top -// left corner of the viewport is outside it. +// Each of these menus hides itself once the pointer leaves it, and the top left corner is outside. const dismissMenu = (page: Page) => page.mouse.move(0, 0) const roomDetail = (page: Page) => page.getByRole("dialog", {name: "Room details"}) @@ -40,8 +38,7 @@ const roomDetail = (page: Page) => page.getByRole("dialog", {name: "Room details const openRoomDetailMenu = (page: Page) => roomDetail(page).getByRole("button", {name: "Room options"}).click() -// The space menu's sections are flat siblings — a header, then the rooms under it — so which -// section a room is in is a question about document order rather than nesting. +// The space menu's sections are flat siblings, so a room's section is a question of document order. const roomSection = (page: Page, name: string) => page.locator(".space-menu__scroll").evaluate((menu, roomName) => { let section: string | undefined @@ -63,8 +60,7 @@ const field = (form: Locator, label: string) => .locator("xpath=following-sibling::div") .locator("input[type=text]") -// RoomForm's permission toggles are a bare checkbox beside their own text rather than a labelled -// control. +// RoomForm's permission toggles are a bare checkbox beside their own text. const permission = (form: Locator, label: string) => form.getByText(label).locator("xpath=preceding-sibling::input") @@ -73,8 +69,7 @@ const reactionPill = (page: Page, text: string) => const react = (page: Page, opener: Locator) => pickEmoji(page, opener, "party popper") -// Outbox routing resolves everything about a person through their relay list, and a gift wrap only -// reaches somebody who has said where their messages go. +// Outbox routing resolves a person through their relay list, and a gift wrap needs one too. const seedChatter = (space: SeededSpace, user: TestUser) => { space.relayList(user) space.messagingRelayList(user) @@ -94,8 +89,7 @@ test("US-018 send and receive a room message in real time", async ({seed, as}) = const {url} = scenario.space("space") const path = roomPath(url, "general") - // Two browser contexts, two identities, one relay: bob's page is already listening when alice - // sends, so the message reaches him over the wire. + // Two browser contexts, two identities, one relay: bob's page is already listening when alice sends. const alice = await as(users.alice, path) const bob = await as(users.bob, path) @@ -131,9 +125,7 @@ test("US-018 send and receive a room message in real time", async ({seed, as}) = await expect(message(bob, "second line")).toContainText("first line") }) -// Two people typing in the same second used to leave every client with a different transcript, -// since the timestamp alone gave the merge nothing to break the tie on and each client saw its own -// message arrive first. Ordering falls back to the event id, which is the same everywhere. +// Two messages sharing a second are ordered by event id, which is the same on every client. test("US-118 messages sent in the same second are in one order for everyone", async ({ seed, as, @@ -167,8 +159,7 @@ test("US-118 messages sent in the same second are in one order for everyone", as items.map(item => item.getAttribute("data-event")), ) - // The room reads newest first in the dom, so the ids run the other way from the order the feed - // holds them in + // The room reads newest first in the dom, so the ids run the other way from the feed. expect(rendered.reverse()).toEqual(tied.map(({id}) => id).sort()) }) @@ -199,8 +190,7 @@ 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 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. + // The membership events a client listens for live are the ones naming itself. await alice.reload() const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"}) @@ -342,8 +332,7 @@ test("US-121 land somewhere after deleting the room you are in", async ({seed, a await admin.getByRole("button", {name: "Delete Room"}).click() await admin.getByRole("button", {name: "Confirm"}).click() - // The space root renders nothing on a wide screen; it hands off to whichever page of the space - // was open last, and the room that was just deleted is the page it remembers. + // The space root renders nothing on a wide screen, handing off to the page of the space open last. await expect(admin).toHaveURL(pathPattern(spacePath(url) + "/")) await expect(roomLink(admin, "General")).toBeVisible() @@ -359,8 +348,7 @@ test("US-021 request access to a private room and get approved", async ({seed, a const space = relay("space") space.room("general", {name: "General"}) - // `private` is what hides the room's history from non-members; `closed` is what keeps a join - // request from admitting her on its own, so an admin has to act on it. + // `private` hides the room's history from non-members; `closed` makes a join a request. space.room("wardroom", {name: "Wardroom", private: true, closed: true}) space.join(user.admin, "general", "wardroom") space.join(user.alice, "general", "wardroom") @@ -381,8 +369,7 @@ 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) - // 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. + // A private room offers to join rather than to ask, and a closed one turns that into a request. await carol.getByRole("button", {name: "Join Room"}).click() await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible() @@ -399,8 +386,7 @@ 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. + // The history the relay refused her is fetched when the room is next read. await carol.reload() await expect(carol.getByText("the charts are in the locker")).toBeVisible() @@ -422,8 +408,7 @@ test("US-022 bring people into a room", async ({seed, as}) => { space.join(user.admin, "general") space.join(user.bob, "general") space.profile(user.bob, {name: "Bob Barnacle"}) - // A space member who isn't in the room yet. Her message is what loads her profile into the - // client that goes looking for her. + // A space member who isn't in the room yet, whose message loads her profile into the client. space.join(dora) space.profile(dora, {name: "Dora Deckhand"}) space.message(dora, "general", "passing through", at(3, HOUR)) @@ -449,8 +434,7 @@ test("US-022 bring people into a room", async ({seed, as}) => { await expect(admin.getByText("Copied to clipboard!")).toBeVisible() - // The link is opened as a path rather than as an absolute url, which would leave the test's own - // dev server for the platform's. + // A path rather than an absolute url, which would leave the test's own dev server for the platform's. const link = new URL(await invite.inputValue()) const carol = await as(users.carol, `/join${link.search}`) @@ -478,8 +462,7 @@ test("US-022 bring people into a room", async ({seed, as}) => { await expect(admin.getByText("Members have successfully been added!")).toBeVisible() await expect(members.getByText("Dora Deckhand")).toBeVisible() - // Somebody who isn't in the space yet has to be let into it first. A pubkey pasted into the - // search field selects that person outright, which is how erik is reachable without a profile. + // A pubkey pasted into the search field selects that person outright, with no profile needed. await admin.getByRole("button", {name: "Add members"}).click() await admin.getByPlaceholder("Search for profiles...").fill(erik.pubkey) await admin.getByRole("button", {name: "Save changes"}).click() @@ -576,9 +559,7 @@ test("US-024 edit or delete a message you sent", async ({seed, as}) => { await expect(message(bob, "we sail at dwan")).toBeVisible() - // Her edit republishes with her original timestamp, and two messages sharing a second are - // ordered by event id (US-118) -- which the edit changes. His reply waits her second out, so - // the order asserted below is about the timestamp rather than a coin flip on the new id. + // Her edit republishes with her original timestamp, and it changes the event id US-118 ties on. const sent = now() while (now() === sent) { @@ -717,17 +698,14 @@ test("US-025 react to a message", async ({seed, as}) => { 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. + // 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() - // 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". + // Exact, because the room behind the dialog names him too, as "@Bob Barnacle". await expect(phone.getByRole("button", {name: "Bob Barnacle", exact: true})).toBeVisible() }) @@ -829,9 +807,7 @@ 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. 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. + // This room is three messages long, so the window runs to the present and the newest loads too. await page.goto(`${path}?at=${older.event.created_at}`) await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport() @@ -839,11 +815,7 @@ test("US-027 find a past message and jump to it", async ({seed, as}) => { await expect(jumpToNewest(page)).toHaveCount(0) }) -// A push notification links to the message it announced, which is near the newest end of the room, -// so the jump lands at the bottom with nowhere left to scroll down to. Anything published since — -// another message, a membership event — is newer than the one linked to, so "is this the last -// event in the room" is the wrong question to hang the button on; whether the loaded window has -// caught up to the present is the right one. +// A push notification links near the newest end, so the jump lands at the bottom with nothing below. test("US-027a a permalink near the newest end lands at the bottom", async ({seed, as}) => { let recent!: SeededEvent @@ -980,19 +952,15 @@ test("US-119 have a message read out loud", async ({seed, as}) => { await expect(alice.getByText("a message from Bob Barnacle")).toBeVisible() - // The quote, the mention and the url are each named rather than spelled out, since none of them - // is intelligible read a character at a time. + // The quote, the mention and the url are named rather than spelled out a character at a time. expect(spoken).toEqual([ "another message\n\nheads up Alice Anchor, the notice is at a link to harbor.example", ]) - // The app decodes what it is answered and rebuilds the container around the samples, so the - // duration is only right if that round trip kept every one of them. + // The app decodes what it is answered and rebuilds the container around the samples. await expect(alice.getByText("/ 0:10")).toBeVisible() - // The clip carries autoplay and the button follows the audio element's own play event, so it - // reads "Play message" until that fires. Waiting for it is what keeps the click below a pause: - // reading the label instead races the autoplay, and loses whenever playback starts in between. + // The clip carries autoplay and the button follows the audio element's own play event. const playPause = alice.getByRole("button", {name: /^(Play|Pause) message$/}) await expect(playPause).toHaveAttribute("aria-label", "Pause message") @@ -1013,11 +981,9 @@ test("US-119 have a message read out loud", async ({seed, as}) => { }) test("US-115 connect a wallet without losing the zap you were composing", async ({seed, as}) => { - // The lightning address on bob's profile, and the lnurl endpoint it resolves to. Zapping only gets - // as far as a dialog once dufflepud answers with a zapper for that endpoint. + // Zapping only gets as far as a dialog once dufflepud answers with a zapper for that endpoint. const lud16 = "bob@zap.test" - // A zapper's receipts are signed by the recipient's lightning provider, so it is an identity of - // its own even where, as here, nothing is ever paid. + // A zapper's receipts are signed by the recipient's lightning provider, so it is an identity of its own. const provider = makeTestUser("zapper") const scenario = await seed(({relay, user}) => { @@ -1037,8 +1003,7 @@ test("US-115 connect a wallet without losing the zap you were composing", async const path = roomPath(url, "general") const page = await as(users.alice, path, {webln: {node: {alias: "Test Node"}}}) - // Registered after the page was opened, so it answers ahead of the empty dufflepud `as()` - // installs, and before the navigation below, since a zapper is looked up once per page load. + // Registered after the page opened, so it answers ahead of the empty dufflepud `as()` installs. await mockDufflepud(page.context(), { zappers: [ { @@ -1070,8 +1035,7 @@ test("US-115 connect a wallet without losing the zap you were composing", async await expect(page.getByRole("alert")).toContainText("Wallet successfully connected!") await expect(connect).toHaveCount(0) - // The zap dialog was underneath rather than replaced, so it answers to the wallet she now has - // instead of still asking for one, and the amount she had typed survived the detour. + // The zap dialog was underneath rather than replaced, so the amount she had typed survived. await expect(zap.getByRole("button", {name: "Connect a lightning wallet"})).toHaveCount(0) await expect(zap.getByRole("button", {name: "Send Zap"})).toBeVisible() await expect(amount).toHaveValue("210") diff --git a/e2e/specs/routing.spec.ts b/e2e/specs/routing.spec.ts index f6d56637..20c25233 100644 --- a/e2e/specs/routing.spec.ts +++ b/e2e/specs/routing.spec.ts @@ -16,8 +16,7 @@ import { users, } from "../harness" -// Every page in a space renders one PageContent, so the number added to the document is the number -// of times the page was built. +// Every page in a space renders one PageContent, so the count is how often the page was built. const watchPageBuilds = (page: Page) => page.evaluate(() => { const selector = "[data-component='PageContent']" @@ -70,10 +69,7 @@ test("keeps two spaces' contents on their own relays", async ({seed, as}) => { await expect(page.getByText("only in space")).toBeVisible() await expect(page.getByRole("link", {name: "Space Lounge"})).toBeVisible() - // Alice belongs to both spaces, so the client is talking to the other relay at the same time — - // its room and its messages just don't belong in this one. It syncs independently, so wait until - // both have actually reached this page: `toHaveCount(0)` is equally satisfied by an element that - // has not loaded yet, which would pass against a client that does conflate them. + // toHaveCount(0) is equally satisfied by an element that has not loaded yet, so wait for both. await expect.poll(() => deliveredByOther(event => event.content === "only in other")).toBe(true) await expect .poll(() => deliveredByOther(event => event.tags.some(spec(["name", "Other Lounge"])))) @@ -116,8 +112,7 @@ test("goes back to the room you left when you switch spaces", async ({seed, as}) await page.getByRole("link", {name: "Other Lounge"}).click() await expect(page).toHaveURL(new RegExp(`${roomPath(other.url, "lounge")}$`)) - // Two entries back: the other space's landing page, then the room this started on. The switch - // used to replace that room's entry rather than push one, so the second step overshot it. + // Two entries back: the other space's landing page, then the room this started on. await page.goBack() await page.goBack() @@ -140,8 +135,7 @@ test("does not stack a history entry for the space you are already in", async ({ await page.getByRole("link", {name: "Space Garden"}).click() await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`)) - // The space's entry page is the room you are on, so this navigates nowhere and should replace - // rather than push. One step back is the room this started on, not the one it never left. + // The space's entry page is the room you are on, so this navigates nowhere and replaces. await page.locator('.primary-nav [data-tip^="space"]').click() await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`)) @@ -179,10 +173,7 @@ test("takes over the space menu's history entry when you navigate out of it", as await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`)) await expect(drawer).toHaveCount(0) - // The menu gave its entry back and the room pushed its own, so one step back is the room the - // menu was opened over. Stacked instead, this lands on the menu again. The room's content is - // asserted as well as the url, since a back that only rewrites the url passes every assertion - // about the address. + // The content is asserted as well as the url, since a back that only rewrites the url would pass. await page.goBack() await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`)) @@ -216,8 +207,7 @@ test("switches spaces inside the space menu, and out of one it has a page for", await expect(drawer.getByRole("link", {name: "Space Lounge"})).toBeVisible() - // The rail is in the menu on a phone, so a space is picked with the menu still open and the room - // is picked after it. Navigating out from under the menu would close it on the first tap. + // The rail is in the menu on a phone, and navigating out from under it would close it. await drawer.locator('.primary-nav [data-tip^="other"]').click() await expect(page).toHaveURL(new RegExp(spacePath(other.url))) @@ -228,8 +218,7 @@ test("switches spaces inside the space menu, and out of one it has a page for", await expect(page).toHaveURL(new RegExp(`${roomPath(other.url, "garden")}$`)) await expect(drawer).toHaveCount(0) - // The space she started in has a page behind it now, so picking it needs no second tap and the - // menu has nothing left to ask. + // The space she started in has a page behind it now, so picking it needs no second tap. await page.getByRole("button", {name: "Open space menu"}).click() await drawer.locator('.primary-nav [data-tip^="space"]').click() @@ -247,9 +236,7 @@ test("enters a space on its details page whatever its relay advertises", async ( const space = scenario.space("space") - // A relay whose document claims no nip-29. The entry path used to read that as "open the chat - // page", which a cold load could never get right: the document arrives after the first - // navigation, so a space opened on chat and corrected itself to about a moment later. + // A relay whose document claims no nip-29. The document arrives after the first navigation. const relayInfo = {[space.url]: {supported_nips: [1, 11, 42]}} const page = await as(users.alice, spacePath(space.url), {relayInfo}) @@ -301,13 +288,11 @@ test("builds a page once when it opens and again when its params change", async page.locator("article header").getByRole("heading", {name: "Tending the Garden"}), ).toBeVisible() - // The rebuild this guards against landed 20ms after the first build, which is before the article - // itself is on screen on a slower run. + // The rebuild this guards against landed 20ms after the first build. await page.waitForTimeout(250) await expectPageBuilds(page, 1) - // Another article is the same route with different params, which SvelteKit answers by keeping the - // page it has. The page reads the address it renders once, so this one does have to be rebuilt. + // Another article is the same route with different params, which SvelteKit answers by keeping the page. await page.getByRole("link", {name: "Repotting in Winter"}).click() await expect( @@ -333,8 +318,7 @@ test("goes back to the room a profile modal was opened over", async ({seed, as}) await expect(message(page, "anyone seen the anchor")).toBeVisible() - // The whole message is a button too, and its accessible name carries the author's, so the - // name on its own is the one that opens the profile. + // The whole message is a button too, and its accessible name carries the author's. await message(page, "anyone seen the anchor") .getByRole("button", {name: "Bob Barnacle", exact: true}) .click() @@ -343,9 +327,7 @@ test("goes back to the room a profile modal was opened over", async ({seed, as}) await page.getByRole("button", {name: "View Full Profile"}).click() await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`)) - // The modal gave its entry back before the profile page pushed its own, so one step back is the - // room. Replacing that entry instead left SvelteKit with the navigation index the room already - // had, and it answered the back by writing the url without building the page again. + // The modal gave its entry back before the profile page pushed its own, so one step back is the room. await page.goBack() await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`)) diff --git a/e2e/specs/settings.spec.ts b/e2e/specs/settings.spec.ts index e5f650f4..9bf1b9ec 100644 --- a/e2e/specs/settings.spec.ts +++ b/e2e/specs/settings.spec.ts @@ -5,20 +5,15 @@ import {dialog, expect, roomPath, settingToggle, test, toast, topDialog, users} // A handle to a seeded event, which only reads once seed() has drained its queue. type Seeded = {readonly id: string} -// The relay picker, which has no heading of its own — and is pushed over the list modal rather -// than alongside it, so it is the only dialog in the dom while it is open. +// The relay picker is pushed over the list modal, so it is the only dialog in the dom. const relayPicker = (page: Page) => topDialog(page) const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name}) -// 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. +// A toast clears after five seconds, which outlasts the batch a saved setting reaches disk in. const waitForToastToClear = (page: Page) => expect(toast(page)).toHaveCount(0) -// A Field lays its slider out under the row holding the label and the current value, so the card -// around both is what a slider is named from. +// A Field lays its slider out under the row holding the label and the current value. const requestsCard = (page: Page) => page.locator(".card").filter({hasText: "Message Requests"}) const requestSliders = (page: Page) => requestsCard(page).locator('input[type="range"]') @@ -27,9 +22,7 @@ test("US-084 block a relay you never want used", async ({seed, as}) => { await seed(({relay, user}) => { const space = relay("space") - // 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. + // A relay is offered as a suggestion once its nip-11 document has been fetched. relay("other") space.room("general", {name: "General"}) @@ -57,8 +50,7 @@ test("US-084 block a relay you never want used", async ({seed, as}) => { await expect(blocked).toContainText("1 Blocked") - // 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. + // It was the picker's only offer a moment ago, which is what makes its absence about the block. await blocked.click() await dialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).click() @@ -346,8 +338,7 @@ test("US-090 change the app's appearance", async ({seed, as}) => { await expect(page.getByText("125%")).toBeVisible() await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/) - // Moving the slider is the save, so the size the document is rendered at survives a reload - // without anything else having been pressed. + // Moving the slider is the save. await page.reload() await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/) @@ -378,8 +369,7 @@ test("US-091 set up how people zap you", async ({seed, as}) => { await address.getByPlaceholder("user@domain.com").fill("alice@example.test") await address.getByRole("button", {name: "Save Changes"}).click() - // The dialog closes itself once the profile has gone out, and the page behind it carries its own - // "Save Changes" — so wait it out rather than leaving that name ambiguous. + // The dialog closes itself once the profile has gone out, and the page behind it has its own Save. await expect(address).toHaveCount(0) await expect(page.getByText("alice@example.test")).toBeVisible() await expect(page.getByText("Not set")).toHaveCount(0) @@ -391,8 +381,7 @@ test("US-091 set up how people zap you", async ({seed, as}) => { await expect(address).toHaveCount(0) await expect(page.getByText("Not set")).toBeVisible() - // Each preset is a row with a remove button and an amount input. The same utility classes land on - // other rows (a button's spinner), so pin it to the zap-amounts form's rows that hold an input. + // The same utility classes land on other rows, so this is pinned to the zap form's input rows. const zapForm = page.locator("form").filter({hasText: "Zap Amounts"}) const presets = zapForm.locator("div.items-center.gap-2:has(input)") @@ -410,8 +399,7 @@ test("US-091 set up how people zap you", async ({seed, as}) => { await waitForToastToClear(page) - // Discarding puts back the last saved values, which are still alice's original four — the zero - // never reached them. + // Discarding puts back the last saved values, which are still alice's original four. await page.getByRole("button", {name: "Discard Changes"}).click() await expect(presets).toHaveCount(4) diff --git a/e2e/specs/space.spec.ts b/e2e/specs/space.spec.ts index 4fc6b6ce..ea3791e8 100644 --- a/e2e/specs/space.spec.ts +++ b/e2e/specs/space.spec.ts @@ -22,12 +22,9 @@ test("opens the space menu in a drawer on a phone", async ({seed, as}) => { await expect(drawer.getByRole("link", {name: "General"})).toBeVisible() // The space rail sits beside the menu inside the panel, so the menu gets what the rail leaves. - // Sized to the panel instead, it runs off the right of the screen. await expect(drawer.locator(".space-menu")).toBeInViewport({ratio: 1}) - // The panel is full width, so the only way back out is the bottom bar, which the drawer stops - // short of rather than covering. Playwright's hit-target check is what proves it: a drawer over - // the bar would take the click itself. + // The drawer stops short of the bottom bar, and playwright's hit-target check is what proves it. const closeButton = page.getByRole("button", {name: "Close space menu"}) await closeButton.click() @@ -43,8 +40,7 @@ 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, on the last space the reader was in. + // The button is the bottom bar's rather than the page's, so it opens on the last space visited. 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 5b086465..bf34713a 100644 --- a/e2e/specs/spaces.spec.ts +++ b/e2e/specs/spaces.spec.ts @@ -4,9 +4,7 @@ import type {Page} from "@playwright/test" import type {SeededSpace} from "../harness" import {expect, pathPattern, 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. +// Room lists reach indexeddb in three-second batches, so a spec about a reload waits on this first. 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] @@ -14,8 +12,7 @@ const cachedFirstSpace = async (page: Page, pubkey: string) => { return newest?.tags.find(tag => tag[0] === "r")?.[1] } -// The rail shows icons and no text, so a row is read by the tooltip naming its relay. See -// spaceNavItem in notifications.spec.ts for why that is a tooltip rather than an accessible name. +// The rail shows icons and no text, so a row is read by the tooltip naming its relay. const railSpaces = (page: Page) => page.locator(".primary-nav [draggable=true]") const expectRailFirst = (page: Page, name: string) => @@ -24,11 +21,7 @@ const expectRailFirst = (page: Page, name: string) => new RegExp(`^${name}`), ) -// A drop reorders the list in place and publishes a new room list behind it, so the order on screen -// is ahead of the one the app has settled on. Reading the next drag off that optimistic order is -// what made this spec fail under a full suite and pass alone: the relay is slower when the box is -// busy, and a room list landing back from it after the next drop replaces the order that drop -// applied. Waiting for the copy on disk to name the new first space is waiting for the round trip. +// A drop reorders the list in place and publishes behind it, so the copy on disk is the round trip. const expectReordered = async (page: Page, pubkey: string, space: SeededSpace) => { await expectRailFirst(page, space.name) await expect.poll(() => cachedFirstSpace(page, pubkey)).toBe(space.url) @@ -57,8 +50,7 @@ test("US-009 browse and search spaces, and reorder your own", async ({seed, as}) const other = scenario.space("other") const unsigned = scenario.space("unsigned") - // The spaces page discovers unjoined spaces by pulling the room lists of the pubkeys it - // bootstraps from, so pointing that at bob is what puts his other space in front of alice. + // The spaces page discovers unjoined spaces by pulling the room lists of its bootstrap pubkeys. const page = await as(users.alice, "/spaces", {env: {VITE_DEFAULT_PUBKEYS: users.bob.pubkey}}) // The page is for spaces she hasn't joined. The ones she has are in the rail, all of them. @@ -97,9 +89,7 @@ test("US-009 browse and search spaces, and reorder your own", async ({seed, as}) await expectRailFirst(page, space.name) - // 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. + // Chromium's synthetic drag starts and moves it but never delivers the drop the reorder needs. const dataTransfer = await page.evaluateHandle(() => new DataTransfer()) const rail = railSpaces(page) @@ -148,8 +138,7 @@ test("US-010 join a space from an invite link", async ({seed, as}) => { await expect(page.getByRole("button", {name: "Join Space"})).toBeDisabled() await expect(page.getByText("You're about to join:")).toHaveCount(0) - // The link makeInviteLink builds, typed rather than followed — an absolute platform url is an - // off-origin navigation, and parseInviteLink only ever reads its query params. + // An absolute platform url is an off-origin navigation, and parseInviteLink reads query params. await invite.fill("https://app.flotilla.social/join?r=other.test&c=") await expect(page.getByText("You're about to join:")).toBeVisible() @@ -174,9 +163,7 @@ test("US-010 a direct link's join prompt outlives the entry redirect", async ({s const other = scenario.space("other") const page = await as(users.alice, "/") - // A space's own path redirects to the page it opens on, and a modal raised while that navigation - // is in flight is overwritten when it lands. Holding the entry page's module back is what puts - // the prompt inside the redirect here; on a phone the chunk arriving late does it by itself. + // A space's own path redirects, and a modal raised while that navigation is in flight is lost. let held = 0 await page.context().route( @@ -204,9 +191,7 @@ test("US-011 request access when a space turns you away", async ({seed, as}) => const closed = relay("closed") const space = relay("space") - // This relay refuses a join that carries no claim. A claim is not an event a scenario can - // publish — the relay only registers one through the nip-86 method the invite dialog calls — - // so the code alice needs comes out of that dialog below. + // The relay only registers a claim through the nip-86 method the invite dialog calls. closed.room("lobby", {name: "Lobby"}) space.room("general", {name: "General"}) @@ -218,8 +203,7 @@ test("US-011 request access when a space turns you away", async ({seed, as}) => const closed = scenario.space("closed") const space = scenario.space("space") - // Administering a relay is not the same as belonging to it, so the space asks admin to join - // like anyone else — and issues him an invite code all the same. + // Administering a relay is not the same as belonging to it. const admin = await as(users.admin, spacePath(closed.url) + "/about") await admin.locator("form").getByRole("button", {name: "Go back"}).click() @@ -321,8 +305,7 @@ test("US-012 decide whether to trust an unsigned space", async ({seed, as}) => { await expect(bob).toHaveURL(/\/home/) - // 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. + // In-app rather than a fresh load: leaving is published in the background and reaches disk late. await bob.getByRole("link", {name: "All Spaces"}).click() await expect(railSpaces(bob)).toHaveCount(0) @@ -368,8 +351,7 @@ test("US-013 follow a space that has moved", async ({seed, as}) => { await expect(alice).toHaveURL(/\/spaces\/other\.test\/about/) - // 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. + // In-app rather than a fresh load: the updated list is published in the background. await alice.getByRole("link", {name: "All Spaces"}).click() await expect(railSpaces(alice)).toHaveCount(1) @@ -410,8 +392,7 @@ test("US-014 leave a space", async ({seed, as}) => { await expect(page).toHaveURL(/\/home/) - // 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. + // In-app rather than a fresh load: leaving is published in the background. await page.getByRole("link", {name: "All Spaces"}).click() await expect(railSpaces(page)).toHaveCount(0) @@ -446,8 +427,7 @@ test("US-015 view a space's details", async ({seed, as}) => { const space = scenario.space("space") - // Contact, terms, privacy and the limitation warnings are nip-11 fields zooid doesn't publish - // on its own, so the scenario merges them over the relay's real document. + // Contact, terms, privacy and the limitation warnings are nip-11 fields zooid doesn't publish. const relayInfo = { [space.url]: { icon: "https://space.test/icon.png", @@ -512,8 +492,7 @@ test("US-017 search across a space", async ({seed, as}) => { const space = scenario.space("space") - // The directory rather than a feed: search is reachable from every page in a space, and this is - // one where a result's own text can't also be on the page behind the dialog. + // The directory rather than a feed, since a result's own text can't be on the page behind it. const page = await as(users.alice, spacePath(space.url) + "/directory") await page.locator(".secondary-nav").getByRole("button", {name: "Search"}).click() diff --git a/src/app.d.ts b/src/app.d.ts index 973e4608..adc836a4 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -2,7 +2,6 @@ import "@poppanator/sveltekit-svg/dist/svg" import "vite-plugin-pwa/pwa-assets" // See https://kit.svelte.dev/docs/types#app -// for information about these interfaces declare global { namespace App { // interface Error {} diff --git a/src/app/access.ts b/src/app/access.ts index 9bb0b79c..72120475 100644 --- a/src/app/access.ts +++ b/src/app/access.ts @@ -114,13 +114,11 @@ export const publishJoinRequest = (url: string, claim?: string) => { export const publishLeaveRequest = (url: string) => command(writer(RelayLeave).forceRoutes(relay(url))).then(publish) -// A relay answers a re-sent request with "duplicate:" and a membership it already has with -// "already a member" — both leave us where we wanted to be, so only anything else is a refusal. +// "duplicate:" and "already a member" both leave us where we wanted to be. const isMembershipRefusal = (error: string) => Boolean(error) && !error.startsWith("duplicate:") && !error.includes("already") -// Joining a room takes two publishes: a NIP-29 request the relay can refuse, and the user's -// own room list, which is what puts the room in their sidebar. `code` is a room invite code. +// Joining takes two publishes: the NIP-29 request the relay can refuse, and the user's room list. export const joinRoom = async (url: string, h: string, code?: string) => { const eventWriter = writer(RoomJoin).setRoom(url, h) @@ -401,8 +399,7 @@ export class Access { sleep(300), ]) - // A relay that reports methods relay-wide rather than per-user can still come back - // "blocked" for this particular user — treat that as having no claim. + // A relay reporting methods relay-wide can still come back "blocked" for this user. if (methods?.includes("createclaim")) { const {result: claims} = await management.listClaims() diff --git a/src/app/actionItems.ts b/src/app/actionItems.ts index a0d3b887..38f1f2cb 100644 --- a/src/app/actionItems.ts +++ b/src/app/actionItems.ts @@ -6,8 +6,7 @@ import {deriveEventsForUrl} from "@app/repository" // Action items (admin review queue) -// A report is resolved by banning the event it names, a join request by allowing the pubkey, so -// the queue holds whichever of the two the user can actually act on. +// A report is resolved by banning the event it names, a join request by allowing the pubkey. export const deriveSpaceActionItems = (url: string) => derived( [ diff --git a/src/app/articles.ts b/src/app/articles.ts index bc02a2d0..d1e141ec 100644 --- a/src/app/articles.ts +++ b/src/app/articles.ts @@ -1,7 +1,6 @@ const WORDS_PER_MINUTE = 200 -// Articles are markdown, so this counts syntax as words too. That's close enough for an estimate, -// and cheaper than parsing the content a second time just to strip it. +// Articles are markdown, so this counts syntax as words too. export const displayReadingTime = (content: string) => { const words = content.trim().split(/\s+/).filter(Boolean).length const minutes = Math.max(1, Math.round(words / WORDS_PER_MINUTE)) diff --git a/src/app/calendar.ts b/src/app/calendar.ts index 902d7f82..3471fe9a 100644 --- a/src/app/calendar.ts +++ b/src/app/calendar.ts @@ -119,8 +119,7 @@ export const startsOnDay = (event: TrustedEvent, day: Date) => { return Boolean(start && isSameDay(secondsToDate(start), day)) } -// Multi-day events show up on each day they cover, in start order within each day. A malformed -// end date could span an unbounded number of days, so stop after a year. +// A malformed end date could span an unbounded number of days, so stop after a year. export const groupEventsByDay = (events: TrustedEvent[]) => { const result = new Map() @@ -177,8 +176,7 @@ export type CalendarBar = { const daysApart = (a: Date, b: Date) => Math.round((a.getTime() - b.getTime()) / (24 * 60 * 60 * 1000)) -// Lays multi-day events spanning `days` out as bars instead of a chip per day, packing -// overlapping ones into as few vertical lanes as possible. +// Multi-day events spanning `days` are laid out as bars, packed into as few lanes as possible. export const layoutMultiDayBars = ( days: Date[], eventsByDay: Map, @@ -247,8 +245,7 @@ export const deriveRsvps = (event: TrustedEvent) => deriveEvents([makeRsvpFilter export const getRsvpStatus = (rsvp: TrustedEvent) => tagValue(tagSpec("status"), rsvp.tags) -// An RSVP replaces the sender's previous one, but a relay can still be holding both, so the -// newest per person is the one that counts. +// A relay can still hold a replaced RSVP, so the newest per person is the one that counts. export const getRsvpsByStatus = (rsvps: TrustedEvent[]) => { const latest = uniqBy( rsvp => rsvp.pubkey, diff --git a/src/app/call.ts b/src/app/call.ts index 8a39a74f..f9da559e 100644 --- a/src/app/call.ts +++ b/src/app/call.ts @@ -10,25 +10,14 @@ import {deriveEventsForUrl} from "@app/repository" export const LIVEKIT_PARTICIPANTS = 39004 -/** - * Aspect ratio constraints for tiles. The lower bound is dynamic - * (1:1 on landscape, 3:4 on portrait); the upper bound is 16:9. - */ +/** The lower aspect bound is 1:1 on landscape and 3:4 on portrait, and the upper is 16:9. */ const TILE_ASPECT_PORTRAIT = 3 / 4 const TILE_ASPECT_LANDSCAPE = 16 / 9 const TILE_GAP = 8 -/** - * Minimum pixel height for a tile before we allow the grid to - * overflow (scroll) instead of forcing tiles into portrait mode. - * Calibrated so ~6-8 tiles on a standard portrait phone fit - * without scrolling; beyond that, scroll is acceptable. - */ +/** Below this the grid scrolls rather than forcing portrait tiles, which is ~6-8 tiles on a portrait phone. */ const MIN_TILE_HEIGHT = 120 -/** - * A single row in an adaptive tile grid. - */ export type TileRow = { columnCount: number tileWidth: number @@ -38,24 +27,14 @@ export type TileRow = { aspectRatio: number } -/** - * Adaptive tile grid: all tiles share the same dimensions. Full rows - * fill the container width; partial rows are centered. - * - * Example (3 tiles, 2 columns): - * row 0: [ square ] [ square ] - * row 1: [ square ] (centered, same size) - */ +/** All tiles share one size. Full rows fill the container width and a partial row is centered. */ export type AdaptiveTileGrid = { rows: TileRow[] totalWidth: number totalHeight: number } -/** - * Score for comparing candidate layouts. Lower is better. - * Uses named fields instead of opaque array "keys" (per review feedback). - */ +/** Score for comparing candidate layouts. Lower is better. */ type LayoutScore = { /** Penalty for vertical overflow: 0 if none, else huge */ verticalOverflowPenalty: number @@ -88,11 +67,7 @@ const compareScores = (a: LayoutScore, b: LayoutScore): number => { return 0 } -/** - * Compute the largest tile size that fits within a given width and height, - * bounded by [minAspect, TILE_ASPECT_LANDSCAPE]. The tile is shrunk to fit - * whichever dimension is more constraining, so it never overflows. - */ +/** The largest tile that fits, shrunk to whichever dimension is more constraining. */ const fitTile = (availWidth: number, availHeight: number, minAspect: number) => { const fillAspect = availWidth / availHeight const aspectRatio = Math.max(minAspect, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect)) @@ -104,11 +79,7 @@ const fitTile = (availWidth: number, availHeight: number, minAspect: number) => return {tileWidth: availWidth, tileHeight, aspectRatio} } -/** - * Build a candidate grid for a given column count. All tiles share the - * same dimensions; the partial last row (if any) is centered with the - * same tile size as full rows. - */ +/** A candidate grid for a given column count, with the partial last row centered. */ const buildCandidate = ( tileCount: number, columnCount: number, @@ -155,21 +126,7 @@ const buildCandidate = ( return {rows, totalWidth, totalHeight} } -/** - * Compute an adaptive tile grid. All tiles share the same dimensions; - * partial rows are centered. Tiles flex between a minimum aspect - * (1:1 on landscape, 3:4 on portrait) and 16:9, capped so they never - * overflow the container. - * - * Only allows overflow (scroll) when tiles would be below - * MIN_TILE_HEIGHT (~6-8 tiles on portrait phone). - * - * Prioritises: - * 1. No overflow (tiles shrink to fit within aspect bounds) - * 2. Minimal whitespace - * 3. Aspect ratios close to 16:9 - * 4. Larger tiles - */ +/** Scored on overflow first, then whitespace, then distance from 16:9, then tile size. */ export const computeAdaptiveGrid = ( tileCount: number, containerWidth: number, @@ -284,8 +241,7 @@ export const deriveIsCallActiveElsewhere = (url: string | undefined, h: string | !($targetRoom.url === url && $targetRoom.h === h), ) -// leaveVoiceRoom no-ops during Joining, since no session exists yet to leave — cancel the -// in-flight join instead, otherwise ending a call that is still connecting does nothing. +// leaveVoiceRoom no-ops during Joining, so cancel the in-flight join instead. export const endCall = async () => { const engine = await import("@app/callEngine") diff --git a/src/app/callEngine.ts b/src/app/callEngine.ts index f6b5d8b3..5c0ef0d3 100644 --- a/src/app/callEngine.ts +++ b/src/app/callEngine.ts @@ -1,7 +1,4 @@ -/** - * Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox - * (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS. - */ +/** Voice rooms via LiveKit. Voice does not work on localhost in Firefox, where ICE candidate gathering fails. */ import { DisconnectReason, Room as LiveKitRoom, @@ -58,13 +55,11 @@ export const joinVoiceRoom = async ( const signal = controller.signal const isActive = () => joinAbortController === controller - // Self-cleaning controller: aborted in finally so whenTimeout/whenAborted - // helpers clear their timers/listeners once the races below have settled. + // Aborted in finally, so whenTimeout and whenAborted clear their timers once the races settle. const settle = new AbortController() try { - // Tear down any existing session before joining. Bound it so a slow leave - // (camera/screenshare renegotiation can take ~15s) cannot block this join. + // Camera and screenshare renegotiation can take ~15s, so a slow leave is bounded. if (get(currentCallSession)) { await Promise.race([ leaveVoiceRoom(), @@ -134,8 +129,7 @@ export const joinVoiceRoom = async ( syncParticipantMedia(p) } - // Bounded against timeout/abort inside setUpMicrophone: a stuck permission - // prompt resolves to muted rather than hanging the join forever. + // A stuck permission prompt resolves to muted rather than hanging the join. const muted = await setUpMicrophone( startMuted, preferredMicId, @@ -144,8 +138,7 @@ export const joinVoiceRoom = async ( settle.signal, ) - // A cancel during the mic step must tear down the connected room rather - // than leaking it. + // A cancel during the mic step leaks the connected room unless it is torn down here. if (signal.aborted) { teardownRoom(liveKitRoom) throw new AbortError() @@ -208,14 +201,7 @@ export const leaveVoiceRoom = async () => { // Always tear down this room's connection and listeners. teardownRoom(session.livekit) - // Only reset shared UI state if this session is still current. A slow leave - // that was superseded by a new join (bounded by a timeout in joinVoiceRoom) - // must not clobber the freshly-joined session when it finally completes. - // - // Compare the LiveKit room rather than the session object: turning off the - // screen share above emits LocalTrackUnpublished, whose handler replaces the - // store value with a new object for the same call. An identity check would - // fail there and leave the UI stuck in a connected state. + // LocalTrackUnpublished replaces the store value with a new object, so compare the LiveKit room. if (get(currentCallSession)?.livekit === session.livekit) { callState.set(CallState.Disconnected) callMicMuted.set(true) @@ -314,29 +300,18 @@ export const switchCallActiveDevice = async ( } } -// The room whose events are allowed to mutate shared state. Abandoned rooms -// (after switching calls or an engine reconnect give-up) must not clobber it. +// The room whose events may mutate shared state, so an abandoned one cannot clobber it. let activeRoom: LiveKitRoom | undefined let reconnectTimeout: ReturnType | undefined let reconnectAttempt = 0 -// Captured from the dropped session so a full rejoin (as opposed to LiveKit's -// own internal reconnect, which reuses the existing tracks) restores the -// user's mic state instead of always rejoining muted. +// A full rejoin restores mic state from these, unlike LiveKit's reconnect, which reuses its tracks. let reconnectMicMuted = true let reconnectMicDeviceId: string | undefined let joinAbortController: AbortController | undefined let hadCallSession = false let audioResumeListener: Promise | undefined -/** - * On mobile, locking the screen can suspend microphone capture without ever - * ending the underlying MediaStreamTrack: the local participant still looks - * connected and unmuted, but publishes silence until the track is manually - * reacquired. LiveKit only guards against this for tracks attached to a DOM - * element (i.e. video), so the mic needs the same treatment on foreground - * return. `App.addListener("appStateChange", ...)` fires from Capacitor's web - * fallback too, so this covers both native and browser tabs. - */ +/** Locking the screen can suspend mic capture without ending the track, and LiveKit only guards video. */ currentCallSession.subscribe(session => { if (session) { hadCallSession = true @@ -362,8 +337,7 @@ const teardownRoom = (livekit: LiveKitRoom) => { activeRoom = undefined } - // Dropping the listeners keeps TrackUnsubscribed from removing the hidden - // audio elements onTrackSubscribed appended, so detach them here instead. + // Dropping the listeners first keeps TrackUnsubscribed from removing these audio elements. for (const participant of livekit.remoteParticipants.values()) { for (const publication of participant.audioTrackPublications.values()) { publication.track?.detach().forEach(el => el.remove()) @@ -475,8 +449,7 @@ const setUpMicrophone = async ( ]) muted = false } catch (e) { - // Timeout or microphone rejection: join muted, the call is still usable. A - // genuine abort is surfaced to the caller so it can tear down the room. + // A timeout or a rejected microphone joins muted, and only a genuine abort reaches the caller. if (e instanceof AbortError) { throw e } @@ -500,11 +473,7 @@ const reacquireMicrophoneIfNeeded = async () => { return } - // Mirrors LiveKit's own (mobile-only, video-track-only) reacquisition - // check: a capture device that died silently still reports readyState - // "live", but the browser flips `muted`/`enabled` on the underlying - // MediaStreamTrack. Checking for actual silence instead would false- - // positive any time the user simply isn't talking. + // A capture device that died silently still reports readyState "live", but flips `muted` on the track. const {mediaStreamTrack} = track const needsReacquisition = mediaStreamTrack.readyState !== "live" || mediaStreamTrack.muted || !mediaStreamTrack.enabled @@ -515,8 +484,7 @@ const reacquireMicrophoneIfNeeded = async () => { try { await track.restartTrack() } catch { - // Best-effort: the user can still recover via mute/unmute or by - // rejoining if reacquiring the mic fails here. + // Mute and unmute or a rejoin recovers the mic if this fails. } } @@ -579,14 +547,11 @@ const makeOnRoomDisconnected = (livekit: LiveKitRoom) => (reason?: DisconnectRea return } - // Livekit unsubscribes remote tracks before emitting Disconnected, so - // onTrackUnsubscribed has already removed their audio elements by now. + // Livekit unsubscribes remote tracks before Disconnected, so their audio elements are already gone. activeRoom = undefined livekit.removeAllListeners() - // Capture mic state before resetting it, so a subsequent full rejoin (see - // scheduleReconnect/attemptReconnect below) can restore it instead of - // silently coming back muted regardless of what the user had set. + // Captured before the reset, so a full rejoin restores it instead of coming back muted. reconnectMicMuted = get(callMicMuted) reconnectMicDeviceId = livekit.getActiveDevice(DeviceKind.AudioInput) diff --git a/src/app/chats.ts b/src/app/chats.ts index c387f298..1823ecb5 100644 --- a/src/app/chats.ts +++ b/src/app/chats.ts @@ -34,9 +34,7 @@ export const makeChatId = (pubkeys: string[]) => { export const splitChatId = (id: string) => getChatPubkeys(id.split(",")) -// A message the user is party to: they wrote it, or it names them. Anything else decrypted out of -// a wrap addressed to them is a rumor about other people, which is either a mistake or an attempt -// to plant a conversation in their list. +// Anything else decrypted out of a wrap addressed to the user is a rumor about other people. export const isUserMessage = (event: TrustedEvent) => { const pubkey = user.get().pubkey @@ -105,13 +103,7 @@ export const chatsById = call(() => { const removeEvents = (removed: Set) => { let dirty = false - // Drop the removed ids from whatever chats hold them, matching on id alone. A removed event - // can't be looked up in the repository — a cancelled delayed send is dropped from it outright - // (unlike a delete, which leaves the target flagged), so `getEvent` would return nothing and - // the message would linger. Replace each affected chat with a fresh object rather than mutating - // its messages in place: deriveChat is deduplicated by reference (see makeDeriveItem/ - // deriveDeduplicated), so a chat whose identity is unchanged never reaches the ui. A chat - // that loses its last message goes with it, since a chat is only ever its messages. + // deriveChat dedupes by reference, so an affected chat is replaced rather than mutated in place. for (const [chatId, chat] of chatsById) { const messages = chat.messages.filter(e => !removed.has(e.id)) @@ -131,10 +123,7 @@ export const chatsById = call(() => { } } - // Login swaps the whole app — a new identity gets a new repository — so a listener bound to - // `app.get().repository` once at start would keep reading the discarded one after login (see the - // note on `fromApp` in core.ts). Re-bind through the `app` store instead: on each app, rebuild - // the list from that repository and listen to it, tearing down the previous binding first. + // A listener bound to `app.get().repository` would keep reading the app login discarded. let repoUnsubscribe: (() => void) | undefined const bindRepository = ($app: App) => { @@ -187,8 +176,7 @@ export const CHAT_TABS = [ {value: ChatTab.Requests, label: "Requests"}, ] -// Both thresholds are at least one: a pubkey nobody vouches for and a message carrying no nonce -// have met nothing, so a zero would wave the whole list through. +// Both thresholds are at least one, since a zero would wave the whole list through. export type ChatContext = { pubkey: string follows: Set @@ -198,23 +186,18 @@ export type ChatContext = { minWot: number } -// The wrap manager keeps everything about a wrap but its ciphertext, and proof of work is read off -// the id and the nonce tag, so an empty content stands in for what it dropped. +// The wrap manager drops the ciphertext, and proof of work reads off the id and the nonce tag. const getWrapPow = (wrap: WrapItem) => getPow({...wrap, content: ""}) -// A gift wrap is the event a sender has to mint, so the work is on it rather than on the rumor -// sealed inside. A message can arrive in more than one wrap; the best of them is what was paid. +// The work is on the wrap rather than the rumor, and the best of several is what was paid. const getMessagePow = (event: TrustedEvent) => Math.max(0, ...app.get().wrapManager.getWraps(event.id).map(getWrapPow)) -// Someone the user has a standing relationship with, whether or not they have ever written to -// each other. +// Someone the user has a standing relationship with, written to or not. const isKnown = (pubkey: string, ctx: ChatContext) => ctx.follows.has(pubkey) || ctx.members.has(pubkey) || (ctx.scores.get(pubkey) ?? 0) >= ctx.minWot -// A chat the user asked for rather than one that arrived: they have written in it, they know -// everyone else in it, or somebody spent proof of work to reach them. Every other participant has -// to be known, so a stranger cannot get in by adding the user to a group with their friends. +// Every other participant has to be known, so a stranger can't get in by adding the user to a group. export const isConversation = (chat: Chat, ctx: ChatContext) => { const others = remove(ctx.pubkey, chat.pubkeys) @@ -236,9 +219,7 @@ export const groupChatsByTab = (chats: Chat[], ctx: ChatContext): ChatsByTab => const userFollowList = deriveUserItem(FollowLists) -// The spaces the user belongs to, read here rather than imported from @app/rooms: that module -// and @app/routes already require each other, and joining the cycle leaves this one holding an -// uninitialized binding. +// Read here rather than imported from @app/rooms, which already cycles with @app/routes. const userRoomList = deriveUserItem(RoomLists) const wotScores = fromApp($app => $app.use(Wot).scores(WotScope.Follows).$) diff --git a/src/app/commands.ts b/src/app/commands.ts index fbde6aaa..18525c8d 100644 --- a/src/app/commands.ts +++ b/src/app/commands.ts @@ -19,9 +19,7 @@ import {Domain, Network, RelayScopedDerivedPlugin, createSearch, projectFrom} fr import type {IApp, Projection} from "@welshman/app" import {fromApp, usePlugin} from "@app/core" -// NIP-CD definitions count where they're published, since a space curates its own command -// namespace by controlling who may write to it. `|` never appears in a url, and keeps these -// distinct from the `'`-separated room keys. +// `|` never appears in a url, and keeps these distinct from the `'`-separated room keys. const makeCommandKey = (url: string, address: string) => `${url}|${address}` const splitCommandKey = (key: string): [string, string] => { @@ -31,9 +29,7 @@ const splitCommandKey = (key: string): [string, string] => { } export class Commands extends RelayScopedDerivedPlugin { - // Definitions are replaceable, so one pull per space keeps the repository current. The set - // lives on the plugin rather than the module so logging in, which swaps the app and its - // repository, starts over. + // The set lives on the plugin, so logging in and swapping the repository starts over. private pulled = new Set() constructor(app: IApp) { @@ -79,9 +75,7 @@ export const getCommandsForTarget = (target: CommandScopeTarget) => .get() .filter(command => command.matches(target)) -// A space's definitions are the same for everything rendered in it, so one store per url is -// shared rather than every message deriving its own. Subscribing is also what pulls them, so -// reading a space loads its definitions whether or not anything is being composed. +// One store per url, and subscribing to it is what pulls the definitions. const commandsByUrl = new Map>() const deriveCommandsForUrl = (url: string): Readable => { @@ -106,16 +100,13 @@ const deriveCommandsForUrl = (url: string): Readable => { return store } -// The definitions a piece of content is read against: the ones its space publishes that also -// scope to it. Content outside a space has no url, and so no commands. +// Content outside a space has no url, and so no commands. export const deriveValidCommands = (target: CommandScopeTarget): Readable => derived(deriveCommandsForUrl(target.url ?? ""), $commands => $commands.filter(command => command.matches(target)), ) -// Without a qualifier an invocation targets every definition whose trigger matches, so more -// than one here is what tells a composer to disambiguate. Takes an invocation from either -// side: the one a composer is typing, or the one a message was parsed into. +// Without a qualifier an invocation targets every definition whose trigger matches. export const getCommandsForInvocation = ( available: CommandReader[], invocation: Pick, @@ -143,16 +134,13 @@ export const createCommandSearch = (target: CommandScopeTarget) => export const getCommandByAddress = (url: string, address: string) => commands.get().get(makeCommandKey(url, address)) -// An invocation in progress, as the composer understands it. `matches` holds every definition -// the text currently targets, so an ambiguous trigger is visible rather than guessed at, and -// `command` is the one whose arguments shape the input. +// `matches` holds every definition the text targets, and `command` is the one shaping the input. export type CommandDraft = { invocation: CommandInvocation command: CommandReader matches: CommandReader[] args: CommandArg[] - // One entry per argument the text supplies, in order, so an argument already typed can be - // shown as bound or as wrong while the rest are still being entered + // One entry per argument the text supplies, in order. bindings: CommandArgBinding[] activeIndex: number } diff --git a/src/app/components/CalendarAgenda.svelte b/src/app/components/CalendarAgenda.svelte index 869c93db..fe12ec00 100644 --- a/src/app/components/CalendarAgenda.svelte +++ b/src/app/components/CalendarAgenda.svelte @@ -52,13 +52,10 @@ let prevFirstEventId = "" let initialScrollDone = false - // The item an in-progress centering has settled on. A profile name or a reaction count that - // resolves after the item has already rendered shifts everything below it, so this stays - // pinned — and gets re-centered by the observer below — until the visitor scrolls themselves. + // A name or a count resolving late shifts everything below it, so the item stays pinned. let pinnedTarget: HTMLElement | undefined = undefined - // offsetTop is relative to the nearest positioned ancestor, which the scroll container itself - // isn't — comparing bounding rects instead keeps this correct regardless of that + // offsetTop is relative to the nearest positioned ancestor, which the scroll container isn't. const recenter = (target: HTMLElement) => { if (!element) { return @@ -80,11 +77,7 @@ pinnedTarget = undefined } - // A resolved profile name, a reaction count, an RSVP tally, an avatar image — all arrive well - // after the item itself renders and can reflow the whole list, and none of them touch - // `events`, so nothing here re-runs the effect below on its own. A ResizeObserver catches - // every one of those (unlike a MutationObserver, which misses an image's own load-driven - // reflow) and keeps the pin centered until the visitor scrolls themselves. + // A ResizeObserver catches an image's own load-driven reflow, which a MutationObserver misses. const observer = new ResizeObserver(() => { if (pinnedTarget) { recenter(pinnedTarget) diff --git a/src/app/components/CalendarDay.svelte b/src/app/components/CalendarDay.svelte index aafc07d9..1e8d3777 100644 --- a/src/app/components/CalendarDay.svelte +++ b/src/app/components/CalendarDay.svelte @@ -25,9 +25,7 @@ const {url, date, events, context}: Props = $props() - // A modal's props are frozen at the time it's pushed, so this has to re-derive from the feed's - // own store rather than receive a pre-filtered list — otherwise creating an event from here - // would leave the modal showing the stale, empty list it opened with. + // A modal's props are frozen at push time, so re-derive from the feed's own store. const dayEvents = $derived(groupEventsByDay($events).get(makeDayKey(date)) ?? []) const back = () => history.back() diff --git a/src/app/components/CalendarMonth.svelte b/src/app/components/CalendarMonth.svelte index 5225a169..771fe58b 100644 --- a/src/app/components/CalendarMonth.svelte +++ b/src/app/components/CalendarMonth.svelte @@ -28,9 +28,7 @@ const {url, events, eventsByDay, date, context}: Props = $props() - // A cell only has room for a few events, so the rest of the day lives in a modal. The feed's - // own store is passed through rather than a pre-filtered list, since a modal's props are - // frozen at push time and wouldn't pick up an event created from inside it. + // A modal's props are frozen at push time, so pass the feed's store rather than a filtered list. const showDay = (day: Date) => pushModal(CalendarDay, { url, diff --git a/src/app/components/CalendarWeek.svelte b/src/app/components/CalendarWeek.svelte index ae3ff28d..5daad31b 100644 --- a/src/app/components/CalendarWeek.svelte +++ b/src/app/components/CalendarWeek.svelte @@ -28,8 +28,7 @@ const {url, events, eventsByDay, date, context}: Props = $props() - // The feed's own store is passed through rather than a pre-filtered list, since a modal's - // props are frozen at push time and wouldn't pick up an event created from inside it. + // A modal's props are frozen at push time, so pass the feed's store rather than a filtered list. const showDay = (day: Date) => pushModal(CalendarDay, { url, diff --git a/src/app/components/CallBanner.svelte b/src/app/components/CallBanner.svelte index c4d56b5a..4b8c2633 100644 --- a/src/app/components/CallBanner.svelte +++ b/src/app/components/CallBanner.svelte @@ -24,8 +24,7 @@ const {relay, h} = $derived($page.params) const routeUrl = $derived(relay ? decodeRelay(relay) : undefined) - // The call's own room page already shows full controls (CallControlBar), so - // the banner would just be redundant clutter there. + // CallControlBar already shows full controls on the call's own room page. const isCallActiveElsewhere = $derived( deriveIsCallActiveElsewhere(routeUrl, typeof h === "string" ? h : undefined), ) diff --git a/src/app/components/CallControlBar.svelte b/src/app/components/CallControlBar.svelte index 1815a139..624eada5 100644 --- a/src/app/components/CallControlBar.svelte +++ b/src/app/components/CallControlBar.svelte @@ -55,8 +55,7 @@ const joiningHere = $derived($callState === CallState.Joining && isTargetingThisRoom) const connectedHere = $derived($callState === CallState.Connected && isTargetingThisRoom) - // callTargetRoom isn't cleared on cancel/leave, so it can still point at this room - // after the call has ended — check actual state, not just "is this the last target". + // callTargetRoom isn't cleared on cancel or leave, so it can still point at this room. const showJoin = $derived(isVoiceRoom && !joiningHere && !connectedHere) const openJoinDialog = () => pushModal(VoiceRoomJoinDialog, {url, h}) diff --git a/src/app/components/Chat.svelte b/src/app/components/Chat.svelte index ee8916a1..c63ef531 100644 --- a/src/app/components/Chat.svelte +++ b/src/app/components/Chat.svelte @@ -141,8 +141,7 @@ addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags) - // Split the message into multiple pieces so that we can use kind 15 to send images per nip 17 - // Sleep 1 second between each one to make sure timestamps are distinct + // Kind 15 carries one image each per nip 17, and a second's sleep keeps the timestamps distinct. const thunks = await Promise.all( Array.from(enumerate(templates)).map(([i, event]) => $wraps.publish({ @@ -154,9 +153,7 @@ ), ) - // Only once the message exists. Publishing has to read each recipient's messaging relays first, - // and a failed read throws before any thunk is made — so the reply or edit this was part of has - // to survive that along with the draft the composer is holding on to. + // Publishing reads each recipient's messaging relays first, and a failed read throws before any thunk. clearParent() clearEventToEdit() @@ -192,8 +189,7 @@ let eventToEdit: TrustedEvent | undefined = $state() let share: Maybe = $state() - // Claim the share once we're on screen. Sharing into the conversation you're already looking at - // doesn't re-create this component, so this can't be read once on mount. + // Sharing into the conversation already on screen doesn't re-create this component. $effect(() => { if ($pendingShare) { share = $pendingShare diff --git a/src/app/components/ChatCompose.svelte b/src/app/components/ChatCompose.svelte index ced59ac2..4588e672 100644 --- a/src/app/components/ChatCompose.svelte +++ b/src/app/components/ChatCompose.svelte @@ -65,8 +65,7 @@ const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run()) - // Tiptap parses a string handed to insertContent as html, so an angle bracket in the - // transcript would eat the rest of the sentence. + // Tiptap parses a string handed to insertContent as html, so an angle bracket would eat the sentence. const insertTranscript = async (text: string) => { const ed = await editor diff --git a/src/app/components/ClassifiedActions.svelte b/src/app/components/ClassifiedActions.svelte index 84db9948..aed0590d 100644 --- a/src/app/components/ClassifiedActions.svelte +++ b/src/app/components/ClassifiedActions.svelte @@ -42,8 +42,7 @@ showStatus = true, }: Props = $props() - // Editing a listing hands this a new version of the event, so every value read off it has to be - // recomputed rather than captured when the component was created. + // Editing hands this a new version of the event, so every value is recomputed rather than captured. const classified = $derived(reader(Classified)(event)) const h = $derived(classified.room()) const topics = $derived(classified.topics() ?? []) diff --git a/src/app/components/CommandArgBar.svelte b/src/app/components/CommandArgBar.svelte index 0e001025..40b2662c 100644 --- a/src/app/components/CommandArgBar.svelte +++ b/src/app/components/CommandArgBar.svelte @@ -19,8 +19,7 @@ const draft = $derived(describeCommandDraft($available, content)) - // Without a qualifier an invocation reaches every executor whose trigger matches, so offer - // the choice rather than picking one silently. + // Without a qualifier an invocation reaches every executor whose trigger matches. const ambiguous = $derived((draft?.matches.length ?? 0) > 1 && !draft?.invocation.pubkey) const activeArg = $derived(draft?.args[draft.activeIndex]) @@ -36,8 +35,7 @@ return [] }) - // An argument the user has moved past that didn't parse. The one being typed is legitimately - // incomplete, so flagging it would just blink red on every keystroke. + // The argument being typed is legitimately incomplete, so flagging it would blink on every keystroke. const invalid = $derived( draft?.bindings.find((binding, i) => i < draft.activeIndex && !binding.value)?.arg, ) diff --git a/src/app/components/CommentCompose.svelte b/src/app/components/CommentCompose.svelte index 700fe5ee..e36ae762 100644 --- a/src/app/components/CommentCompose.svelte +++ b/src/app/components/CommentCompose.svelte @@ -38,9 +38,7 @@ const selectFiles = () => editor.then(ed => ed.commands.selectFiles()) - // A reply to a room event is a NIP-22 comment on the space's own relay. A reply to a kind 1 - // note is a NIP-10 note, which the outbox model routes to the note's author and back to the - // replier's own relays. + // A reply to a room event is a NIP-22 comment, and a reply to a kind 1 note is a NIP-10 note. const renderReply = async (content: string, tags: string[][]) => { if (url) { const eventWriter = writer(Comment) @@ -50,8 +48,7 @@ .setParentFromEvent(parent ?? event) .setProtected(await $relays.hasNip(url, 70)) - // A comment on a room event is a room event too: an untagged one isn't visible to the - // group at all, so the relay neither gates it with the room nor deletes it with it. + // An untagged comment isn't visible to the group, so the relay neither gates nor deletes it with the room. if (h) { eventWriter.setRoom(url, h) } diff --git a/src/app/components/Content.svelte b/src/app/components/Content.svelte index b6e2e41f..819e306b 100644 --- a/src/app/components/Content.svelte +++ b/src/app/components/Content.svelte @@ -63,9 +63,7 @@ url, }: Props = $props() - // An invocation is plain text and carries no tags, so it's only recognizable against the - // definitions the space it was written in publishes. Anything else — including a command - // nobody here answers to — falls through and renders as the text it is. + // An invocation is plain text, so it is only recognizable against the space's own definitions. const available = deriveValidCommands({ url, kind: event.kind, diff --git a/src/app/components/ContentMarkdown.svelte b/src/app/components/ContentMarkdown.svelte index 73f04917..a58534d4 100644 --- a/src/app/components/ContentMarkdown.svelte +++ b/src/app/components/ContentMarkdown.svelte @@ -1,13 +1,11 @@