Sweep the multi-line comments out of src and e2e (#640)

This commit is contained in:
Coracle-Bot 2026-09-24 18:23:21 +00:00 committed by hodlbod
parent 152214591a
commit 5dc8f9ddaf
170 changed files with 787 additions and 2049 deletions

View file

@ -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 them before handing work back. The full style reference is under Development
Conventions below. 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. - **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. - **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. - **Truthiness** — test values directly (`if (invoice.paid_at)`) instead of comparing against `null`/`undefined`, and put the truthy branch first in a ternary.

View file

@ -7,24 +7,20 @@ import {injectEvents, injectSession} from "./session"
// Must match TEST_ENV_KEY in src/lib/test/env.ts. // Must match TEST_ENV_KEY in src/lib/test/env.ts.
const TEST_ENV_KEY = "__TEST_ENV__" 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 // Set the first time the app reads the injected env, the only evidence here that src/app/env.ts ran.
// has that the hook in src/app/env.ts ran.
const TEST_ENV_READ_KEY = "__TEST_ENV_READ__" const TEST_ENV_READ_KEY = "__TEST_ENV_READ__"
export type BootOptions = { export type BootOptions = {
// Every relay list the app reads at startup is pointed here, so it can only dial relays the // Every relay list the app reads at startup is pointed here, so it can only dial the scenario's.
// scenario created.
relays: string[] relays: string[]
// What a pubkey's own lists are resolved from, which is a relay of its own only when the // What a pubkey's own lists are resolved from, defaulting to `relays`.
// scenario opened one. Defaults to `relays`.
indexers?: string[] indexers?: string[]
spaces?: string[] spaces?: string[]
user?: TestUser user?: TestUser
// What this user's client already has in local storage, e.g. their room list. // What this user's client already has in local storage, e.g. their room list.
events?: TrustedEvent[] events?: TrustedEvent[]
path?: string path?: string
// VITE_ values the scenario sets for itself, applied over the relay-derived ones below. Anything // VITE_ values the scenario sets for itself. Anything named here has to be something the test owns.
// named here has to be something the test owns, or the test fails on a leak.
env?: Record<string, string> env?: Record<string, string>
} }
@ -58,8 +54,7 @@ export const boot = async (
VITE_DEFAULT_SPACES: spaces.join(","), VITE_DEFAULT_SPACES: spaces.join(","),
VITE_PLATFORM_RELAYS: "", VITE_PLATFORM_RELAYS: "",
VITE_BLOCKED_RELAYS: "", VITE_BLOCKED_RELAYS: "",
// Nothing serves this url, so a push bridge connection is reported as a leak instead of // Nothing serves this url, so a push bridge connection is reported as a leak.
// blending into the traffic of a relay the scenario did create.
VITE_PUSH_BRIDGE: "ws://localhost:1/", VITE_PUSH_BRIDGE: "ws://localhost:1/",
...env, ...env,
}, },
@ -75,10 +70,7 @@ export const boot = async (
await page.goto(path) await page.goto(path)
// The root layout renders nothing until its async setup block resolves, so the shell appearing // The root layout renders nothing until its async setup resolves, and a rejected session renders the landing dialog.
// 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.
const shell = page.locator(user ? ".primary-nav" : ".fl") const shell = page.locator(user ? ".primary-nav" : ".fl")
for (let attempt = 0; ; attempt++) { 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 // 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.
const usedTestEnv = await page.evaluate( const usedTestEnv = await page.evaluate(
key => Boolean(Reflect.get(window, key)), key => Boolean(Reflect.get(window, key)),
TEST_ENV_READ_KEY, TEST_ENV_READ_KEY,

View file

@ -1,15 +1,10 @@
import type {Page} from "@playwright/test" import type {Page} from "@playwright/test"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
// Must match the database name and the `events` table in src/app/storage.ts, which are scoped to // Must match the database name and the `events` table in src/app/storage.ts.
// one identity.
const databaseName = (pubkey: string) => `flotilla-9gl-${pubkey}` const databaseName = (pubkey: string) => `flotilla-9gl-${pubkey}`
/** /** What this user's client has written to disk. Events reach indexeddb in three-second batches. */
* 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.
*/
export const readCachedEvents = (page: Page, pubkey: string): Promise<TrustedEvent[]> => export const readCachedEvents = (page: Page, pubkey: string): Promise<TrustedEvent[]> =>
page.evaluate(async name => { page.evaluate(async name => {
const open = await new Promise<IDBDatabase>((resolve, reject) => { const open = await new Promise<IDBDatabase>((resolve, reject) => {
@ -19,8 +14,7 @@ export const readCachedEvents = (page: Page, pubkey: string): Promise<TrustedEve
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
// An unversioned open creates the database when it is missing, so a client that has not written // An unversioned open creates the database when it is missing, so a client with no writes has no store.
// anything yet has no store to read.
if (!open.objectStoreNames.contains("events")) { if (!open.objectStoreNames.contains("events")) {
open.close() open.close()

View file

@ -2,8 +2,7 @@ import type {BrowserContext} from "@playwright/test"
import type {StampedEvent} from "@welshman/util" import type {StampedEvent} from "@welshman/util"
import type {TestUser} from "../keys" import type {TestUser} from "../keys"
// The function playwright installs on window for the shim below to call into. The keys live in // The keys live in node, so a signer built in the page would not be the one seeding signs with.
// node, so a signer built in the page would not be the one seeding signs with.
const TEST_NIP07_KEY = "__TEST_NIP07__" const TEST_NIP07_KEY = "__TEST_NIP07__"
type Nip07Call = type Nip07Call =
@ -11,13 +10,7 @@ type Nip07Call =
| {method: "signEvent"; template: StampedEvent} | {method: "signEvent"; template: StampedEvent}
| {method: "encrypt" | "decrypt"; scheme: "nip04" | "nip44"; pubkey: string; message: string} | {method: "encrypt" | "decrypt"; scheme: "nip04" | "nip44"; pubkey: string; message: string}
/** /** A NIP-07 provider backed by a test identity's real signer, installed before the page navigates. */
* 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.
*/
export const injectNip07 = async (context: BrowserContext, user: TestUser) => { export const injectNip07 = async (context: BrowserContext, user: TestUser) => {
await context.exposeBinding(TEST_NIP07_KEY, (source, call: Nip07Call) => { await context.exposeBinding(TEST_NIP07_KEY, (source, call: Nip07Call) => {
if (call.method === "getPublicKey") { if (call.method === "getPublicKey") {

View file

@ -7,9 +7,7 @@ const TEST_SESSION_KEY = "__TEST_SESSION__"
const TEST_EVENTS_KEY = "__TEST_EVENTS__" const TEST_EVENTS_KEY = "__TEST_EVENTS__"
// A nip01 session in the {method, data} shape @welshman/app's session handlers deserialize, so // The {method, data} shape @welshman/app's session handlers deserialize, installed before navigating.
// 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.
export const injectSession = (context: BrowserContext, user: TestUser) => export const injectSession = (context: BrowserContext, user: TestUser) =>
context.addInitScript( context.addInitScript(
([key, session]) => { ([key, session]) => {
@ -18,8 +16,7 @@ export const injectSession = (context: BrowserContext, user: TestUser) =>
[TEST_SESSION_KEY, {method: "nip01", data: {secret: user.secret}}] as const, [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 // The local cache a returning user would boot with, loaded once the injected session is restored.
// returning user would boot with.
export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) => export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) =>
context.addInitScript( context.addInitScript(
([key, value]) => { ([key, value]) => {

View file

@ -1,22 +1,13 @@
import type {BrowserContext} from "@playwright/test" import type {BrowserContext} from "@playwright/test"
// What `getInfo` answers with. `supports` is what src/app/components/WalletConnect.svelte gates the // `supports` is what WalletConnect.svelte gates the connection on, and the alias names it on screen.
// connection on, and the node's alias is what the wallet page names the connection by.
export type WebLnInfo = { export type WebLnInfo = {
supports?: string[] supports?: string[]
node?: {alias?: string; pubkey?: string} node?: {alias?: string; pubkey?: string}
version?: string version?: string
} }
/** /** A WebLN provider on `window`, installed before the page navigates: WalletConnect reads it while it renders. */
* 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.
*/
export const injectWebLn = (context: BrowserContext, info: WebLnInfo = {}) => export const injectWebLn = (context: BrowserContext, info: WebLnInfo = {}) =>
context.addInitScript( context.addInitScript(
$info => { $info => {

View file

@ -1,20 +1,13 @@
import {inspect} from "node:util" import {inspect} from "node:util"
import type {BrowserContext, ConsoleMessage} from "@playwright/test" 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 // A CSP refusal reaches the console and nothing else, so a policy that has rotted is invisible (#535).
// 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).
const isRefusal = (text: string) => text.includes("Content Security Policy") 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 // Recreating the container between tests aborts route chunks in flight, until #529 stops the churn.
// 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.
const isChunkLoss = (text: string) => text.includes("Failed to fetch dynamically imported module") 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 // Chrome carries a failed request's url nowhere but the message's location.
// 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.
const locate = (message: ConsoleMessage) => { const locate = (message: ConsoleMessage) => {
const text = message.text() const text = message.text()
const {url} = message.location() const {url} = message.location()
@ -22,9 +15,7 @@ const locate = (message: ConsoleMessage) => {
return url && !text.includes(url) ? `${text} ${url}` : text return url && !text.includes(url) ? `${text} ${url}` : text
} }
// Playwright builds this from the page's exception details, and a page that throws something other // A page that throws something other than an Error leaves playwright neither message nor stack.
// 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:".
const describe = (error: Error) => error.stack || error.message || inspect(error) const describe = (error: Error) => error.stack || error.message || inspect(error)
export type FaultWatch = { 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. // The subset of it that means the app broke rather than the box being noisy.
found: string[] found: string[]
observe(context: BrowserContext, who: string): void observe(context: BrowserContext, who: string): void
// Throws when the app itself broke while the test ran: an uncaught exception, or code of ours the // Throws on an uncaught exception or code the browser refused to run, not on a failed request.
// 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.
assertNone(): void assertNone(): void
} }

View file

@ -7,10 +7,7 @@ export type TestFile = {
buffer: Buffer buffer: Buffer
} }
// A real 1x1 gif. Gif rather than png because compressFileForUpload passes it through untouched // Gif rather than png, since compressFileForUpload passes it through instead of re-encoding it.
// 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().
export const GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" export const GIF_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
export const GIF = Buffer.from(GIF_BASE64, "base64") 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}) 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 // Every picker opens the browser's own chooser, and the input behind it is never on screen.
// it a file: the input behind it is never on screen.
export const chooseFile = async (page: Page, button: Locator, file: TestFile) => { export const chooseFile = async (page: Page, button: Locator, file: TestFile) => {
const chooser = page.waitForEvent("filechooser") const chooser = page.waitForEvent("filechooser")

View file

@ -99,8 +99,7 @@ export type {
} from "./net/http" } from "./net/http"
export type {WebLnInfo} from "./app/webln" export type {WebLnInfo} from "./app/webln"
// Mirrors encodeRelay in src/app/relays.ts. Importing it reaches the app's module graph, and with // Mirrors encodeRelay in src/app/relays.ts, which cannot be imported without pulling in sveltekit.
// it sveltekit.
const encodeRelay = (url: string) => const encodeRelay = (url: string) =>
encodeURIComponent( encodeURIComponent(
normalizeRelayUrl(url) normalizeRelayUrl(url)
@ -112,51 +111,40 @@ export const spacePath = (url: string) => `/spaces/${encodeRelay(url)}`
export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}` 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 // Mirrors makeChatId in src/app/chats.ts.
// with commas — see makeChatId in src/app/chats.ts.
export const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}` export const chatPath = (...pubkeys: string[]) => `/chat/${[...pubkeys].sort().join(",")}`
export const profilePath = (pubkey: string) => `/people/${npubEncode(pubkey)}` 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 // Matches a path literally, for one whose query string, hash or dots would otherwise be wildcards.
// path being matched, or a host whose dots would otherwise be wildcards.
export const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) export const pathPattern = (path: string) => new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
// What a page is opened with, over and above the scenario's own relays. // What a page is opened with, over and above the scenario's own relays.
export type PageOptions = { export type PageOptions = {
// Overrides the project's context options, for a spec that needs a viewport or a permission of // Overrides the project's context options, for a spec needing its own viewport or permission.
// its own.
context?: BrowserContextOptions context?: BrowserContextOptions
// VITE_ values applied over the ones derived from the scenario's relays, e.g. a platform space or // VITE_ values applied over the ones derived from the scenario's relays. See BootOptions in app/boot.ts.
// the domain hosted relays are created under. See BootOptions in app/boot.ts.
env?: Record<string, string> env?: Record<string, string>
// A NIP-07 provider signing as this user, for a login that goes through an extension. // A NIP-07 provider signing as this user, for a login that goes through an extension.
nip07?: TestUser nip07?: TestUser
// A WebLN provider on window, for a wallet that gets connected through an extension. // A WebLN provider on window, for a wallet that gets connected through an extension.
webln?: WebLnInfo webln?: WebLnInfo
// A blossom server, installed before the page boots. mockBlossom called on the page `as()` // A blossom server installed before boot, for a url src/app/sync.ts probes and caches on load.
// 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.
blossom?: BlossomOptions blossom?: BlossomOptions
// Fields merged over a relay's own nip-11 document, keyed by relay url. // Fields merged over a relay's own nip-11 document, keyed by relay url.
relayInfo?: RelayInfoOverrides relayInfo?: RelayInfoOverrides
// What the hosting backend already knows about this user. Read `getHosting(page.context())` for // What the hosting backend already knows about this user; getHosting changes it mid-test.
// the handle that changes it mid-test.
hosting?: HostingFixtures hosting?: HostingFixtures
// Relay urls that take the socket and answer nothing, from the page's first connection onward. // Relay urls that take the socket and answer nothing; silenceRelay does the same mid-test.
// `silenceRelay(page.context(), url)` is the same fault applied mid-test, which takes effect on
// the next connection rather than this one.
silent?: string[] silent?: string[]
} }
export type Harness = { export type Harness = {
zooid: Zooid zooid: Zooid
seed(build: (tools: SeedTools) => MaybeAsync<void>): Promise<Scenario> seed(build: (tools: SeedTools) => MaybeAsync<void>): Promise<Scenario>
// A logged-in page for a user, in its own browser context, with its own storage and its own // A logged-in page for a user, in its own browser context.
// sockets into the relays every other user is talking to.
as(user: TestUser, path?: string, options?: PageOptions): Promise<Page> as(user: TestUser, path?: string, options?: PageOptions): Promise<Page>
// The same page with no session injected, which is the only way to watch a login or a logout // The same page with no session injected, which is the only way to watch a login or a logout.
// happen.
visit(path?: string, options?: PageOptions): Promise<Page> visit(path?: string, options?: PageOptions): Promise<Page>
} }
@ -172,8 +160,7 @@ export type HarnessWorkerFixtures = {
} }
export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
// Playwright's own context, and the `page` fixture built on it, is unrouted, so a page born // Playwright's own context is unrouted, so a page born there boots against the relays in .env.
// there boots the app against the relays baked into .env.
context: async () => { context: async () => {
throw new Error( throw new Error(
"The built-in `context` and `page` fixtures reach the real network. Open a page with the " + "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<HarnessFixtures, HarnessWorkerFixtures>({
"they navigate.", "they navigate.",
) )
}, },
// Playwright builds this one with `playwright.request.newContext()`, an http client in this // Playwright builds this with request.newContext(), which belongs to no browser context.
// process that belongs to no browser context, so `installHttpRoutes` cannot see it.
request: async () => { request: async () => {
throw new Error( throw new Error(
"The built-in `request` fixture makes http requests from node, where nothing intercepts " + "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)`.", "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 // One container per worker, recreated in the teardown of the test that finishes.
// the teardown of the test that finishes rather than the setup of the one that starts.
zooid: [ zooid: [
// playwright reads a fixture's dependencies off this pattern, so it has to stay a pattern // playwright reads a fixture's dependencies off this pattern, so it stays a pattern.
// even when there are none.
// eslint-disable-next-line no-empty-pattern // eslint-disable-next-line no-empty-pattern
async ({}, use) => { async ({}, use) => {
const zooid = new Zooid() const zooid = new Zooid()
@ -228,12 +212,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
const {urls, indexerUrls, cache} = requireScenario() const {urls, indexerUrls, cache} = requireScenario()
await zooid.settle() await zooid.settle()
// The project's own `use` first, so a viewport or device descriptor set in // context.route does not see a request a service worker makes, and sveltekit registers one in dev.
// 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.
const context = await browser.newContext({ const context = await browser.newContext({
...testInfo.project.use, ...testInfo.project.use,
serviceWorkers: "block", serviceWorkers: "block",
@ -242,14 +221,10 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
contexts.push({name: user?.name ?? "anonymous", context}) contexts.push({name: user?.name ?? "anonymous", context})
// `use.trace` and the built-in reporting only cover contexts playwright made itself, so a // use.trace and the built-in reporting only cover contexts playwright made itself.
// 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.
faults.observe(context, user?.name ?? "anonymous") faults.observe(context, user?.name ?? "anonymous")
// Playwright matches the most recently registered route first and every mock falls through // Playwright matches the most recently registered route first, so the block-all goes in first.
// what it doesn't recognize, so the block-all goes in before the mocks, and all of it before
// the page navigates.
await installHttpRoutes(context) await installHttpRoutes(context)
await installWebSocketRoutes(context, zooid) await installWebSocketRoutes(context, zooid)
@ -276,9 +251,7 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
await injectWebLn(context, options.webln) await injectWebLn(context, options.webln)
} }
// Headless Chromium reports `Notification.permission` as "denied" even where playwright has // Headless Chromium reports Notification.permission as "denied" whatever playwright granted.
// granted the permission at the browser level, so the grant is reflected into the API the app
// reads.
if (options.context?.permissions?.includes("notifications")) { if (options.context?.permissions?.includes("notifications")) {
await context.addInitScript(() => { await context.addInitScript(() => {
Object.defineProperty(Notification, "permission", { Object.defineProperty(Notification, "permission", {
@ -311,28 +284,22 @@ export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
}, },
}) })
// Closed before anything is read off it: an $effect teardown reading a binding svelte has // Closed before anything is read off it: an $effect teardown reading a cleared binding throws.
// already cleared throws on unmount, and that is the fault the suite was blindest to.
for (const {context} of contexts) { for (const {context} of contexts) {
await context.close() await context.close()
} }
// Before the assertions below, which throw: a test that fails still owes the next one a // Before the assertions below, which throw: a failing test still owes the next one a container.
// container, and with every page already closed there is nothing left for the recreate's
// network churn to interrupt.
await zooid.reset() await zooid.reset()
if (faults.found.length > 0 || testInfo.status !== testInfo.expectedStatus) { if (faults.found.length > 0 || testInfo.status !== testInfo.expectedStatus) {
// A path rather than a body: the list reporter truncates an inline attachment, and the // A path rather than a body, since the list reporter truncates an inline attachment.
// nightly run on the box keeps test-results and nothing else.
const consolePath = testInfo.outputPath("browser-console.log") const consolePath = testInfo.outputPath("browser-console.log")
await writeFile(consolePath, faults.log.join("\n")) await writeFile(consolePath, faults.log.join("\n"))
await testInfo.attach("browser-console", {path: consolePath, contentType: "text/plain"}) 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 // Whether a user ever had an event, which tells a client that dropped one from a relay that never sent it.
// 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.
const transcriptPath = testInfo.outputPath("relay-transcript.log") const transcriptPath = testInfo.outputPath("relay-transcript.log")
await writeFile( await writeFile(

View file

@ -9,8 +9,7 @@ export type TestUser = {
signer: Nip01Signer signer: Nip01Signer
} }
// Every identity this process can sign as. zooid authenticates each write and refuses an event // zooid refuses an event whose author is not the authenticated pubkey, so seeding looks the author up here.
// whose author is not the authenticated pubkey, so seeding looks a fixture's author up here.
export const testUsersByPubkey = new Map<string, TestUser>() export const testUsersByPubkey = new Map<string, TestUser>()
const makeUser = (name: string, secret: string): TestUser => { const makeUser = (name: string, secret: string): TestUser => {
@ -21,8 +20,7 @@ const makeUser = (name: string, secret: string): TestUser => {
return user return user
} }
// The secrets never leave the test process. They are stable across runs, so a pubkey can be // Stable across runs, so a pubkey can be asserted on and its leading nibbles name an author in a diff.
// asserted on directly, and the leading nibbles name an author in a failed-assertion diff.
export const users = { export const users = {
alice: makeUser("alice", "a11ce00000000000000000000000000000000000000000000000000000000001"), alice: makeUser("alice", "a11ce00000000000000000000000000000000000000000000000000000000001"),
bob: makeUser("bob", "b0b0000000000000000000000000000000000000000000000000000000000002"), bob: makeUser("bob", "b0b0000000000000000000000000000000000000000000000000000000000002"),
@ -30,15 +28,10 @@ export const users = {
admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"), admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"),
} }
// secp256k1's group order. A secret is a scalar in [1, n), so the digest below is reduced into // secp256k1's group order. A secret is a scalar in [1, n), so the digest below is reduced into it.
// that range rather than rejected when it falls outside.
const CURVE_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") const CURVE_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141")
/** /** An identity beyond the four above, derived from its name so its pubkey is stable, and registered on minting. */
* 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.
*/
export const makeTestUser = (name: string) => { export const makeTestUser = (name: string) => {
const digest = BigInt("0x" + createHash("sha256").update(name).digest("hex")) const digest = BigInt("0x" + createHash("sha256").update(name).digest("hex"))
const secret = ((digest % (CURVE_ORDER - 1n)) + 1n).toString(16).padStart(64, "0") const secret = ((digest % (CURVE_ORDER - 1n)) + 1n).toString(16).padStart(64, "0")

View file

@ -1,8 +1,6 @@
import type {BrowserContext} from "@playwright/test" import type {BrowserContext} from "@playwright/test"
// State the harness keeps per browser context, outside the browser: recorded traffic, blocked // Reading one before the route installer that owns it has run is a spec calling things out of order.
// 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.
export const makeContextStore = <T>(installer: string) => { export const makeContextStore = <T>(installer: string) => {
const byContext = new WeakMap<BrowserContext, T>() const byContext = new WeakMap<BrowserContext, T>()

View file

@ -8,9 +8,7 @@ import {tenantByUrl} from "../zooid/config"
import {requestZooid} from "../zooid/transport" import {requestZooid} from "../zooid/transport"
import {makeContextStore} from "./context" import {makeContextStore} from "./context"
// Mirrors the service urls in src/app/env.ts and .env, which this process can't import because // Mirrors the service urls in src/app/env.ts, which reads import.meta.env and pulls in Capacitor.
// 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.
const DUFFLEPUD_ORIGIN = "https://dufflepud.coracle.social" const DUFFLEPUD_ORIGIN = "https://dufflepud.coracle.social"
const PUSH_SERVER_ORIGIN = "https://nps.flotilla.social" const PUSH_SERVER_ORIGIN = "https://nps.flotilla.social"
const HOSTING_ORIGIN = "https://api.hosting.coracle.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. // Transcription and speech both go here, against whichever key the user has saved.
const OPENROUTER_ORIGIN = "https://openrouter.ai" const OPENROUTER_ORIGIN = "https://openrouter.ai"
// Where the hosting api sends a browser to pay. `.test` resolves nowhere and the block-all aborts // .test resolves nowhere and the block-all aborts the navigation, so a spec sees the redirect.
// the navigation, so a spec sees the redirect without one leaving.
const CHECKOUT_ORIGIN = "https://checkout.test" const CHECKOUT_ORIGIN = "https://checkout.test"
// Relay-hosted livekit lives under a well-known path rather than an origin of its own. // 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", "base64",
) )
// The dev server from vite.config.ts, on the port playwright.config.ts started it on. Traffic to it // The dev server. Traffic to it is the app loading itself, so it is the one host both layers pass.
// is the app loading itself rather than egress, so it is the one host both layers here let past,
// websockets included for Vite's hmr socket.
export const isDevServerUrl = (url: URL) => export const isDevServerUrl = (url: URL) =>
url.port === (process.env.E2E_PORT ?? "1847") && url.port === (process.env.E2E_PORT ?? "1847") &&
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
@ -55,18 +50,11 @@ export type BlockedRequest = {
const blockedStore = makeContextStore<BlockedRequest[]>("installHttpRoutes") const blockedStore = makeContextStore<BlockedRequest[]>("installHttpRoutes")
/** /** Blocks every http request but the dev server's and a relay's own origin. Install this first. */
* 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.
*/
export const installHttpRoutes = (context: BrowserContext) => { export const installHttpRoutes = (context: BrowserContext) => {
const blocked = blockedStore.set(context, []) const blocked = blockedStore.set(context, [])
// The dev server is left unrouted rather than matched and continued. A sveltekit page in dev is // A sveltekit page in dev is hundreds of module requests, and none of them is egress.
// hundreds of module requests, and none of them is egress.
return context.route( return context.route(
url => !isDevServerUrl(url), url => !isDevServerUrl(url),
async route => { async route => {
@ -94,12 +82,7 @@ export const installHttpRoutes = (context: BrowserContext) => {
) )
} }
/** /** Opt-in: some of what a page asks for is meant to be refused, so call it from a spec rather than teardown. */
* 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.
*/
export const assertNoBlockedRequests = (context: BrowserContext) => { export const assertNoBlockedRequests = (context: BrowserContext) => {
const blocked = blockedStore.get(context) 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. // Fields to merge over a relay's own nip-11 document, keyed by its url.
export type RelayInfoOverrides = Record<string, object> export type RelayInfoOverrides = Record<string, object>
/** /** A relay's real document with a few fields replaced. It is read at startup, so install it before navigating. */
* 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.
*/
export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverrides) => { export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverrides) => {
const overrideByOrigin = new Map( const overrideByOrigin = new Map(
Object.entries(overrides).map(([url, override]): [string, object] => [ 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 host = hostByOrigin.get(origin)
const override = overrideByOrigin.get(origin) const override = overrideByOrigin.get(origin)
// The nip-86 management api posts to this same path, and what it answers decides what the // The nip-86 management api posts to this same path, so only the document request is touched.
// admin ui offers, so only the document request is touched.
if (request.method() === "GET" && host && override) { if (request.method() === "GET" && host && override) {
const {status, headers, body} = await requestZooid(host, "GET", "/", { const {status, headers, body} = await requestZooid(host, "GET", "/", {
...request.headers(), ...request.headers(),
@ -151,8 +126,7 @@ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverr
return route.fulfill({ return route.fulfill({
status, status,
// khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's own // khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's headers are kept.
// headers are kept, all but the length of a body that is about to change.
headers: omit(["content-length"], headers), headers: omit(["content-length"], headers),
body: JSON.stringify({...JSON.parse(body.toString()), ...override}), 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. An empty body leaves window.plausible as the queueing shim. */
* 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.
*/
export const mockAnalytics = (context: BrowserContext) => export const mockAnalytics = (context: BrowserContext) =>
context.route(`${PLAUSIBLE_ORIGIN}/**`, route => context.route(`${PLAUSIBLE_ORIGIN}/**`, route =>
route.fulfill({contentType: "application/javascript", body: ""}), route.fulfill({contentType: "application/javascript", body: ""}),
@ -183,12 +153,7 @@ export type DufflepudFixtures = {
zappers?: {lnurl: string; info?: Omit<ZapperValues, "lnurl">}[] zappers?: {lnurl: string; info?: Omit<ZapperValues, "lnurl">}[]
} }
/** /** Dufflepud, the one service url the app hard-codes. `as()` installs it with no fixtures, so a spec calls this again. */
* 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.
*/
export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtures = {}) => export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtures = {}) =>
context.route(`${DUFFLEPUD_ORIGIN}/**`, route => { context.route(`${DUFFLEPUD_ORIGIN}/**`, route => {
const {pathname} = new URL(route.request().url()) const {pathname} = new URL(route.request().url())
@ -214,12 +179,10 @@ export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtur
return route.fallback() return route.fallback()
}) })
// The rate the harness writes its silence at. Nothing downstream reads it: the app decodes what it // The app decodes what it is answered and writes its own header from the result.
// is answered and writes its own header from the result.
const SPEECH_RATE = 24000 const SPEECH_RATE = 24000
// Silence as a wav, built rather than inlined so a spec can ask for a length and then assert the // Built rather than inlined, so a spec can ask for a length and assert the duration read off it.
// duration the player reads off it.
const silence = (seconds: number) => { const silence = (seconds: number) => {
const bytes = seconds * SPEECH_RATE * 2 const bytes = seconds * SPEECH_RATE * 2
const wav = Buffer.alloc(44 + bytes) const wav = Buffer.alloc(44 + bytes)
@ -240,16 +203,10 @@ const silence = (seconds: number) => {
return wav return wav
} }
// The format the app asks the real endpoint for. The harness has no mp3 encoder, so it answers a // The harness has no mp3 encoder, so it answers a wav, which the app decodes either way.
// wav instead — the app decodes either, and only an answer a browser can decode exercises that.
const SPEECH_FORMAT = "mp3" const SPEECH_FORMAT = "mp3"
/** /** OpenRouter's text to speech, answering every request with the same silence. Raw pcm is refused. */
* 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.
*/
export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3) => { export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3) => {
const spoken: string[] = [] const spoken: string[] = []
@ -272,18 +229,14 @@ export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3)
} }
export type Transcription = { export type Transcription = {
// The name each recording was uploaded under, in the order it was uploaded. OpenRouter picks a // OpenRouter picks a decoder off the extension, so this is how a spec reads what the recorder made.
// decoder off the extension, so this is how a spec reads what the recorder produced.
uploads: string[] uploads: string[]
// Stops answering. A request that arrives while this is on is held open until `release`, which // Stops answering. A request that arrives while this is on is held open until `release`.
// is what a spec needs to watch a transcription that is still out.
hold(): void hold(): void
release(): 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 ( export const mockOpenRouterTranscription = async (
context: BrowserContext, context: BrowserContext,
text: string, 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 // getBlossomServer probes the space's own origin, then the user's kind-10063 list, then this.
// 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.
export const DEFAULT_BLOSSOM_ORIGIN = "https://blossom.primal.net" export const DEFAULT_BLOSSOM_ORIGIN = "https://blossom.primal.net"
export type BlossomOptions = { export type BlossomOptions = {
@ -327,27 +278,18 @@ export type BlossomOptions = {
server: string server: string
} }
/** /** What a spec can turn on. Each of these is off by default. */
* 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.
*/
export type BlossomHandle = { export type BlossomHandle = {
// The same server, blobs included, in another user's context. A conversation's image is uploaded // A conversation's image is uploaded encrypted, so one end's bytes have to be the other's.
// encrypted and decrypted by the recipient, so the bytes one end puts in have to be the bytes the
// other reads back.
install(context: BrowserContext): Promise<void> install(context: BrowserContext): Promise<void>
// Holds every upload open until `release`, which makes the in-flight state a fact rather than a // Holds every upload open until `release`, which makes the in-flight state a fact rather than a race.
// race against a mock that answers in a microtask.
hold(): void hold(): void
release(): void release(): void
// Refuses every upload probe from here on, with a reason the app has to show. // Refuses every upload probe from here on, with a reason the app has to show.
refuse(reason: string): void refuse(reason: string): void
} }
/** /** A blossom server that keeps what it was given, hashing an upload exactly as the real thing would. */
* 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.
*/
export const mockBlossom = async (context: BrowserContext, {server}: BlossomOptions) => { export const mockBlossom = async (context: BrowserContext, {server}: BlossomOptions) => {
const {origin} = new URL(server) const {origin} = new URL(server)
const blobs = new Map<string, {body: Buffer; type: string}>() const blobs = new Map<string, {body: Buffer; type: string}>()
@ -364,8 +306,7 @@ export const mockBlossom = async (context: BrowserContext, {server}: BlossomOpti
const {pathname} = new URL(request.url()) const {pathname} = new URL(request.url())
if (pathname === "/upload") { if (pathname === "/upload") {
// BUD-06, which the app asks before spending an upload. A refusal has to expose its // BUD-06, which the app asks before spending an upload. A refusal exposes its reason cross-origin.
// reason cross-origin or all the toast can say is the status code.
if (method === "HEAD") { if (method === "HEAD") {
if (refusal) { if (refusal) {
return route.fulfill({ return route.fulfill({
@ -424,10 +365,7 @@ export const mockBlossom = async (context: BrowserContext, {server}: BlossomOpti
return handle return handle
} }
/** /** The push server. Only the native adapters talk to it, so a browser run reaching this is a regression. */
* 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.
*/
export const mockPushServer = (context: BrowserContext) => export const mockPushServer = (context: BrowserContext) =>
context.route(`${PUSH_SERVER_ORIGIN}/**`, route => { context.route(`${PUSH_SERVER_ORIGIN}/**`, route => {
const [resource] = new URL(route.request().url()).pathname.split("/").filter(Boolean) const [resource] = new URL(route.request().url()).pathname.split("/").filter(Boolean)
@ -437,8 +375,7 @@ export const mockPushServer = (context: BrowserContext) =>
return route.fulfill({json: {}}) return route.fulfill({json: {}})
} }
// Registration is only usable if it comes back with both. The relay is told to post // The relay is told to post notifications to the callback rather than the client ever fetching it.
// notifications to the callback rather than the client ever fetching it.
const key = "test-push-subscription" const key = "test-push-subscription"
return route.fulfill({json: {key, callback: `${PUSH_SERVER_ORIGIN}/callback/${key}`}}) return route.fulfill({json: {key, callback: `${PUSH_SERVER_ORIGIN}/callback/${key}`}})
@ -447,26 +384,20 @@ export const mockPushServer = (context: BrowserContext) =>
return route.fallback() return route.fallback()
}) })
// One record straight off the hosting api, whose shapes live in src/app/hosting.ts. They're left // One record off the hosting api, left loose so mocking a flow doesn't pull import.meta.env in.
// 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.
export type HostingRecord = Record<string, unknown> export type HostingRecord = Record<string, unknown>
// The backend's state when the page opens. Everything after that is what the app wrote, plus // The backend's state when the page opens.
// whatever the handle below changed.
export type HostingFixtures = { export type HostingFixtures = {
plans?: HostingRecord[] plans?: HostingRecord[]
// Provisioning runs on every login and ignores what it gets back, so the empty default carries // Provisioning runs on every login and ignores what it gets back.
// any spec that isn't actually exercising the hosting ui.
tenant?: HostingRecord tenant?: HostingRecord
relays?: HostingRecord[] relays?: HostingRecord[]
invoices?: HostingRecord[] invoices?: HostingRecord[]
draftInvoice?: HostingRecord draftInvoice?: HostingRecord
} }
// The backend changing its mind between two of the user's clicks, such as a custom domain that // The backend changing its mind between two of the user's clicks, which no click can reach.
// verifies or an invoice that gets paid. It is the half of those flows no click can reach.
export type HostingHandle = { export type HostingHandle = {
setTenant(patch: HostingRecord): void setTenant(patch: HostingRecord): void
setRelay(id: string, patch: HostingRecord): void setRelay(id: string, patch: HostingRecord): void
@ -477,12 +408,7 @@ const hostingStore = makeContextStore<HostingHandle>("mockHosting")
export const getHosting = (context: BrowserContext) => hostingStore.get(context) export const getHosting = (context: BrowserContext) => hostingStore.get(context)
/** /** The hosting api as a small stateful fake. Each browser context gets its own store. */
* 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.
*/
export const mockHosting = async (context: BrowserContext, fixtures: HostingFixtures = {}) => { export const mockHosting = async (context: BrowserContext, fixtures: HostingFixtures = {}) => {
const plans = fixtures.plans ?? [] const plans = fixtures.plans ?? []
const relays = new Map((fixtures.relays ?? []).map(relay => [String(relay.id), relay])) 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: "",
custom_domain_verified: 0, custom_domain_verified: 0,
...body, ...body,
// A created space is opened straight away, so its url has to be one the container // A created space is opened straight away, so its url has to be one the container serves.
// serves. The client names the domain from VITE_HOSTING_RELAY_DOMAIN.
zooid_domain: body.zooid_domain || "test", 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 ?? []}}}) return route.fulfill({json: {data: {activity: relay.activity ?? []}}})
} }
// The dump is whatever the fixture left on the record, served as the download itself rather // The dump is served as the download itself rather than in a json envelope.
// than in a json envelope.
if (sub === "export") { if (sub === "export") {
return route.fulfill({ return route.fulfill({
contentType: "application/x-ndjson", 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 // The fake counts the lines and refuses the ones that don't look signed, as zooid reports them.
// ones that don't look signed, which is the shape zooid reports problems in.
if (sub === "import") { if (sub === "import") {
const lines = (request.postData() ?? "").split("\n").filter(line => line.trim()) const lines = (request.postData() ?? "").split("\n").filter(line => line.trim())
const problems = lines.flatMap((line, index) => 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}`}}}) 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 // GET and reconcile both answer with the invoice as it stands, which is how a spec marks one paid.
// one paid. Flip `paid_at` with the handle and let the dialog's next poll find it.
return route.fulfill({json: {data: invoice}}) return route.fulfill({json: {data: invoice}})
} }
@ -671,8 +593,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt
} }
export type LivekitOptions = { export type LivekitOptions = {
// Where the client is told to connect. Point it at something the test owns, since the token this // Point it at something the test owns, since the token this hands out is accepted by nothing else.
// hands out is accepted by nothing else.
serverUrl: string serverUrl: string
token?: string token?: string
} }
@ -694,10 +615,7 @@ export const mockLivekit = (context: BrowserContext, {serverUrl, token}: Livekit
}, },
) )
/** /** Serves a png for anything loaded as an image, so a fixture's avatar and blob urls are never fetched. */
* 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.
*/
export const mockImages = (context: BrowserContext) => export const mockImages = (context: BrowserContext) =>
context.route( context.route(
url => !isDevServerUrl(url), url => !isDevServerUrl(url),

View file

@ -32,8 +32,7 @@ type Traffic = {
const trafficStore = makeContextStore<Traffic>("installWebSocketRoutes") const trafficStore = makeContextStore<Traffic>("installWebSocketRoutes")
// A relay that holds nothing. REQs get an immediate EOSE and events are accepted and dropped, so a // A relay that holds nothing, so a leak fails on the assertion naming it rather than on a timeout.
// leak fails on the assertion that names it rather than on a timeout three layers away.
const openEmptyRelay = (): RelayConnection => { const openEmptyRelay = (): RelayConnection => {
let listener: (message: RelayMessage) => void = () => undefined 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 // A relay that takes the socket and says nothing, so the only way out is the caller's own deadline.
// 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.
const openSilentRelay = (): RelayConnection => ({ const openSilentRelay = (): RelayConnection => ({
onMessage() {}, onMessage() {},
send() {}, send() {},
@ -100,15 +97,7 @@ const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => {
route.onClose(() => connection.close()) route.onClose(() => connection.close())
} }
/** /** The single interception point for relay traffic. On the context, so it covers a page opened later. */
* 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.
*/
export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => { export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => {
const traffic = trafficStore.set(context, { const traffic = trafficStore.set(context, {
transcript: [], transcript: [],
@ -125,8 +114,7 @@ export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) =>
export const getTranscript = (context: BrowserContext) => trafficStore.get(context).transcript 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 // Every event this context put on the wire, oldest first. One event on three relays is three entries.
// published to three relays is three entries.
export const getPublished = (context: BrowserContext): PublishedEvent[] => export const getPublished = (context: BrowserContext): PublishedEvent[] =>
getTranscript(context) getTranscript(context)
.filter( .filter(
@ -134,28 +122,21 @@ export const getPublished = (context: BrowserContext): PublishedEvent[] =>
) )
.map(({url, message}) => ({url, event: message[1] as TrustedEvent})) .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 // The same, narrowed to one kind, for asking what the client published rather than what it rendered.
// what it rendered.
export const getPublishedEvents = (context: BrowserContext, kind: number) => export const getPublishedEvents = (context: BrowserContext, kind: number) =>
getPublished(context) getPublished(context)
.filter(({event}) => event.kind === kind) .filter(({event}) => event.kind === kind)
.map(({event}) => event) .map(({event}) => event)
// Makes a relay answer like one that never held anything, without its url becoming a leak. `serve` // `serve` resolves a relay once, at open, so a socket already open keeps its relay until a reload.
// 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.
export const forgetRelay = (context: BrowserContext, url: string) => export const forgetRelay = (context: BrowserContext, url: string) =>
trafficStore.get(context).forgotten.add(normalizeRelayUrl(url)) 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 // Resolved at open, like forgetRelay, so a page that boots into the fault passes `silent` to `as`.
// 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.
export const silenceRelay = (context: BrowserContext, url: string) => export const silenceRelay = (context: BrowserContext, url: string) =>
trafficStore.get(context).silenced.add(normalizeRelayUrl(url)) 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 // Every frame in both directions, oldest first. Attach it to a failing test.
// actually said, and to whom.
export const formatTranscript = (context: BrowserContext) => export const formatTranscript = (context: BrowserContext) =>
getTranscript(context) getTranscript(context)
.map( .map(

View file

@ -8,11 +8,7 @@ import type {TestUser} from "../keys"
import {makePublisher} from "./publish" import {makePublisher} from "./publish"
import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish" import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} from "./publish"
/** /** One public relay's fixtures. Unlike a space it holds what a client reaches for by pubkey rather than by space. */
* 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.
*/
export type SeededOpenRelay = { export type SeededOpenRelay = {
readonly name: OpenRelayName readonly name: OpenRelayName
// Known before seeding runs, since a relay list has to name the relay a note is seeded on. // 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[] readonly relayLists: SeededEvent[]
note(user: TestUser, content: string, createdAt?: number): SeededEvent note(user: TestUser, content: string, createdAt?: number): SeededEvent
profile(user: TestUser, values: ProfileValues, 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 // A relay a scenario expects the client to read from has to appear in the reader's own list.
// appear in the reader's own list: zooid answers no REQ without nip-42, and Flotilla only
// identifies to relays that list names.
relayList(user: TestUser, urls: RelayListUrls, createdAt?: number): SeededEvent relayList(user: TestUser, urls: RelayListUrls, createdAt?: number): SeededEvent
follows(user: TestUser, follows: TestUser[], createdAt?: number): SeededEvent follows(user: TestUser, follows: TestUser[], createdAt?: number): SeededEvent
event(user: TestUser, template: SeededTemplate, 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 // This relay's domain kinds, bound to a resolver that answers with its url.
// space's `kind()` does.
kind<R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>( kind<R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>(
factory: KindFactory<R, W, Q>, factory: KindFactory<R, W, Q>,
): ConfiguredKind<R, W, Q> ): ConfiguredKind<R, W, Q>

View file

@ -5,20 +5,16 @@ import type {TestUser} from "../keys"
// A queued write, drained in declaration order by `seed` in scenario.ts. // A queued write, drained in declaration order by `seed` in scenario.ts.
export type Enqueue = (write: () => Promise<void>) => void export type Enqueue = (write: () => Promise<void>) => void
// A handle to an event the scenario is going to publish. Seeding calls record what to write and // Seeding calls record what to write and return before anything is written.
// return before anything is written, so the event is filled in when its turn in the queue comes up.
export type SeededEvent = { export type SeededEvent = {
readonly event: SignedEvent readonly event: SignedEvent
readonly id: string readonly id: string
} }
// An event to seed, either already rendered or built when its turn comes up. A domain writer needs // A domain writer needs a relay url to resolve its hints against, so anything it builds is deferred.
// 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.
export type SeededTemplate = StampedEvent | (() => MaybeAsync<EventTemplate>) export type SeededTemplate = StampedEvent | (() => MaybeAsync<EventTemplate>)
// A nip-65 relay list, as the two sets a client reads off it: `write` is what an outbox-routed load // `write` is what an outbox-routed load resolves to, `read` is what a feed asks for that pubkey.
// for this pubkey resolves to, `read` is what a feed asks for that pubkey's context.
export type RelayListUrls = { export type RelayListUrls = {
read?: string[] read?: string[]
write?: 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. // The relay these events are seeded into, for the error a read-too-early raises.
name: string name: string
enqueue: Enqueue enqueue: Enqueue
// The moment the scenario began. A fixture declared without a timestamp is stamped with it // The moment the scenario began. A fixture with no timestamp is stamped with it, not the wall clock.
// rather than with the wall clock, so two fixtures describing the same thing cannot land
// seconds apart and decide which of them wins.
startedAt: number startedAt: number
// How an event this process signs reaches the relay, deferred because a space's relay handle // Deferred, since a space's relay handle only exists once the queue has started draining.
// only exists once the queue has started draining.
sign: (user: TestUser, template: StampedEvent) => Promise<SignedEvent> sign: (user: TestUser, template: StampedEvent) => Promise<SignedEvent>
} }
// The queue every seeding call goes through: what to write is recorded now and published when // What to write is recorded now and published when `seed()` drains.
// `seed()` drains, and what it produced only reads back after that.
export const makePublisher = ({name, enqueue, startedAt, sign}: PublisherOptions) => { export const makePublisher = ({name, enqueue, startedAt, sign}: PublisherOptions) => {
// Queues a write and hands back a getter for whatever it produced. // Queues a write and hands back a getter for whatever it produced.
const seeded = <T>(write: () => Promise<T>) => { const seeded = <T>(write: () => Promise<T>) => {

View file

@ -11,16 +11,13 @@ import type {SeededSpace} from "./space"
import {seedOpenRelay} from "./openRelay" import {seedOpenRelay} from "./openRelay"
import type {SeededOpenRelay} from "./openRelay" import type {SeededOpenRelay} from "./openRelay"
// A fixture timestamp, as an offset from the moment the scenario started. `at(2, HOUR)` is two // A fixture timestamp as an offset from the scenario's start. `at(2, HOUR)` is two hours before it.
// hours before the test began, count-first like int and ago.
export type At = (count: number, unit: number) => number export type At = (count: number, unit: number) => number
export type SeedTools = { export type SeedTools = {
// Names a space the container already serves. Its policy is its toml in zooid/docker/config. // Names a space the container already serves. Its policy is its toml in zooid/docker/config.
relay: (name: SpaceName) => SeededSpace relay: (name: SpaceName) => SeededSpace
// Names one of the public relays, which is where the follow graph lives: `indexer` is what a // Names one of the public relays, where the follow graph lives. See ARCHITECTURE.md, "The follow graph".
// pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See
// ARCHITECTURE.md, "The follow graph".
open: (name: OpenRelayName) => SeededOpenRelay open: (name: OpenRelayName) => SeededOpenRelay
user: typeof users user: typeof users
at: At at: At
@ -31,14 +28,11 @@ export type Scenario = {
readonly at: At readonly at: At
// The spaces this scenario seeded, which are the relays the app is handed as its own. // The spaces this scenario seeded, which are the relays the app is handed as its own.
readonly urls: string[] readonly urls: string[]
// What a pubkey's own lists are resolved from: the open indexer when a scenario declared one, // The open indexer when a scenario declared one, and the spaces otherwise.
// and the spaces otherwise, which is what a scenario that knows nothing about open relays gets.
readonly indexerUrls: string[] readonly indexerUrls: string[]
space(name: SpaceName): SeededSpace space(name: SpaceName): SeededSpace
open(name: OpenRelayName): SeededOpenRelay open(name: OpenRelayName): SeededOpenRelay
// The events a returning user's client would already have on disk: their room list, and their // A relay won't serve the list that would tell authPolicy it may identify to it.
// 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".
cache(user: TestUser): SignedEvent[] cache(user: TestUser): SignedEvent[]
} }
@ -74,16 +68,14 @@ export const seed = async (
await build({relay, open, user: users, at}) await build({relay, open, user: users, at})
// Seeding is async and fixtures depend on one another, so the builder only records what to // Fixtures depend on one another, so the builder only records what to write and this drains it.
// write. Draining the queue here publishes each fixture in the order it was declared.
for (const write of writes) { for (const write of writes) {
await write() await write()
} }
const seeded = Array.from(spaces.values()) const seeded = Array.from(spaces.values())
// A room list is replaceable and covers every space at once, so it can only be written after // A room list is replaceable and covers every space, so it is written after all of them are seeded.
// all of them have been seeded.
const membershipsByPubkey = new Map<string, {user: TestUser; urls: string[]; tags: string[][]}>() const membershipsByPubkey = new Map<string, {user: TestUser; urls: string[]; tags: string[][]}>()
for (const space of seeded) { for (const space of seeded) {

View file

@ -23,20 +23,16 @@ import type {TestUser} from "../keys"
import {makePublisher} from "./publish" import {makePublisher} from "./publish"
import type {Enqueue, ProfileValues, RelayListUrls, SeededEvent, SeededTemplate} 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, // @welshman/domain has no writer for NIP-29 kind-9 messages, so this pairs the base writer and reader.
// 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.
class MessageWriter extends EventWriter<BaseEventReader> {} class MessageWriter extends EventWriter<BaseEventReader> {}
// The kind-14 a direct message really is. It is never published, since each participant gets it // The kind-14 a direct message really is. It is never published, since each participant gets a wrap.
// inside a gift wrap, so this is what a spec asserts on.
export type SeededRumor = { export type SeededRumor = {
readonly rumor: HashedEvent readonly rumor: HashedEvent
readonly id: string readonly id: string
} }
// A user's membership as their own client sees it, which the scenario turns into one room list // A user's membership as their own client sees it, turned into a room list once every space is seeded.
// per user once every space has been seeded.
export type SeededMembership = { export type SeededMembership = {
user: TestUser user: TestUser
rooms: string[] rooms: string[]
@ -48,26 +44,19 @@ export type SeededSpace = {
readonly memberships: SeededMembership[] readonly memberships: SeededMembership[]
room(h: string, options?: RoomOptions): void room(h: string, options?: RoomOptions): void
member(user: TestUser, h?: string): 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 // Relay and room membership, plus a place in the user's own room list.
// joined this space through the ui ends up with.
join(user: TestUser, ...rooms: string[]): void join(user: TestUser, ...rooms: string[]): void
message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent
reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent
profile(user: TestUser, values: ProfileValues, 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 // Outbox routing resolves everything about a person through their relay list.
// 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.
relayList(user: TestUser, urls?: RelayListUrls, createdAt?: number): SeededEvent 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 // Having one is what makes a person reachable, so messaging being off is a person without one.
// is what makes a person reachable, so a story about messaging being off is a person without one.
messagingRelayList(user: TestUser, urls?: string[], createdAt?: number): SeededEvent messagingRelayList(user: TestUser, urls?: string[], createdAt?: number): SeededEvent
event(user: TestUser, template: SeededTemplate, 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 // One kind-14 rumor, gift-wrapped once per participant including the sender.
// sender, whose own copy is the half of the thread their client reads back.
dm(from: TestUser, to: TestUser[], content: string, createdAt?: number): SeededRumor 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 // This space's domain kinds, bound to a resolver that answers with its url.
// 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())`.
kind<R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>( kind<R extends BaseEventReader, W extends EventWriter<R>, Q extends EventQuery>(
factory: KindFactory<R, W, Q>, factory: KindFactory<R, W, Q>,
): ConfiguredKind<R, W, Q> ): ConfiguredKind<R, W, Q>
@ -76,16 +65,14 @@ export type SeededSpace = {
export type SeedSpaceOptions = { export type SeedSpaceOptions = {
zooid: Zooid zooid: Zooid
enqueue: Enqueue enqueue: Enqueue
// The moment the scenario began. A fixture declared without a timestamp is stamped with it // A fixture declared without a timestamp is stamped with the scenario's start, not the wall clock.
// rather than with the wall clock.
startedAt: number startedAt: number
name: SpaceName name: SpaceName
} }
export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => { export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => {
const memberships: SeededMembership[] = [] const memberships: SeededMembership[] = []
// Known before seeding runs, unlike the relay handle below, so a fixture on another relay can // Known before seeding runs, unlike the relay handle below, so another relay's fixture can name it.
// name this one.
const url = tenantUrl(name) const url = tenantUrl(name)
let testRelay: Maybe<TestRelay> let testRelay: Maybe<TestRelay>
@ -138,9 +125,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
const message = (user: TestUser, h: string, content: string, createdAt = startedAt) => const message = (user: TestUser, h: string, content: string, createdAt = startedAt) =>
publish(() => relay().message(user, h, content, createdAt)) 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.svelte renders a quote from the nostr uri rather than the q tag, as prependParent does.
// content rather than from the q tag, so the uri is prepended as prependParent does in
// src/app/rooms.ts.
const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) => const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) =>
event( event(
user, user,
@ -188,9 +173,7 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions):
const messagingRelayList = (user: TestUser, urls = [url], createdAt = startedAt) => const messagingRelayList = (user: TestUser, urls = [url], createdAt = startedAt) =>
event(user, () => kind(MessagingRelayList).writer().setUrls(urls).renderTemplate(), createdAt) 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 // A gift wrap's author is an ephemeral key nobody here can authenticate as, so the sender sends it.
// 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.
const dm = (from: TestUser, to: TestUser[], content: string, createdAt = startedAt) => { const dm = (from: TestUser, to: TestUser[], content: string, createdAt = startedAt) => {
const rumor = seeded(async () => { const rumor = seeded(async () => {
const writer = kind(DirectMessage).writer().setContent(content) const writer = kind(DirectMessage).writer().setContent(content)

View file

@ -1,48 +1,34 @@
import {expect} from "@playwright/test" import {expect} from "@playwright/test"
import type {Locator, Page} 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. */
* 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.
*/
export const dialog = (page: Page, title: string) => export const dialog = (page: Page, title: string) =>
page.getByRole("dialog", {name: title, exact: true}) 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 // The modal on top, for one with no heading of its own or one pushed over another.
// alongside it.
export const topDialog = (page: Page) => page.getByRole("dialog").last() 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 // A modal is mounted alongside the page it covers, so both forms are in the dom at once.
// 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.
export const modalForm = (page: Page, title: string) => export const modalForm = (page: Page, title: string) =>
page.locator("form").filter({has: page.getByRole("heading", {name: title})}) 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 // Named rather than counted, since a card is free to put a join of its own above it.
// of its own above it — a calendar event's rsvp buttons are one.
export const emojiButton = (scope: Locator) => scope.getByRole("button", {name: "Add a reaction"}) 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 // The menu is the one action with no accessible name, and the last thing in a card's last join.
// has.
export const menuButton = (scope: Locator) => scope.locator(".join").getByRole("button").last() 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 // The picker is a web component with an open shadow root, and searching avoids its category tabs.
// reachable through it. Searching rather than browsing avoids depending on which category tab an
// emoji happens to live under.
export const pickEmoji = async (page: Page, opener: Locator, annotation: string) => { export const pickEmoji = async (page: Page, opener: Locator, annotation: string) => {
await opener.click() await opener.click()
const picker = page.locator("emoji-picker").filter({visible: true}) 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 // Tippy keeps a hidden popover mounted through its fade, so the last card's picker is still up.
// 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.
await expect(picker).toHaveCount(1) await expect(picker).toHaveCount(1)
// A result's label is the emoji's name, its annotation and every shortcode joined together, so // A result's label joins the emoji's name, its annotation and every shortcode.
// the annotation is matched rather than the whole of it.
await picker.locator("input.search").fill(annotation) await picker.locator("input.search").fill(annotation)
await picker await picker
.getByRole("option", {name: new RegExp(annotation)}) .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. // 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") export const toast = (page: Page) => page.getByRole("alert")
// FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row, with the // FieldInline, RoomDetail and EventInfo all lay a labelled control out as a single row.
// label at one end and the control at the other.
export const settingRow = (page: Page, label: string) => export const settingRow = (page: Page, label: string) =>
page.locator("div.items-center.justify-between").filter({hasText: label}) 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]") 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 // Only the conversation composer has a disabled state; a room's is usable as soon as it renders.
// 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.
export const composerEnabled = (page: Page) => export const composerEnabled = (page: Page) =>
expect(page.locator(".room__compose .chat-editor")).toHaveAttribute("aria-disabled", "false") 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") 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 // .room__content is column-reverse, so the message at the bottom of the room is first in the dom.
// the dom.
export const messages = (page: Page) => page.locator(".room__item") export const messages = (page: Page) => page.locator(".room__item")
export const message = (page: Page, text: string) => messages(page).filter({hasText: text}) 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) => export const bubble = (page: Page, text: string) =>
page.locator(".chat-bubble").filter({hasText: text}) page.locator(".chat-bubble").filter({hasText: text})
// Typing into a room's composer and sending it. The composer is waited for rather than assumed, // Clicking a composer that already holds the caret would collapse the selection an edit types over.
// 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.
export const send = async (page: Page, content: string) => { export const send = async (page: Page, content: string) => {
const editor = composer(page) 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. // One conversation in the sidebar list is one button; nothing inside it is one.
export const chatItems = (page: Page) => chatList(page).locator("button") 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 // The comment and thread composers are rich text rather than chat editors.
// different one.
export const noteEditor = (scope: Locator | Page) => export const noteEditor = (scope: Locator | Page) =>
scope.locator(".note-editor [contenteditable=true]") 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 // A date as the browser formatted it. The options mirror dateFormatter in @welshman/lib.
// the ones the app rendered with. The options mirror dateFormatter in @welshman/lib.
export const longDate = (page: Page, seconds: number) => export const longDate = (page: Page, seconds: number) =>
page.evaluate( page.evaluate(
ts => ts =>

View file

@ -1,32 +1,16 @@
/** // The virtual relays the container serves. .test is reserved by rfc 2606, so none of them resolve.
* 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 relays a space is seeded on: members-only, with nip-29 groups on, which is what a Flotilla // The relays a space is seeded on: members-only, with nip-29 groups on.
// space is.
export const spaceTenants = { export const spaceTenants = {
space: "space.test", space: "space.test",
other: "other.test", other: "other.test",
// Policy space.toml cannot express at the same time. `closed` refuses a join without an invite, // Policy space.toml cannot express at the same time: no invite, stripped signatures, delegated management.
// `unsigned` serves events with their signatures stripped, `delegated` gives every member one
// management method.
closed: "closed.test", closed: "closed.test",
unsigned: "unsigned.test", unsigned: "unsigned.test",
delegated: "delegated.test", delegated: "delegated.test",
} as const } as const
// Public relays with no groups, which is where anything outside a space lives: `indexer` is what a // Public relays with no groups, where anything outside a space lives. See ARCHITECTURE.md, "The follow graph".
// pubkey's own lists are resolved from, `outbox` is a followed pubkey's write relay. See
// ARCHITECTURE.md, "The follow graph".
export const openTenants = { export const openTenants = {
indexer: "indexer.test", indexer: "indexer.test",
outbox: "outbox.test", outbox: "outbox.test",

View file

@ -20,39 +20,27 @@ import type {PublishOptions} from "./types"
import {connectToZooid, port, requestZooid} from "./transport" import {connectToZooid, port, requestZooid} from "./transport"
import type {ZooidConnection} 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 // Pinned by digest so a spec runs against one zooid. ZOOID_IMAGE is how an unreleased build is tried.
// 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.
const image = const image =
process.env.ZOOID_IMAGE ?? process.env.ZOOID_IMAGE ??
"gitea.coracle.social/coracle/zooid:latest@sha256:7950843900cbb1c7c1f17de5524f6999badd1002cde94476855e90e67e0f2248" "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 // How long after a recreate chromium can still abort what a page has in flight.
// 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.
const SETTLE = 2500 const SETTLE = 2500
const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url)) const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url))
const configSource = fileURLToPath(new URL("docker/config", 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 // zooid saves a relay's toml back on a nip-86 edit, so it is handed a copy rather than the repo's.
// 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.
const configDir = join(tmpdir(), "flotilla-e2e-zooid-config") const configDir = join(tmpdir(), "flotilla-e2e-zooid-config")
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
// execFile does not go through a shell, so a `docker` that exists only as an alias or a function is // execFile does not go through a shell, so a docker that exists only as an alias needs E2E_DOCKER.
// invisible to it however well it works when typed. Name the executable to drive with E2E_DOCKER in
// that case.
const dockerCommand = process.env.E2E_DOCKER ?? "docker" const dockerCommand = process.env.E2E_DOCKER ?? "docker"
// Whether something already answers on the port the container publishes, asked before this run // Whether something already answers on the port the container publishes.
// brings its own up.
const isRelayPortTaken = () => const isRelayPortTaken = () =>
new Promise<boolean>(resolve => { new Promise<boolean>(resolve => {
const socket = createConnection({port, host: "127.0.0.1"}) const socket = createConnection({port, host: "127.0.0.1"})
@ -69,9 +57,7 @@ const isRelayPortTaken = () =>
socket.once("error", () => resolve(false)) socket.once("error", () => resolve(false))
}) })
// Every invocation carries the config directory and the image, `down` included. The compose file // The compose file names both without a default, so a call that left one out fails to interpolate.
// 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.
const docker = (...args: string[]) => const docker = (...args: string[]) =>
execFileAsync(dockerCommand, args, { execFileAsync(dockerCommand, args, {
env: {...process.env, ZOOID_CONFIG: configDir, ZOOID_IMAGE: image}, 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) 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 // Why the container cannot be driven, or undefined when it can.
// PATH and a daemon that is not running need different answers, so they are reported apart rather
// than as one boolean.
const probeDocker = async () => { const probeDocker = async () => {
try { try {
await docker("compose", "version") 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 // How far the container's clock is from this host's, in seconds. Positive means the container is behind.
// 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.
const getClockDrift = async (host: string) => { const getClockDrift = async (host: string) => {
const {headers} = await requestZooid(host, "GET", "/", {accept: "application/nostr+json"}) 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 // nip-42 accepts an auth event within ten minutes of the relay's own clock (nip42.go).
// 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.
const describeClockDrift = (drift: number) => { const describeClockDrift = (drift: number) => {
const [minutes, direction] = const [minutes, direction] =
drift > 0 ? [drift / 60, "behind"] : [Math.abs(drift) / 60, "ahead of"] drift > 0 ? [drift / 60, "behind"] : [Math.abs(drift) / 60, "ahead of"]
@ -151,8 +131,7 @@ const getTestUser = (pubkey: string) => {
let problem: Maybe<Promise<Maybe<string>>> let problem: Maybe<Promise<Maybe<string>>>
// Asked once a worker, since docker does not come and go mid-run. The answer is also written to the // Also written to the terminal, since a skip reason otherwise reaches only the html report.
// terminal, because a skip reason otherwise reaches only the html report.
export const describeDockerProblem = () => export const describeDockerProblem = () =>
(problem ??= probeDocker().then(reason => { (problem ??= probeDocker().then(reason => {
if (reason) { if (reason) {
@ -164,14 +143,9 @@ export const describeDockerProblem = () =>
return reason return reason
})) }))
/** /** The relay every test runs against. One container serves a virtual relay per entry in `tenants`. */
* 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.
*/
export class Zooid { export class Zooid {
// Every socket into the container, the client's and seeding's alike, is one this process opened, // Every socket into the container is one this process opened, so this map also defines a url that is not a leak.
// so this map is also the definition of a url that is not a leak.
relays = new Map( relays = new Map(
tenantNames.map(name => [ tenantNames.map(name => [
tenantUrl(name), tenantUrl(name),
@ -189,8 +163,7 @@ export class Zooid {
// When the last recreate's interface churn stops being able to abort a request. // When the last recreate's interface churn stops being able to abort a request.
private settledAt = 0 private settledAt = 0
// Verifies docker rather than bringing the container up, which `reset` does. Repeat calls are // Verifies docker rather than bringing the container up, which reset does. Repeat calls are free.
// free, so the fixture can call this per test.
start = async () => { start = async () => {
if (this.started) { if (this.started) {
return return
@ -246,11 +219,7 @@ export class Zooid {
} }
} }
// Leaves a fresh container for whoever runs next. Recreating one churns the host's network // Recreating churns the host's network interfaces, and chromium aborts what it has in flight.
// 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.
reset = async () => { reset = async () => {
this.closeSessions() 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 // As late as the wait can be left: teardown, the next test's fixtures and its seeding are inside it.
// test's fixtures and its seeding are all inside the window already, so most tests pay nothing
// here.
settle = async () => { settle = async () => {
const remaining = this.settledAt - Date.now() 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 // A pull of a pinned reference can only produce the relay this suite was written against.
// was written against, and a machine that already has it never gets here.
private fetchImage = async () => { private fetchImage = async () => {
console.warn(`\nFetching the zooid image this suite is pinned to:\n ${image}\n`) 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 // Compose reports only that a container exited, so without this a startup failure has no reason.
// without this a startup failure is a status code and no reason.
private logs = () => private logs = () =>
compose("logs", "--no-color", "--tail", "50").then( compose("logs", "--no-color", "--tail", "50").then(
({stdout, stderr}) => [stdout, stderr].filter(Boolean).join("\n").trim(), ({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) 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 // Storage is tmpfs with no volume mounted for it, so a new container is a new database.
// recreating is what makes this a reset.
private up = async () => { private up = async () => {
// A fresh copy on every recreate is what restores relay metadata a test edited through nip-86. // A fresh copy restores metadata a test edited through nip-86, and the container runs as uid 65532.
// 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.
await rm(configDir, {recursive: true, force: true}) await rm(configDir, {recursive: true, force: true})
await cp(configSource, configDir, {recursive: true}) await cp(configSource, configDir, {recursive: true})
await chmod(configDir, 0o777) await chmod(configDir, 0o777)
@ -348,8 +309,7 @@ export class Zooid {
const deadline = Date.now() + ms(30) const deadline = Date.now() + ms(30)
while (Date.now() < deadline) { while (Date.now() < deadline) {
// A 404 means the dispatcher has no relay bound to that host yet, so the configs are still // A 404 means the dispatcher has no relay bound to that host yet, so the configs are still loading.
// loading. Anything a relay itself answers means every tenant is ready.
const isUp = await Promise.all( const isUp = await Promise.all(
tenantNames.map(name => tenantNames.map(name =>
requestZooid(tenants[name], "GET", "/", {accept: "application/nostr+json"}).then( 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 // NIP-42 binds an identity to a connection, and khatru rebuilds the signed url from transport.ts's headers.
// url signed into the auth event is the one the client uses, since that is what khatru rebuilds
// from the headers transport.ts sends.
private authenticate = async (host: string, user: TestUser) => { private authenticate = async (host: string, user: TestUser) => {
const key = `${host} ${user.pubkey}` const key = `${host} ${user.pubkey}`
const session = this.sessions.get(key) 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 summary = `Failed to authenticate as ${user.name} on ${host}: ${detail}`
const drift = await getClockDrift(host).catch(() => undefined) const drift = await getClockDrift(host).catch(() => undefined)
// Nearly always the clock rather than the key. Every identity here is one zooid's toml names, // Nearly always the clock: the signature is checked after the timestamp the relay compares against.
// and the signature is checked after the timestamp the relay compares against its own.
if (drift !== undefined && Math.abs(drift) > int(10, MINUTE)) { if (drift !== undefined && Math.abs(drift) > int(10, MINUTE)) {
throw new Error(`${summary}\n\n${describeClockDrift(drift)}`) throw new Error(`${summary}\n\n${describeClockDrift(drift)}`)
} }
@ -412,8 +369,7 @@ export class Zooid {
throw new Error(summary) throw new Error(summary)
} }
// Both halves of seeding, the auth that opens a connection and every event written over it, are // Both the auth that opens a connection and every event written over it are answered with an OK.
// answered with an OK naming the event that was sent.
private send = async (connection: ZooidConnection, message: ClientMessage, id: string) => { private send = async (connection: ZooidConnection, message: ClientMessage, id: string) => {
connection.send(message) connection.send(message)

View file

@ -17,11 +17,7 @@ export type TestRelayOptions = {
publish: (event: SignedEvent, options?: PublishOptions) => Promise<void> publish: (event: SignedEvent, options?: PublishOptions) => Promise<void>
} }
// Seeding signs the events a real client would send and publishes them over a socket. Room // admin is the only test identity the tomls grant can_manage, and every timestamp is the caller's.
// 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.
export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay => { export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay => {
const event = async (user: TestUser, template: StampedEvent) => { const event = async (user: TestUser, template: StampedEvent) => {
const signed = await user.signer.sign(template) const signed = await user.signer.sign(template)
@ -37,8 +33,7 @@ export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay
event, event,
publish, publish,
room: async (h, meta, createdAt) => { room: async (h, meta, createdAt) => {
// A relay stamps the metadata it derives from these ops with the op's own created_at, so // A relay stamps derived metadata with the op's created_at, so creation must be older than an edit.
// creation has to be strictly older than the edit or the edit is dropped as stale.
await event( await event(
users.admin, users.admin,
makeEvent(ROOM_CREATE, {tags: [["h", h]], created_at: createdAt - 1}), makeEvent(ROOM_CREATE, {tags: [["h", h]], created_at: createdAt - 1}),

View file

@ -4,21 +4,7 @@ import {parseJson} from "@welshman/lib"
import type {ClientMessage, RelayMessage} from "@welshman/net" import type {ClientMessage, RelayMessage} from "@welshman/net"
import type {RelayConnection} from "./types" import type {RelayConnection} from "./types"
/** /** The only place that knows the container has a loopback address. A relay selection drops a local url. */
* 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://<tenant>.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).
*/
// Must match the published port in zooid/docker/compose.yaml. // Must match the published port in zooid/docker/compose.yaml.
export const port = 3334 export const port = 3334
@ -26,13 +12,11 @@ export const port = 3334
const headersFor = (host: string) => ({Host: host, "X-Forwarded-Proto": "https"}) const headersFor = (host: string) => ({Host: host, "X-Forwarded-Proto": "https"})
export type ZooidConnection = RelayConnection & { export type ZooidConnection = RelayConnection & {
// Resolves with the first message the relay sends that matches. Seeding waits for its auth // Resolves with the first message the relay sends that matches.
// challenge and for the OK answering each write this way.
wait(match: (message: RelayMessage) => boolean): Promise<RelayMessage> wait(match: (message: RelayMessage) => boolean): Promise<RelayMessage>
} }
// One connection to the container, as one of its virtual relays. Node's own WebSocket cannot carry // Node's own WebSocket cannot carry a Host header, which fetch forbids, so this depends on `ws`.
// a Host header, which fetch forbids, so the harness depends on `ws` for this alone.
export const connectToZooid = (host: string): ZooidConnection => { export const connectToZooid = (host: string): ZooidConnection => {
const socket = new WebSocket(`ws://127.0.0.1:${port}/`, {headers: headersFor(host)}) const socket = new WebSocket(`ws://127.0.0.1:${port}/`, {headers: headersFor(host)})
const listeners = new Set<(message: RelayMessage) => void>() 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 // A 404 from zooid's dispatcher means `tenants` and the tenant toml's `host` have drifted apart.
// and the `host` in the tenant's toml have drifted apart.
socket.on("error", error => socket.on("error", error =>
console.error(`The zooid container refused a socket for ${host}: ${error.message}`), console.error(`The zooid container refused a socket for ${host}: ${error.message}`),
) )
@ -62,8 +45,7 @@ export const connectToZooid = (host: string): ZooidConnection => {
onMessage: listener => { onMessage: listener => {
listeners.add(listener) listeners.add(listener)
}, },
// A client may speak before the container has finished its handshake. The page's socket is open // A client may speak before the container has finished its handshake.
// as soon as playwright has a route for it, well before this one is.
send: message => { send: message => {
if (socket.readyState === WebSocket.OPEN) { if (socket.readyState === WebSocket.OPEN) {
write(message) write(message)
@ -92,8 +74,7 @@ export type ZooidResponse = {
body: Buffer body: Buffer
} }
// One http request to the container, carrying the same two headers, so the nip-11 document and the // Carries the same two headers, so the nip-11 and nip-86 answers the browser reads are the relay's.
// nip-86 management api the browser reads are the real relay's.
export const requestZooid = ( export const requestZooid = (
host: string, host: string,
method: string, method: string,
@ -109,8 +90,7 @@ export const requestZooid = (
host: "127.0.0.1", host: "127.0.0.1",
port, port,
headers: { headers: {
// A pseudo-header describes the request line rather than the request, and node:http // A pseudo-header describes the request line, and node:http rejects a header name with a colon in it.
// rejects a header name with a colon in it.
...Object.fromEntries( ...Object.fromEntries(
Object.entries(requestHeaders).filter(([name]) => !name.startsWith(":")), Object.entries(requestHeaders).filter(([name]) => !name.startsWith(":")),
), ),
@ -122,8 +102,7 @@ export const requestZooid = (
const responseHeaders: Record<string, string> = {} const responseHeaders: Record<string, string> = {}
for (const [name, value] of Object.entries(response.headers)) { for (const [name, value] of Object.entries(response.headers)) {
// Hop-by-hop headers describe the connection this answer arrived on, not the one the // Hop-by-hop headers describe the connection this answer arrived on, not the browser's.
// browser is waiting on, which playwright frames itself.
if (value && !["connection", "keep-alive", "transfer-encoding"].includes(name)) { if (value && !["connection", "keep-alive", "transfer-encoding"].includes(name)) {
responseHeaders[name] = Array.isArray(value) ? value.join(", ") : value responseHeaders[name] = Array.isArray(value) ? value.join(", ") : value
} }

View file

@ -3,9 +3,7 @@ import type {ClientMessage, RelayMessage} from "@welshman/net"
import type {TestUser} from "../keys" import type {TestUser} from "../keys"
export type PublishOptions = { export type PublishOptions = {
// Which identity the seeding connection authenticates as. Defaults to the event's own author, // Which identity the seeding connection authenticates as, defaulting 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.
as?: TestUser as?: TestUser
} }
@ -17,9 +15,7 @@ export type RoomOptions = {
private?: boolean private?: boolean
} }
// A handle to one relay, plus the seeding affordances scenarios build on. Every seeding call // Every seeding call publishes over a real socket, so the relay stores what it would for a client.
// publishes over a real socket rather than inserting into storage, so the relay stores exactly what
// it would have stored for a real client.
export type TestRelay = { export type TestRelay = {
readonly name: string readonly name: string
readonly url: string readonly url: string
@ -29,13 +25,11 @@ export type TestRelay = {
member(user: TestUser, h: string | undefined, createdAt: number): Promise<void> member(user: TestUser, h: string | undefined, createdAt: number): Promise<void>
// Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs. // Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs.
event(user: TestUser, event: StampedEvent): Promise<SignedEvent> event(user: TestUser, event: StampedEvent): Promise<SignedEvent>
// An event this process did not sign, sent over `as`'s connection. A gift wrap, whose author is // An event this process did not sign, sent over `as`'s connection, which a gift wrap needs.
// the ephemeral key that wrapped it, is the case that needs this.
publish(event: SignedEvent, options: PublishOptions): Promise<void> publish(event: SignedEvent, options: PublishOptions): Promise<void>
} }
// One client's connection to a relay. Every connection to a url reaches the same container, so one // Every connection to a url reaches the same container, so one context observes another's writes.
// browser context observes another's writes over the wire.
export type RelayConnection = { export type RelayConnection = {
onMessage(listener: (message: RelayMessage) => void): void onMessage(listener: (message: RelayMessage) => void): void
send(message: ClientMessage): void send(message: ClientMessage): void

View file

@ -19,9 +19,7 @@ import {
const ICON = gifFile("icon.gif") const ICON = gifFile("icon.gif")
// The plans the hosting backend offers. `free` is what RelayForm starts on, and `basic` is the paid // `free` is what RelayForm starts on. PricingTable names a plan by its member limit.
// 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.
const PLANS = [ const PLANS = [
{id: "free", name: "Free", amount: 0, hidden: false, members: 50, blossom: false, livekit: false}, {id: "free", name: "Free", amount: 0, hidden: false, members: 50, blossom: false, livekit: false},
{ {
@ -29,7 +27,7 @@ const PLANS = [
name: "Basic", name: "Basic",
amount: 500, amount: 500,
hidden: false, 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 // eslint-disable-next-line no-restricted-syntax
members: null, members: null,
blossom: true, blossom: true,
@ -37,8 +35,7 @@ const PLANS = [
}, },
] ]
// One relay as the hosting api serves it. Every field RelayDetailCard reads is present, since it // Every field RelayDetailCard reads is present, since it calls .trim() and .replace() on several.
// calls .trim() and .replace() on several of them.
const hostedRelay = (overrides: Record<string, unknown> = {}) => ({ const hostedRelay = (overrides: Record<string, unknown> = {}) => ({
id: "relay-1", id: "relay-1",
tenant_pubkey: users.alice.pubkey, tenant_pubkey: users.alice.pubkey,
@ -65,26 +62,18 @@ const hostedRelay = (overrides: Record<string, unknown> = {}) => ({
...overrides, ...overrides,
}) })
// Tippy mounts a menu the first time it is opened and leaves it in the dom when it hides, so a page // Tippy leaves a menu in the dom when it hides. Exact, since "Edit role" and "Edit roles" differ.
// 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.
const menuItem = (page: Page, name: string) => const menuItem = (page: Page, name: string) =>
page.getByRole("button", {name, exact: true}).filter({visible: true}) 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 // That button is labeled with the space's name and its host, and only the host survives a rename.
// the secondary nav. 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() 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 // The card's aria-label is baked at first render, so the card is found by the name it shows.
// 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.
const memberCard = (page: Page, name: string) => const memberCard = (page: Page, name: string) =>
page.locator(".card-interactive").filter({hasText: name}) page.locator(".card-interactive").filter({hasText: name})
// Exact, because several permission names are a substring of another — "Ban members" of "Unban // Exact, because several permission names are a substring of another: "Ban members" of "Unban members".
// members".
const permission = (scope: Locator, name: string) => const permission = (scope: Locator, name: string) =>
scope.getByRole("checkbox", {name, exact: true}) scope.getByRole("checkbox", {name, exact: true})
@ -93,8 +82,7 @@ const openEventMenu = (card: Locator) => menuButton(card).click()
const articleCard = (page: Page, title: string) => const articleCard = (page: Page, title: string) =>
page.locator('[data-component="ArticleItem"]').filter({hasText: title}) 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 // A FieldInline puts its control in the div immediately after its label.
// the hosting card is told apart from the others in the same grid.
const setting = (page: Page, label: string) => const setting = (page: Page, label: string) =>
page.locator("label").filter({hasText: label}).locator("xpath=following-sibling::div") 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() 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 upload's file input is hidden inside its own label, so the chooser opens by clicking it.
// the label rather than by writing to the input. Picking a file dismisses the picker.
const picker = dialog(admin, "Add an image") const picker = dialog(admin, "Add an image")
const chooser = admin.waitForEvent("filechooser") 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.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle"}) 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 // Somebody who belongs to a different space, so adding her here is a change to this one.
// member list rather than something the scenario already seeded.
other.room("lounge", {name: "Lounge"}) other.room("lounge", {name: "Lounge"})
other.join(user.admin, "lounge") other.join(user.admin, "lounge")
other.join(newcomer, "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") const other = scenario.space("other")
// Arriving through the other space is what loads Nadia's profile: the invite dialog's search // The invite dialog's search reads the profiles this client already holds.
// reads the profiles this client already holds.
const page = await as(users.admin, roomPath(other.url, "lounge"), { const page = await as(users.admin, roomPath(other.url, "lounge"), {
context: {permissions: ["clipboard-read", "clipboard-write"]}, 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") const space = relay("space")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
// A room the relay will not admit anyone to on their own say-so, so a join request stays // A room the relay will not admit anyone to on their own say-so, so a join request stays pending.
// pending instead of being granted the moment it lands.
space.room("vault", {name: "Vault", closed: true, private: true}) space.room("vault", {name: "Vault", closed: true, private: true})
space.join(user.admin, "general", "vault") space.join(user.admin, "general", "vault")
space.join(user.alice, "general") space.join(user.alice, "general")
@ -740,8 +724,7 @@ test("US-130 share out admin permissions", async ({seed, as}) => {
const admins = dialog(admin, "Admins") const admins = dialog(admin, "Admins")
// The owner holds every method implicitly, so the relay leaves them out of listmethodassignees // 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.
await expect(admins.locator(".badge").filter({hasText: "Owner"})).toBeVisible() await expect(admins.locator(".badge").filter({hasText: "Owner"})).toBeVisible()
await expect(admins.getByText("Nobody else has been given management permissions.")).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", { const page = await as(users.alice, "/settings/hosting", {
hosting: {plans: PLANS}, hosting: {plans: PLANS},
// A space created here is opened at wss://<subdomain>.<domain>/, so the domain has to be one // A space created here is opened at wss://<subdomain>.<domain>/, which has to name a tenant.
// the container serves and the subdomain has to name one of its tenants.
env: {VITE_HOSTING_RELAY_DOMAIN: "test"}, 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.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
// Where the space ends up once the domain verifies, so the client has somewhere real to follow // Where the space ends up once the domain verifies.
// it to.
other.room("general", {name: "General"}) other.room("general", {name: "General"})
other.join(user.alice, "general") other.join(user.alice, "general")
}) })
@ -1062,9 +1043,7 @@ test("US-102 pause a relay and settle the bill", async ({seed, as}) => {
hosting: { hosting: {
plans: PLANS, plans: PLANS,
relays: [hostedRelay({plan_id: "basic"})], relays: [hostedRelay({plan_id: "basic"})],
// Declared oldest first, which is not the order payment history reads in. Invoice's // Declared oldest first, which is not the order payment history reads in. null is "not yet".
// paid_at/voided_at/method are number | null / InvoiceMethod | null upstream, so null is
// how the hosting API spells "hasn't happened yet".
/* eslint-disable no-restricted-syntax */ /* eslint-disable no-restricted-syntax */
invoices: [ 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 history = page.locator(".card").filter({hasText: "Payment History"}).first()
const invoices = history.getByRole("listitem") const invoices = history.getByRole("listitem")
// The whole period, not just its start: the two invoices meet at a month boundary, so either // The two invoices meet at a month boundary, so either date on its own reads the same on both.
// 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.
const period = ({start, end}: {start: number; end: number}) => const period = ({start, end}: {start: number; end: number}) =>
page.evaluate( page.evaluate(
([from, to]) => ([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() 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 // delegated.test grants every member one management method, so `supportedmethods` differs by user.
// 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.
const openDelegatedMenu = (page: Page) => const openDelegatedMenu = (page: Page) =>
page.getByRole("button", {name: /delegated\.test/}).click() page.getByRole("button", {name: /delegated\.test/}).click()

View file

@ -26,13 +26,11 @@ type Seeded = {readonly id: string; readonly event: SignedEvent}
const PARTY = "🎉" const PARTY = "🎉"
// The comment and thread-reply composers are the only forms on their pages carrying a rich text // The comment and thread-reply composers are the only forms on their pages with a rich text editor.
// editor.
const composerForm = (page: Page) => const composerForm = (page: Page) =>
page.locator("form").filter({has: page.locator(".note-editor")}) 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 // RoomCompose's join is the upload button and then the compose menu.
// a thread written from inside a room is started.
const openComposeMenu = (page: Page) => const openComposeMenu = (page: Page) =>
page page
.locator("form") .locator("form")
@ -56,36 +54,28 @@ const expectReactionRoundTrip = async (page: Page, scope: Locator, opener: Locat
await expect(pill).toHaveCount(0) 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 // Formatted by the browser rather than by node, so the locale and the timezone are the app's.
// 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.
const shortDate = (page: Page, seconds: number) => const shortDate = (page: Page, seconds: number) =>
page.evaluate( page.evaluate(
ts => new Intl.DateTimeFormat(undefined, {dateStyle: "short"}).format(new Date(ts * 1000)), ts => new Intl.DateTimeFormat(undefined, {dateStyle: "short"}).format(new Date(ts * 1000)),
seconds, seconds,
) )
// The card is a div carrying an overlay link, so it is found by its component rather than by a // The card is a div carrying an overlay link, so it is found by its component rather than a role.
// role — its own contents include a profile button and the room and action links.
const articleCards = (page: Page) => page.locator('[data-component="ArticleItem"]') const articleCards = (page: Page) => page.locator('[data-component="ArticleItem"]')
// A comment is a flat block in the tree rather than a card, so it carries a component marker for // A comment is a flat block rather than a card, and the marker sits on its own row, not its replies.
// the specs to name; the marker sits on the comment's own row, not on its replies.
const comment = (page: Page, text: string) => const comment = (page: Page, text: string) =>
page.locator('[data-component="Comment"]').filter({hasText: text}) 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 // Clicked near its top-left: a card whose footer wraps puts an interactive row under the centre.
// 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.
const openArticle = async (page: Page, title: string) => { const openArticle = async (page: Page, title: string) => {
await articleCards(page) await articleCards(page)
.filter({hasText: title}) .filter({hasText: title})
.getByRole("link", {name: title, exact: true}) .getByRole("link", {name: title, exact: true})
.click({position: {x: 20, y: 20}}) .click({position: {x: 20, y: 20}})
// The card's own action bar carries a data-component the article page carries too, and the list // The card's own action bar carries the same data-component, and the list is still on screen.
// 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.
await expect(page.locator("article header").getByRole("heading", {name: title})).toBeVisible() 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("A short teaser about gardens.")
await expect(garden).toContainText(await shortDate(page, at(4, HOUR))) 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 // 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.
const winter = articleCards(page).filter({hasText: "Winter Reading"}) const winter = articleCards(page).filter({hasText: "Winter Reading"})
const winterBox = (await winter.boundingBox())! const winterBox = (await winter.boundingBox())!
const winterActions = (await winter.locator('[data-component="ArticleActions"]').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 noteEditor(composerForm(bob)).pressSequentially("The soil chapter is the good one.")
await composerForm(bob).getByRole("button", {name: "Comment"}).click() await composerForm(bob).getByRole("button", {name: "Comment"}).click()
// The comment renders from the optimistic write, but the composer holds what was typed until the // The composer holds what was typed until the relay confirms it, so the page carries this text twice.
// relay confirms it, so for a moment the page carries this text twice. Match the rendered comment.
await expect(comment(bob, "The soil chapter is the good one.")).toBeVisible() 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 // A comment on a room event is a room event, so without the room tag the relay sees no group.
// 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.
await expect await expect
.poll(() => .poll(() =>
getPublishedEvents(bob.context(), COMMENT).map(event => tagValue(tagSpec("h"), event.tags)), 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 composer.locator('[data-tip="Add an image"]').click()
await (await chooser).setFiles({name: "bed.gif", mimeType: "image/gif", buffer: GIF}) 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 // The attachment carries an uploading marker until the blossom descriptor replaces its blob url.
// blossom descriptor comes back and replaces its blob url.
await expect(composer.locator(".tiptap-object")).not.toHaveClass(/tiptap-uploading/) await expect(composer.locator(".tiptap-object")).not.toHaveClass(/tiptap-uploading/)
await composer.getByRole("button", {name: "Comment"}).click() 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.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"}) 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 // 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.
space.relayList(user.alice) space.relayList(user.alice)
const article = space.event( 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() 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 // The room's copy is published after the composer has moved on, and a page that unloads takes it.
// after the composer has moved on, so wait for it to leave — a page that unloads mid-publish
// takes it with it.
await expect.poll(() => getPublishedEvents(page.context(), MESSAGE)).toHaveLength(1) await expect.poll(() => getPublishedEvents(page.context(), MESSAGE)).toHaveLength(1)
await page.goto(roomPath(url, "lounge")) 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") 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 // A card says which room an article was posted in; the article's own page carries it in the page bar.
// page bar instead.
const roomLink = pageBar(page).getByRole("link", {name: /#\s*Lounge/}) const roomLink = pageBar(page).getByRole("link", {name: /#\s*Lounge/})
await expect(roomLink).toBeVisible() 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.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"}) space.profile(user.bob, {name: "Bob Barker"})
// An older topic with replies, so the board's reply count and last-post time are statements // An older topic with replies, so the reply count and last-post time are about a thread.
// about the thread rather than about an empty row.
const topic = space.event( const topic = space.event(
user.bob, user.bob,
() => () =>
@ -670,8 +650,7 @@ test("US-042 start a thread and see it filed under its room", async ({seed, as})
.locator("section") .locator("section")
.filter({has: page.getByRole("heading", {name: "General", exact: true})}) .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 // Each board creates its own threads, so the room comes from the button that was clicked.
// than from a picker.
await general.getByRole("button", {name: "Create", exact: true}).click() await general.getByRole("button", {name: "Create", exact: true}).click()
const fromThreads = modalForm(page, "Create a Thread") 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), at(3, HOUR),
) )
// Twenty replies is exactly the window a thread opens with, and the last of them is alice's, // 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.
for (let i = 1; i <= 20; i++) { for (let i = 1; i <= 20; i++) {
space.event( space.event(
i === 20 ? user.alice : i % 2 === 0 ? user.carol : user.bob, 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"]) .toEqual(["lounge"])
// His is the twenty first reply, so the oldest one drops out of the window, and the opening // His is the twenty first reply, so the oldest one drops out of the window.
// post stays above whatever the window holds.
const showEarlier = bob.getByRole("button", {name: "Show earlier replies"}) const showEarlier = bob.getByRole("button", {name: "Show earlier replies"})
await expect(showEarlier).toBeVisible() 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"}) 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 // Every assertion below is about which of the replies are in the window, so wait for all of them.
// have all arrived.
await expect(bob.getByText("41 replies")).toBeVisible() await expect(bob.getByText("41 replies")).toBeVisible()
// The thread opens on its newest twenty replies, with the opening post above them. // 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 composer = modalForm(page, "Create a Thread")
const nevent = nip19.neventEncode({id: promoted.id, kind: MESSAGE, relays: [url]}) 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 seeded entity is parsed, which is also what makes the thread carry a q tag for the message.
// the raw uri — which is also what makes the thread carry a q tag for the message.
await expect(noteEditor(composer)).toContainText(`${nevent.slice(0, 16)}...`) await expect(noteEditor(composer)).toContainText(`${nevent.slice(0, 16)}...`)
await composer.getByPlaceholder("What is this thread about?").fill("Deploy failures") await composer.getByPlaceholder("What is this thread about?").fill("Deploy failures")

View file

@ -37,7 +37,6 @@ test("authenticates over nip-42 before a members-only relay serves anything", as
pubkey: users.alice.pubkey, pubkey: users.alice.pubkey,
}) })
// The whole point of intercepting the transport rather than swapping in an adapter: until the // Until the client proves who it is, the relay hands it nothing at all.
// client proves who it is, the relay hands it nothing at all.
expect(firstEventAt).toBeGreaterThan(authenticatedAt) expect(firstEventAt).toBeGreaterThan(authenticatedAt)
}) })

View file

@ -47,22 +47,17 @@ import type {TestUser} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue. // A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string; readonly event: SignedEvent} 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 // A shelf is a card and its menu button side by side, so the menu is reached through their wrapper.
// the two share.
const shelfCard = (page: Page, title: string) => const shelfCard = (page: Page, title: string) =>
page.getByRole("button", {name: new RegExp(title)}).locator("xpath=..") 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 // Button swallows both the default and the propagation of a click, and the middle of a card is one.
// 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.
const openCard = (card: Locator, title: string) => card.getByText(title).click() 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 // PollOption renders an option as a small card carrying its label, its count and its progress bar.
// its progress bar.
const pollOption = (page: Page, label: string) => page.locator(".card-sm").filter({hasText: label}) 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 // A multiple choice vote goes out once the delay window closes, so "both publish" is visible here.
// vote goes out once the delay window closes, so this is where "both publish" is visible.
const pollResponses = (page: Page, pollId: string) => const pollResponses = (page: Page, pollId: string) =>
getTranscript(page.context()) getTranscript(page.context())
.filter( .filter(
@ -104,9 +99,7 @@ test("US-046 create and browse a calendar event", async ({seed, as}) => {
at(1, HOUR), at(1, HOUR),
) )
// Ten events that have already happened, so the list is taller than the viewport and "opens // Ten events that have already happened, so the list is taller than the viewport.
// scrolled to the next upcoming event" is a statement about where it sits rather than about a
// list that fits on one screen anyway.
for (let i = 1; i <= 10; i++) { for (let i = 1; i <= 10; i++) {
addEvent(`Past Meetup ${String(i).padStart(2, "0")}`, at((11 - i) * 4, DAY)) 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) await expect(cards).toHaveCount(12)
// The calendar opens on the first event that hasn't happened yet, with the oldest one scrolled // The calendar opens on the first event that hasn't happened yet.
// out of the way above it.
await expect(card("Autumn Fair")).toBeInViewport() await expect(card("Autumn Fair")).toBeInViewport()
await expect(card("Past Meetup 01")).not.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") const composer = modalForm(page, "Create an Event")
// Field renders its label and its input as siblings rather than wiring them together, so the // Field renders its label and input as siblings, so the writable fields are taken in document order.
// 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.
const textInputs = composer.locator('input[type="text"]:not([readonly])') const textInputs = composer.locator('input[type="text"]:not([readonly])')
const title = textInputs.first() const title = textInputs.first()
const location = textInputs.last() 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 composer.getByRole("button", {name: "Save Event"}).click()
await expect(page.getByRole("alert")).toContainText("Please provide start and end times.") 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 picker takes two clicks for a range, each at the time its own time input carries.
// the time its own time input carries — noon and one, for a range that starts empty.
const target = new Date() const target = new Date()
target.setMonth(target.getMonth() + 1, 15) 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") 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 hero card is always the first feature card on the page; the About tab renders one below it.
// the About tab renders its own feature card below it for the description.
const eventCard = page.locator(".card.z-feature").first() const eventCard = page.locator(".card.z-feature").first()
await expect(page.getByRole("heading", {name: "Harvest Supper", exact: true})).toBeVisible() 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() await confirmDelete.click()
// The badge is an optimistic local write, so it says nothing about the relay. The confirmation // The badge is an optimistic local write, and the confirmation stays up until the retraction is out.
// stays up until the retraction has been published, which is what makes it safe to reload.
await expect(eventCard.getByText("Deleted", {exact: true})).toBeVisible() await expect(eventCard.getByText("Deleted", {exact: true})).toBeVisible()
await expect(confirmDelete).toHaveCount(0) 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.alice, {name: "Alice Anderson"})
space.profile(user.bob, {name: "Bob Barker"}) space.profile(user.bob, {name: "Bob Barker"})
// The multiple choice half of the story is about voting rather than about creating, and a // A wire assertion needs option ids known up front, so these two are fixtures.
// wire assertion needs option ids known up front, so these two are fixtures.
snacks = space.event( snacks = space.event(
user.alice, 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}) => { 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 // A receipt is only counted once the lnurl endpoint answers with the zapper that signed it.
// receipt is only counted once that endpoint answers with the zapper that signed it.
const lud16 = "bob@zap.test" const lud16 = "bob@zap.test"
const lnurl = getLnUrl(lud16)! const lnurl = getLnUrl(lud16)!
// A zap receipt is signed by the recipient's lightning provider rather than by either party to // A zap receipt is signed by the recipient's lightning provider rather than by either party.
// the zap, so the provider is an identity of its own — and one zooid will take a write from.
const provider = makeTestUser("zapper") const provider = makeTestUser("zapper")
let running!: Seeded 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(), 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 // Two and a half days old, so how long it has been running rounds to three whenever this renders.
// of the run this renders on.
running = space.event( running = space.event(
user.bob, user.bob,
() => () =>
@ -513,10 +496,7 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) =
at(30, HOUR), at(30, HOUR),
) )
// What a wallet publishes once an invoice is paid: the payer's own zap request carried as the // A receipt whose invoice disagrees with the amount its request asked for is thrown away.
// 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.
const contribute = (from: TestUser, sats: number, createdAt: number) => const contribute = (from: TestUser, sats: number, createdAt: number) =>
space.event( space.event(
provider, provider,
@ -533,8 +513,7 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) =
created_at: createdAt, created_at: createdAt,
}) })
// What was paid is read straight out of the invoice's human-readable part, where an `n` // What was paid is read out of the invoice's human-readable part, where an `n` is a tenth of a sat.
// is a tenth of a sat, so that prefix is all of a bolt11 that has to be real.
return space return space
.kind(ZapReceipt) .kind(ZapReceipt)
.writer() .writer()
@ -614,8 +593,7 @@ test("US-050 create a funding goal and track its progress", async ({seed, as}) =
"3", "3",
) )
// Registered after the page was opened, so it answers ahead of the empty dufflepud `as()` // Registered after the page opened, so it answers ahead of the empty dufflepud `as()` installs.
// installs, and before the navigation below, since a zapper is looked up once per page load.
await mockDufflepud(page.context(), { await mockDufflepud(page.context(), {
zappers: [ zappers: [
{ {
@ -773,8 +751,7 @@ test("US-052 comment on and react to community posts", async ({seed, as}) => {
at(3, HOUR), at(3, HOUR),
) )
// Three comments already there, so bob's fills the four the page shows without asking and // Three comments already there, so bob's fills the four the page shows and alice's tips it past.
// alice's tips it past them.
for (let i = 1; i <= 3; i++) { for (let i = 1; i <= 3; i++) {
space.event( space.event(
user.carol, 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() 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 // The library is the whole space's, so an ordinary member is offered the same controls.
// shelves were made with.
await expect(alice.getByRole("button", {name: "Create Shelf"})).toBeVisible() await expect(alice.getByRole("button", {name: "Create Shelf"})).toBeVisible()
await expect(alice.getByRole("button", {name: "Add a link"})).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) 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 // A room item is itself a role=button wrapping its content, so the quote is the inner of the two.
// itself a role=button tap target wrapping its content, so the quote is the inner of the two.
const quote = page.getByRole("button").filter({hasText: "Pizza or tacos?"}).last() const quote = page.getByRole("button").filter({hasText: "Pizza or tacos?"}).last()
await expect(quote).toBeVisible() await expect(quote).toBeVisible()

View file

@ -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']") 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 // The reply preview and the editing indicator are the same bordered strip, each with its own X.
// indicator — are the same bordered strip, each with a single button in it: its X.
const banner = (page: Page, text: string) => const banner = (page: Page, text: string) =>
page.locator(".room__compose .border-l-2").filter({hasText: text}) page.locator(".room__compose .border-l-2").filter({hasText: text})
// Both of these are DataTransfer dispatches rather than anything native: prosemirror reads the file // prosemirror reads the file off the event, and playwright's dispatchEvent drops the dataTransfer.
// straight off the event, and playwright's own dispatchEvent builds a plain Event, which would drop
// the dataTransfer the handler needs.
const dropImage = (editor: Locator, name: string) => const dropImage = (editor: Locator, name: string) =>
editor.evaluate( editor.evaluate(
(node, {name, data}) => { (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.relayList(user.bob)
space.message(user.bob, "general", "morning all", at(2, HOUR)) 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 // Someone matching the same term from a different space, to rank below this space's own members.
// dropdown has a non-member to rank below this space's own members.
other.room("lounge", {name: "Lounge"}) other.room("lounge", {name: "Lounge"})
other.join(user.alice, "lounge") other.join(user.alice, "lounge")
other.join(outsider, "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 space = scenario.space("space")
const other = scenario.space("other") const other = scenario.space("other")
// Alice arrives through the other space so that the outsider's profile is in her client before // A profile reaches her by being rendered, and his never renders in the space she is typing in.
// she composes: a profile reaches her by being rendered, and his never renders in the space she
// is about to type in.
const page = await as(users.alice, roomPath(other.url, "lounge")) 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". // 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}), timeline(page).getByRole("button", {name: "Bobbin Amaranth", exact: true}),
).toBeVisible() ).toBeVisible()
// A space's nav item is labeled with the name its nip-11 document reports, and the two tenants // A space's nav item is labeled with the name its nip-11 document reports.
// report "space" and "other".
await page.locator('.primary-nav [data-tip="space"]').click() await page.locator('.primary-nav [data-tip="space"]').click()
await page.locator(".secondary-nav").getByRole("link", {name: "General"}).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)).toContainText("pasted.gif")
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0) await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
// The same thing in a conversation. Its composer stays disabled until the recipient's messaging // Its composer stays disabled until the recipient's messaging relays have been read.
// relays have been read, which is what waiting on the composer waits out.
await alice.goto(chatPath(users.bob.pubkey)) await alice.goto(chatPath(users.bob.pubkey))
await bob.goto(chatPath(users.alice.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") await expect(composer(alice)).toContainText("selfie.gif")
// The file node appears the moment it is attached, which is also the moment `uploading` goes // Submit returns without a word while `uploading` is true, and encryption makes that window wide.
// 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.
await expect(sendButton(alice)).toBeEnabled() await expect(sendButton(alice)).toBeEnabled()
await composer(alice).press("Enter") await composer(alice).press("Enter")
// A conversation's image is uploaded encrypted, so the recipient fetches the ciphertext and // A conversation's image is uploaded encrypted, so the recipient decrypts it into a blob url.
// decrypts it into a blob url rather than pointing an <img> at the server.
await expect(bob.locator('.chat-bubble img[src^="blob:"]')).toBeVisible() 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 // Back to the room on a fresh page, so no toast the conversation raised is still standing.
// one asserted below can only be the rejected upload's own.
await alice.goto(path) await alice.goto(path)
await expect(timeline(alice).getByText("morning all")).toBeVisible() 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.join(user.bob, "general")
space.message(user.bob, "general", "morning all", at(2, HOUR)) 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 kind-10050 lets the Messages nav navigate; his enables the conversation's composer.
// her to enable chat; bob's is what enables the conversation's composer.
for (const person of [user.alice, user.bob]) { for (const person of [user.alice, user.bob]) {
space.profile(person, {name: person.name}) space.profile(person, {name: person.name})
space.relayList(person) 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 // Drafts live in memory, so every move here is an in-app navigation.
// whether or not they were kept.
const page = await as(users.alice, chatPath(users.bob.pubkey)) const page = await as(users.alice, chatPath(users.bob.pubkey))
const editor = composer(page) const editor = composer(page)
const rooms = page.locator(".secondary-nav") 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") await expect(editor).toHaveText("half a thought")
// The composer is remounted around a restored draft, so put the caret in it before sending // The composer is remounted around a restored draft, so put the caret in it before sending.
// rather than typing into whatever had focus when the room came back.
await editor.click() await editor.click()
await editor.press("Enter") 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 alice = await as(users.alice, roomPath(url, "general"))
const transcription = await mockOpenRouterTranscription(alice.context(), "the tide turns at six") 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 // Recording asks for nothing. Asking for a transcript with no key saved asks for one.
// prompt reading a message out loud uses.
const action = await record(alice) const action = await record(alice)
await action.getByRole("button", {name: "Transcribe it"}).click() 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") 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 // OpenRouter picks its decoder off the extension, so the upload has to name the format.
// under a name that names the format.
expect(transcription.uploads).toEqual([expect.stringMatching(/^dictation\.\w+$/)]) expect(transcription.uploads).toEqual([expect.stringMatching(/^dictation\.\w+$/)])
await composer(alice).press("Enter") await composer(alice).press("Enter")
await expect(timeline(alice)).toContainText("the tide turns at six") 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 // A transcription outlives the composer that asked for it, and waits for whichever comes next.
// left while the request is still out, and the transcript waits for whichever composer is next.
transcription.hold() transcription.hold()
await (await record(alice)).getByRole("button", {name: "Transcribe it"}).click() 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 // A dictation is held by the app rather than by the composer, so reloading the page is losing it.
// that started one, so reloading the page is losing it rather than leaving it.
await roomLink(alice, "Lounge").click() await roomLink(alice, "Lounge").click()
await expect(composer(alice)).toBeVisible() 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}) await mockBlossom(alice.context(), {server: DEFAULT_BLOSSOM_ORIGIN})
// Leaving the question unanswered throws the recording away, so nothing is uploaded and the // Leaving the question unanswered throws the recording away.
// composer is where it was.
await (await record(alice)).getByRole("button", {name: "Discard"}).click() await (await record(alice)).getByRole("button", {name: "Discard"}).click()
await expect(dialog(alice, "Transcribe or send?")).toHaveCount(0) 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") await composer(alice).press("Enter")
// The imeta on the message says the upload is audio, which is what gives it a player rather // The imeta on the message says the upload is audio, which gives it a player rather than a link.
// than a link.
await expect(timeline(alice).locator(`audio[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible() await expect(timeline(alice).locator(`audio[src^="${DEFAULT_BLOSSOM_ORIGIN}/"]`)).toBeVisible()
}) })

View file

@ -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. // A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string} type Seeded = {readonly id: string}
// A bolt11 invoice as @welshman/content recognizes one: only behind a `lightning:` scheme, and // @welshman/content takes an invoice only behind a `lightning:` scheme, and renders what follows.
// what the chip renders and copies is what follows the scheme.
const INVOICE = const INVOICE =
"lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq" + "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfq" +
"ypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3" + "ypqdq5xysxxatsyp3k7enxv4jsxqzpuaztrnwngzn3kdzw5hydlzf03qdgm2hdq27cqv3" +
"agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9" "agm2awhz5se903vruatfhq77w3ls4evs3ch9zw97j25emudupq63nyw24cg27h2rspfj9"
// An event id no fixture publishes. A quote card is in its loading state until the quoted event // The relay is a container on loopback, so only a quote that never arrives holds the loading state.
// 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.
const UNKNOWN_EVENT_ID = "6f1ac4b0d2e37f5981c6ab4e2d0937fc85be1a2d3c4f5061728394a5b6c7d8e9" const UNKNOWN_EVENT_ID = "6f1ac4b0d2e37f5981c6ab4e2d0937fc85be1a2d3c4f5061728394a5b6c7d8e9"
// A cashu token keeps its own scheme in the value, and needs fifty-odd payload characters after // A cashu token keeps its own scheme in the value, and needs fifty-odd payload characters after it.
// it before the parser will take it.
const CASHU = const CASHU =
"cashu:cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJpZCI6IjAwOWExZjI5MzI1M2U0MWUiLCJhbW91bnQiOjIs" + "cashu:cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJpZCI6IjAwOWExZjI5MzI1M2U0MWUiLCJhbW91bnQiOjIs" +
"InNlY3JldCI6ImFhYSIsIkMiOiJiYmIifV0sIm1pbnQiOiJodHRwczovL21pbnQudGVzdCJ9XX0=" "InNlY3JldCI6ImFhYSIsIkMiOiJiYmIifV0sIm1pbnQiOiJodHRwczovL21pbnQudGVzdCJ9XX0="
@ -52,8 +48,7 @@ test("US-060 reveal a flagged sensitive message", async ({seed, as}) => {
const {url} = scenario.space("space") const {url} = scenario.space("space")
const page = await as(users.alice, roomPath(url, "general")) 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 // Scoped to bob's own message, so this is a claim about it rather than about the room.
// than about the room as a whole.
const message = page.locator(`[data-event="${flagged.id}"]`) const message = page.locator(`[data-event="${flagged.id}"]`)
const warning = message.getByText('flagged by the author as "spoilers"') 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() await expect(page.getByText(paragraphs[11])).toBeVisible()
// The whole card is a link into the article, so expanding in place means the click never // The whole card is a link into the article, so expanding in place means the click never reached.
// reached it.
expect(page.url()).toBe(before) expect(page.url()).toBe(before)
}) })
@ -196,8 +190,7 @@ test("US-063 preview a shared link", async ({seed, as}) => {
const {url} = scenario.space("space") const {url} = scenario.space("space")
// The room is opened by hand below rather than by as(): a preview is fetched once per url and // A preview is fetched once per url and memoized for the life of the page.
// memoized for the life of the page, so the backend has to be standing before the first render.
const page = await as(users.alice, "/") const page = await as(users.alice, "/")
let servePreview = () => {} let servePreview = () => {}
@ -206,8 +199,7 @@ test("US-063 preview a shared link", async ({seed, as}) => {
servePreview = resolve servePreview = resolve
}) })
// Registered after as(), so it answers ahead of the harness's own dufflepud. Holding the one // Registered after as(), so it answers ahead of the harness's own dufflepud.
// preview open makes the loading state a fact rather than a race.
await page.context().route( await page.context().route(
requestUrl => requestUrl.pathname === "/link/preview", requestUrl => requestUrl.pathname === "/link/preview",
async route => { 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.getByText("Everything new in this release.")).toBeVisible()
await expect(card.locator(previewImage)).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 // The same url mid-sentence, now that the preview it would have shown is known to resolve.
// compact link, and none of the card.
await expect(inline.getByRole("link", {name: "example.test/announcement"})).toBeVisible() await expect(inline.getByRole("link", {name: "example.test/announcement"})).toBeVisible()
await expect(inline.getByText("Flotilla ships v1")).toHaveCount(0) await expect(inline.getByText("Flotilla ships v1")).toHaveCount(0)
await expect(inline.locator(previewImage)).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)) 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 // Carol says something in the room too, so her profile is loaded before her name has to render.
// render on a card quoting her thread.
space.message(user.carol, "general", "posted a roadmap", at(140, MINUTE)) space.message(user.carol, "general", "posted a roadmap", at(140, MINUTE))
const topic = space.event( 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}"]`) 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 // Asserted on the quote nothing can answer, since the thread resolves off a socket to loopback.
// 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.
const unresolvableMessage = page.locator(`[data-event="${unresolvable.id}"]`) const unresolvableMessage = page.locator(`[data-event="${unresolvable.id}"]`)
await expect(unresolvableMessage.getByText("Loading event...")).toBeVisible() 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() await expect(originalMessage).toBeInViewport()
// The quoted thread's card, once it has loaded: the profile circle beside the message carries // The profile circle beside the message carries the same border classes, so the title says which.
// the same border classes, so the title is what says which of them is meant.
const quotedThread = threadMessage.locator(".border.border-solid").filter({ const quotedThread = threadMessage.locator(".border.border-solid").filter({
hasText: "Roadmap for Q3", 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. // 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)) 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 // The shortcode's image comes off the message's own emoji tag.
// when someone picks a custom emoji.
tokens = space.event( tokens = space.event(
user.carol, user.carol,
makeEvent(MESSAGE, { 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.getByText("#nostr", {exact: true})).toHaveClass(/link-content/)
await expect(message.getByAltText(":partyparrot:")).toBeVisible() 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 // An img contributes no text, so with no emoji tag to resolve the shortcode is rendered verbatim.
// no emoji tag to resolve, the shortcode is rendered verbatim.
await expect(message.getByText(":partyparrot:")).toHaveCount(0) await expect(message.getByText(":partyparrot:")).toHaveCount(0)
const inlineCode = message.locator("code").filter({hasText: "npm install"}) 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!") await expect(toast).toContainText("Copied to clipboard!")
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(INVOICE) 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 // Both copies raise the same toast, so this one is dismissed rather than waited out.
// the second assertion would pass on the first toast.
await toast.getByRole("button").click() await toast.getByRole("button").click()
await expect(toast).toHaveCount(0) await expect(toast).toHaveCount(0)

View file

@ -34,15 +34,13 @@ import type {SeededSpace, TestUser} from "../harness"
const articlePath = (url: string, address: string) => const articlePath = (url: string, address: string) =>
`${spacePath(url)}/articles/${encodeURIComponent(address)}` `${spacePath(url)}/articles/${encodeURIComponent(address)}`
// Outbox routing resolves everything about a person through their relay list, so a person here is // Outbox routing resolves a person through their relay list, so a person here has a kind-10002.
// a membership, a profile and a kind-10002.
const seedPerson = (space: SeededSpace, user: TestUser, name: string) => { const seedPerson = (space: SeededSpace, user: TestUser, name: string) => {
space.profile(user, {name}) space.profile(user, {name})
space.relayList(user) space.relayList(user)
} }
// A tippy is appended to the layout's own target rather than beside its trigger, and it keeps its // A tippy is appended to the layout's own target and keeps its content mounted after it hides.
// content mounted after it hides, so the visible card there is the popover that was just opened.
const detail = (page: Page) => page.locator(".tippy-target .card").filter({visible: true}) 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. // 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")}) 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 // The editor takes focus itself once it has mounted, and typing before that puts the caret back.
// caret back at the start partway through the sentence.
await expect(noteEditor(form)).toBeFocused() await expect(noteEditor(form)).toBeFocused()
await noteEditor(form).pressSequentially(body) await noteEditor(form).pressSequentially(body)
await form.getByRole("button", {name: "Comment"}).click() 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 // The slider is initialised from the settings store on mount, so a fresh page says the client took it.
// 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.
const setSendDelay = async (page: Page, seconds: number) => { const setSendDelay = async (page: Page, seconds: number) => {
const slider = page.locator("input[type=range]") 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(message(alice, "wrong room, sorry")).toHaveCount(0)
await expect(toast(alice)).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 // Sent after the cancelled one and delayed by as long, so its window has been and gone.
// cancelled one would have left in has been and gone.
await send(alice, "still here") await send(alice, "still here")
await expect(message(bob, "still here")).toBeVisible() 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() 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 // That was her only message to him, so the conversation goes with it.
// the list with nothing left to name it by.
await expect(chatItems(alice)).toHaveCount(0) await expect(chatItems(alice)).toHaveCount(0)
await send(alice, "actually, hi") 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) 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 // Bob's client names both relays, but he is a member of one, so the other refuses a wrap for him.
// wrap addressed to him is stored by one and refused by the other.
space.messagingRelayList(user.bob, [space.url, other.url]) space.messagingRelayList(user.bob, [space.url, other.url])
space.message(user.bob, "general", "morning all", at(2, HOUR)) 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") const {url} = scenario.space("space")
// A room the relay never created, which is where a link to a room that has since been deleted // A room the relay never created, which is where a link to a room that has since been deleted lands.
// lands. Everything else about the space still works, so only this one publish is refused.
const ghost = roomPath(url, "archive") const ghost = roomPath(url, "archive")
const alice = await as(users.alice, ghost) 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() await expect(failure).toBeVisible()
// The text is still hers to see, and the publish is finished and was refused, so there is // The text is still hers to see, and the publish is finished and was refused.
// nothing left for bob to receive.
await expect(message(alice, "anyone here?")).toContainText("anyone here?") await expect(message(alice, "anyone here?")).toContainText("anyone here?")
await expect(message(bob, "anyone here?")).toHaveCount(0) 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 ghost = roomPath(url, "archive")
const alice = await as(users.alice, ghost) const alice = await as(users.alice, ghost)
// Opened now rather than once the room exists: seeding again below replaces the scenario, and a // Seeding again replaces the scenario, and a page opened from that one carries no room list.
// 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.
const bob = await as(users.bob, roomPath(url, "general")) 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 // A delay is what makes the retry's own "Sending..." a state rather than an instant.
// watches a retry the way the person who asked for it does.
await setSendDelay(alice, 5) await setSendDelay(alice, 5)
await alice.goto(ghost) await alice.goto(ghost)
@ -290,13 +277,11 @@ test("US-070 retry a failed relay", async ({seed, as}) => {
await expect(toast(alice)).toContainText("Sending...") await expect(toast(alice)).toContainText("Sending...")
// The room still does not exist, so this attempt is refused too: the toast goes without ever // The room still does not exist, so this attempt is refused too.
// having said the message was sent, and the message is still marked failed.
await expect(toast(alice)).toHaveCount(0) await expect(toast(alice)).toHaveCount(0)
await expect(failure).toBeVisible() await expect(failure).toBeVisible()
// The admin creates the room the message was addressed to. Seeding again is the only way the // Seeding again is the only way the relay changes its mind about something it has already refused.
// relay changes its mind about something it has already refused.
await seed(({relay}) => { await seed(({relay}) => {
relay("space").room("archive", {name: "Archive"}) 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 {url} = scenario.space("space")
const quiet = scenario.space("other").url 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 // A space whose relay this browser will not open a socket to, so a post is sent and nothing returns.
// unconfirmed looks like from the inside: it is sent, and nothing comes back.
const alice = await as(users.alice, `${spacePath(url)}/articles`, { const alice = await as(users.alice, `${spacePath(url)}/articles`, {
env: {VITE_BLOCKED_RELAYS: quiet}, env: {VITE_BLOCKED_RELAYS: quiet},
}) })
// A comment leaves after the send delay the way a chat message does, so the window in which its // A comment leaves after the send delay the way a chat message does.
// Cancel link is live is hers to set.
await setSendDelay(alice, 1) await setSendDelay(alice, 1)
await alice.goto(`${spacePath(url)}/articles`) 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 alice.goto(`${spacePath(quiet)}/articles`)
await writeArticle(alice, "Into the Void", "Nobody is listening.") 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 says the relay never answered.
// Nothing was refused, so there is no toast — the action bar under the article is what says the
// relay never answered.
const stuck = articleActions(alice) const stuck = articleActions(alice)
await expect(stuck.getByText("Failed to send!")).toBeVisible() 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: "Delete Article"}).click()
await alice.getByRole("button", {name: "Confirm"}).click() await alice.getByRole("button", {name: "Confirm"}).click()
// But this page is the article's own view, so it stays and is marked deleted rather than // This page is the article's own view, so it stays and is marked deleted rather than vanishing.
// vanishing, and the "Deleted" pill stands in place of the actions it offered before.
await expect(article.getByText("Deleted", {exact: true})).toBeVisible() await expect(article.getByText("Deleted", {exact: true})).toBeVisible()
await expect(article.getByRole("button", {name: "Add a reaction"})).toHaveCount(0) 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.alice)
space.messagingRelayList(user.bob) space.messagingRelayList(user.bob)
// Carol's client names a relay she does not belong to, so every part of a message to her is // Carol's client names a relay she does not belong to, so one of the two refuses each part.
// stored by one of her two relays and refused by the other.
space.messagingRelayList(user.carol, [space.url, other.url]) 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 composer(alice).pressSequentially("here is the harbour")
await chooseFile(alice, alice.locator("button[data-tip='Add an image']"), gifFile("harbour.gif")) 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 // The editor names the file the moment it is attached, and a submit while it uploads is dropped.
// upload is done — and a submit while it is still running is dropped on the floor.
await expect(composer(alice)).toContainText("harbour.gif") await expect(composer(alice)).toContainText("harbour.gif")
await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0) await expect(composer(alice).locator(".tiptap-uploading")).toHaveCount(0)
await expect(sendButton(alice)).toBeEnabled() await expect(sendButton(alice)).toBeEnabled()

View file

@ -26,11 +26,7 @@ import {
} from "../harness" } from "../harness"
import type {SeededRumor, SeededSpace, TestUser} from "../harness" import type {SeededRumor, SeededSpace, TestUser} from "../harness"
// Everything about a person is resolved through their relay list — their profile, their messaging // A person here is a membership, a profile and a relay list, since everything resolves through one.
// 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.
const seedPerson = (space: SeededSpace, user: TestUser, name: string, ...rooms: string[]) => { const seedPerson = (space: SeededSpace, user: TestUser, name: string, ...rooms: string[]) => {
space.join(user, ...rooms) space.join(user, ...rooms)
space.profile(user, {name}) 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. // ChatItem's unread mark is a bare dot with no text of its own.
const unreadDots = (scope: Locator) => scope.locator(".rounded-full.bg-primary") 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 // The only ghost circle button either the profile page or the profile modal renders.
// page or the profile modal renders.
const profileMenu = (scope: Page | Locator) => scope.locator("button.button-circle.button-ghost") const profileMenu = (scope: Page | Locator) => scope.locator("button.button-circle.button-ghost")
// A modal's body is the only scroll container carrying its title. // 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") .locator(".scroll-container")
.filter({has: page.getByRole("heading", {name: title, exact: true})}) .filter({has: page.getByRole("heading", {name: title, exact: true})})
// Both things the composer puts above itself — the message being replied to and the editing // The reply preview and the editing indicator are the same bordered strip.
// indicator — are the same bordered strip.
const composePreview = (page: Page) => page.locator(".room__compose .border-l-2") 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 // The same words are sent more than once in these stories, so only the id says which bubble is which.
// once in these stories, and only the id says which bubble is which.
const message = (page: Page, id: string) => page.locator(`[data-event="${id}"]`) const message = (page: Page, id: string) => page.locator(`[data-event="${id}"]`)
const enablePrompt = (page: Page) => page.getByRole("heading", {name: "Enable direct messaging?"}) 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 // The composer stays disabled until every recipient's messaging relays have been read.
// on it is part of sending rather than a wait for a wait's sake.
const sendDm = async (page: Page, content: string) => { const sendDm = async (page: Page, content: string) => {
await composerEnabled(page) await composerEnabled(page)
await send(page, content) await send(page, content)
} }
// A suggestion carries the pubkey it selects as its label, so the name is what gets typed and the // A suggestion carries the pubkey it selects as its label, so the pubkey identifies the row.
// pubkey is what identifies the row that comes back.
const startChat = async (page: Page, people: {term: string; user: TestUser}[]) => { const startChat = async (page: Page, people: {term: string; user: TestUser}[]) => {
await page.getByRole("button", {name: "Start New Chat"}).click() 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) { for (const {term, user} of people) {
const suggestion = page.locator(`.tiptap-suggestions__item[aria-label="${user.pubkey}"]`) 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 // The suggestion list is rebuilt off the term changing, so a term arriving at once is matched once.
// changing, so a term that arrives all at once is matched against whatever the profile search
// held at that instant and never again.
await page.getByPlaceholder("Search for profiles...").pressSequentially(term, {delay: 150}) await page.getByPlaceholder("Search for profiles...").pressSequentially(term, {delay: 150})
await expect(suggestion).toContainText(term) 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() 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 // A message's actions are a hover popover on a pointer device and named buttons on a touch one.
// buttons on a touch one, so a spec that wants to name them opens a touch context.
const openMessageMenu = (page: Page, id: string) => const openMessageMenu = (page: Page, id: string) =>
message(page, id).locator(".chat-bubble").click() 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 // Formatted by the browser rather than by node. The options mirror dateFormatter in @welshman/lib.
// locale and the timezone are the ones the app rendered with. The options mirror dateFormatter and
// dateTimeFormatter in @welshman/lib.
const dayLabel = (page: Page, seconds: number) => const dayLabel = (page: Page, seconds: number) =>
page.evaluate( page.evaluate(
ts => ts =>
@ -122,10 +108,7 @@ const stampLabel = (page: Page, seconds: number) =>
seconds, seconds,
) )
// What this user's client has written to disk, of one kind. Events reach indexeddb in three-second // Events reach indexeddb in three-second batches, so a spec that reloads has to read the cache first.
// 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.
const cachedContent = async (page: Page, pubkey: string, kind: number) => const cachedContent = async (page: Page, pubkey: string, kind: number) =>
(await readCachedEvents(page, pubkey)) (await readCachedEvents(page, pubkey))
.filter(event => event.kind === kind) .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.alice)
space.messagingRelayList(user.bob) space.messagingRelayList(user.bob)
// A conversation the three of them are already in. It puts bob's profile in her client before // A conversation the three of them are already in, which puts bob's profile in her client.
// she goes looking for him, without being the one-on-one she is about to start.
space.dm(user.carol, [user.alice, user.bob], "welcome aboard, both of you", at(3, HOUR)) space.dm(user.carol, [user.alice, user.bob], "welcome aboard, both of you", at(3, HOUR))
}) })
const page = await as(users.alice, "/chat") 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 // A group conversation's item in the list names only the first of its participants.
// 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.
await chatItems(page).filter({hasText: "welcome aboard"}).click() await chatItems(page).filter({hasText: "welcome aboard"}).click()
await expect(pageBar(page)).toContainText("Bob Barnacle") 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") const page = await as(users.alice, "/chat")
// Their names rather than their npubs is the client saying it has both profiles, which is what // Their names rather than their npubs is the client saying it has both profiles.
// the search she is about to type into is built from.
await expect(chatItems(page).filter({hasText: "just us two"})).toContainText("Bob Barnacle") await expect(chatItems(page).filter({hasText: "just us two"})).toContainText("Bob Barnacle")
await expect(chatItems(page).filter({hasText: "hello from carol"})).toContainText("Carol Cutter") 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)) 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 // A touch context, which puts a message's actions behind named buttons rather than a hover popover.
// behind a row of icons in a hover popover.
const alice = await as(users.alice, chatPath(users.bob.pubkey), {context: {hasTouch: true}}) const alice = await as(users.alice, chatPath(users.bob.pubkey), {context: {hasTouch: true}})
const bob = await as(users.bob, chatPath(users.alice.pubkey)) 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(alice, "yes I did")).toContainText("did you see the thing?")
await expect(bubble(bob, "yes I did")).toContainText("did you see the thing?") await expect(bubble(bob, "yes I did")).toContainText("did you see the thing?")
// Edit: her own message is replaced in place for both of them rather than duplicated. The menu // The menu leads with React and Reply and keeps the rest behind a disclosure.
// leads with React and Reply and keeps the rest behind a disclosure.
await openMessageMenu(alice, hers.id) await openMessageMenu(alice, hers.id)
await alice.getByRole("button", {name: "More Options"}).click() await 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}) 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 // A result's label joins the emoji, its annotation and every shortcode.
// annotation is what gets matched rather than the whole of it.
await picker.locator("input.search").fill("party popper") await picker.locator("input.search").fill("party popper")
await picker.getByRole("option", {name: /party popper/}).click() 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.alice)
space.messagingRelayList(user.bob) space.messagingRelayList(user.bob)
// A conversation each of them already has. A list with something in it is how each page says // A list with something in it is how each page says its own end of the sync is up.
// its own end of the sync is up, before the one that has to arrive live is sent.
space.dm(user.carol, [user.alice], "see you monday", at(4, HOUR)) space.dm(user.carol, [user.alice], "see you monday", at(4, HOUR))
space.dm(user.carol, [user.bob], "you too", 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") 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 // His own copy first, so a send that lost keystrokes fails here rather than thirty seconds later.
// rather than thirty seconds later as a message that never reached her
await expect(bubble(bob, "starting a chat with you")).toBeVisible() await expect(bubble(bob, "starting a chat with you")).toBeVisible()
// Her list picks the conversation up on its own // 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 space = relay("space")
const inbox = relay("other") const inbox = relay("other")
// Bob she follows. Carol she knows only through the space they are both in, which is the // Carol she knows only through the space they are both in, the weakest thing that counts as knowing.
// weakest thing that still counts as knowing someone.
seedPerson(space, user.alice, "Alice Anchor") seedPerson(space, user.alice, "Alice Anchor")
seedPerson(space, user.bob, "Bob Barnacle") seedPerson(space, user.bob, "Bob Barnacle")
seedPerson(space, user.carol, "Carol Cutter") 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(), 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 // Membership of a relay never reaches a room list, so writing from it vouches for nobody.
// 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.
space.messagingRelayList(user.alice, [space.url, inbox.url]) space.messagingRelayList(user.alice, [space.url, inbox.url])
inbox.member(user.alice) inbox.member(user.alice)
inbox.member(dave) 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)) 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 // The socket a client opens before it knows what a relay is for never identifies itself to it.
// is for never identifies itself to it, and the inbox relay serves an anonymous reader nothing.
const page = await as(users.alice, "/chat", { const page = await as(users.alice, "/chat", {
env: {VITE_INDEXER_RELAYS: scenario.space("space").url}, 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") seedPerson(space, user.bob, "Bob Barnacle")
space.messagingRelayList(user.bob) space.messagingRelayList(user.bob)
// Alice's inbox is a relay she has nothing else to do with: not a space she has joined, and // Her messaging relay list is the one thing that vouches for her, and the relay serves her nothing until it does.
// 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.
inbox.member(user.alice) inbox.member(user.alice)
// Bob's own copy of the wrap is seeded onto the same relay, and this relay authorizes a wrap by // This relay authorizes a wrap by the member its p tag names, so bob has to be one too.
// the member its p tag names, so he has to be one too. Nothing else here is his.
inbox.member(user.bob) inbox.member(user.bob)
// Tagged verbatim rather than through setUrls, which normalizes on the way in. A list written // Tagged verbatim rather than through setUrls, which normalizes on the way in.
// by another client is where a url missing its trailing slash comes from, and the relay it
// names is the same relay either way.
space.event(user.alice, () => space.event(user.alice, () =>
space space
.kind(MessagingRelayList) .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)) 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 // Only her space indexes, so the inbox relay is dialled for her messages and nothing else.
// socket a client opens before it knows what a relay is for never identifies itself to it, and
// this relay serves an anonymous reader nothing.
const page = await as(users.alice, "/chat", { const page = await as(users.alice, "/chat", {
env: {VITE_INDEXER_RELAYS: scenario.space("space").url}, 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)) 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)) 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 // A room message comes off the same relay and is read back off the wire every time.
// 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.
space.message(user.bob, "general", "boat is in the water", at(2, HOUR)) space.message(user.bob, "general", "boat is in the water", at(2, HOUR))
}) })
const url = scenario.space("space").url const url = scenario.space("space").url
// A touch context, which is what puts Edit Message behind a named button. Editing is the only way // A touch context, which is what puts Edit Message behind a named button.
// the ui takes a direct message back, and the delete it publishes is the second half of the story.
const alice = await as(users.alice, roomPath(url, "general"), {context: {hasTouch: true}}) 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() 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(bubble(alice, "thanks")).toBeVisible()
await expect(alice.locator(".chat-bubble").filter({hasText: "thakns"})).toHaveCount(0) 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 // A reaction travels gift-wrapped the way the messages do, so it is kept or lost with them.
// lost with them rather than on its own terms.
await openMessageMenu(alice, his.id) await openMessageMenu(alice, his.id)
await alice.getByRole("button", {name: "React"}).click() 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("🎉") await expect(message(alice, his.id)).toContainText("🎉")
// The edit, the delete that retracted the original and the reaction all reach disk in batches, so // The edit, the delete and the reaction all reach disk in batches, which is what the reload reads.
// waiting for them there is waiting for the whole of what the reload is about to read back.
await expect await expect
.poll(() => cachedContent(alice, users.alice.pubkey, DIRECT_MESSAGE)) .poll(() => cachedContent(alice, users.alice.pubkey, DIRECT_MESSAGE))
.toEqual(expect.arrayContaining(["the tide charts are up", "thanks"])) .toEqual(expect.arrayContaining(["the tide charts are up", "thanks"]))

View file

@ -24,54 +24,39 @@ import {
} from "../harness" } from "../harness"
import type {SeededEvent, SeededSpace, TestUser} 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 // A small primary-colored dot in the corner. RelaySummary's member check is the same shape at h-5 w-5.
// 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.
const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary") const unreadDot = (scope: Locator) => scope.locator("div.h-2.w-2.rounded-full.bg-primary")
// The bell SpaceMenuRoomItem hangs off a muted room. An icon is a css mask built from a data url, // An icon is a css mask built from a data url, so a room only renders this one when it is muted.
// so which bell it is can't be read out of the class list, but a room only renders one when it is
// muted.
const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50") const mutedRoomBell = (room: Locator) => room.locator("div.ml-auto.opacity-50")
// The bell SpaceMenuHeader puts beside the space's name once the space itself is muted. The only // The only other absolutely-positioned dot in that button is the admin action-items one, at opacity-0.
// other absolutely-positioned dot in that button is the admin action-items one, which is opacity-0.
const mutedSpaceBell = (header: Locator) => header.locator("div.opacity-50") 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 // The name comes from nip-11; until that document lands the tooltip is the relay's host instead.
// 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.
const spaceNavItem = (page: Page, name: string) => const spaceNavItem = (page: Page, name: string) =>
page.locator(`.primary-nav [data-tip^="${name}"]`) 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, // The desktop rail has no equivalent, since the menu is always on screen there.
// since the menu is always on screen there.
const spaceMenuNavItem = (page: Page) => page.getByRole("button", {name: "Open space menu"}) 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. // The space menu's header, the one button in the secondary nav carrying the relay's address.
const spaceMenu = (page: Page, url: string) => const spaceMenu = (page: Page, url: string) =>
page.locator(".secondary-nav").getByRole("button", {name: pathPattern(displayRelayUrl(url))}) 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 // The menu closes on the next mouseup, which a click with no gap between press and release reaches.
// land in the same instant can reach that listener as it mounts, shutting the menu again. A human's
// click has a gap between the two; playwright's has one only when it is asked for.
const openSpaceMenu = (menu: Locator) => menu.click({delay: 200}) const openSpaceMenu = (menu: Locator) => menu.click({delay: 200})
// Every story here goes on to assert something that depends on the message having landed, so // Every story here depends on the message having landed, so posting is finished once it renders.
// posting is only finished once it has rendered.
const post = async (page: Page, content: string) => { const post = async (page: Page, content: string) => {
await send(page, content) await send(page, content)
await expect(message(page, content)).toBeVisible() await expect(message(page, content)).toBeVisible()
} }
// RoomItem's hover actions are icons with no accessible names, in a fixed order: zap, emoji, reply, // RoomItem's hover actions are icons with no accessible names, in a fixed order: zap, emoji, reply, edit, menu.
// edit (only on your own recent message), menu.
const replyToMessage = (page: Page, text: string) => messageActions(page, text).nth(2).click() 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 // Chromium's own notifications are invisible to a test, and a tab playwright drives is never hidden.
// 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.
const captureNotifications = async (page: Page) => { const captureNotifications = async (page: Page) => {
const notifications: {title: string; body: string}[] = [] const notifications: {title: string; body: string}[] = []
@ -101,17 +86,14 @@ const captureNotifications = async (page: Page) => {
return notifications return notifications
} }
// The page bar names the room, so waiting for it is what says the composer below now belongs to // The page bar names the room, so waiting for it says the composer belongs to the room just opened.
// the room that was just opened rather than to the one being torn down.
const postTo = async (page: Page, room: string, content: string) => { const postTo = async (page: Page, room: string, content: string) => {
await roomLink(page, room).click() await roomLink(page, room).click()
await expect(pageBar(page)).toContainText(room) await expect(pageBar(page)).toContainText(room)
await post(page, content) await post(page, content)
} }
// Outbox routing resolves everything about a person through their relay list: settings are // Settings are published to the relays a person's list names, and a gift wrap needs one too.
// published to the relays it names, and a gift wrap only reaches somebody who has said where their
// messages go.
const seedRelays = (space: SeededSpace, user: TestUser) => { const seedRelays = (space: SeededSpace, user: TestUser) => {
space.relayList(user) space.relayList(user)
space.messagingRelayList(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.alice, "general", "random")
space.join(user.bob, "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 // History bob wrote himself, since his own messages never raise an indicator.
// for him — his own messages never raise an indicator.
space.message(user.bob, "general", "anyone around?", at(2, HOUR)) 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 // Bob is sitting on the spaces page the whole time, so the dot arrives without a navigation
await expect(unreadDot(navItem)).toBeVisible() await expect(unreadDot(navItem)).toBeVisible()
// Inside the space, the dot points at the room the message landed in. A space's room list only // A space's room list only exists in its own menu, so this is the one indicator the rail can't show.
// exists in its own menu, so this is the one indicator the rail can't show.
await navItem.click() await navItem.click()
const general = roomLink(bob, "General") const general = roomLink(bob, "General")
const random = roomLink(bob, "Random") 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 // Both rooms are on screen first, so a room with no dot is one raising none rather than not rendered.
// row that never rendered
await expect(random).toBeVisible() await expect(random).toBeVisible()
await expect(unreadDot(general)).toBeVisible() await expect(unreadDot(general)).toBeVisible()
await expect(unreadDot(random)).toHaveCount(0) 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") const space = scenario.space("space")
// Silencing a whole space is only offered once push notifications are on, so that is where alice // Silencing a whole space is only offered once push notifications are on.
// has to start.
const alice = await as(users.alice, "/settings/alerts", { const alice = await as(users.alice, "/settings/alerts", {
context: {permissions: ["notifications"]}, 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"))) 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 // Mute is the stronger of the two settings there: it forces notifications off and hides badges.
// forces the room's notifications off and hides its unread badges too.
await openRoomDetail(alice) await openRoomDetail(alice)
const roomMute = settingRow(alice, "Mute").getByRole("checkbox") 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 openSpaceMenu(menu)
await alice.getByRole("button", {name: "Turn off notifications"}).click() await alice.getByRole("button", {name: "Turn off notifications"}).click()
// Silencing the space is about its alerts — hiding unread badges is the room mute's job — so the // Hiding unread badges is the room mute's job, so the dots the rooms are carrying stay up.
// bell appears beside its name and the dots the rooms are carrying stay up.
await expect(mutedSpaceBell(menu)).toBeVisible() await expect(mutedSpaceBell(menu)).toBeVisible()
await expect(unreadDot(general)).toBeVisible() await expect(unreadDot(general)).toBeVisible()
await expect(unreadDot(random)).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.
// Clicking the header while the menu is still on its way out toggles it straight back shut, so
// wait for it to go before reopening it.
const turnOn = alice.getByRole("button", {name: "Turn on notifications"}) const turnOn = alice.getByRole("button", {name: "Turn on notifications"})
await expect(turnOn).toHaveCount(0) await 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(conversation).toContainText("General")
await expect(unreadDot(conversation)).toBeVisible() await expect(unreadDot(conversation)).toBeVisible()
// A space's threads, events and classifieds are counted per space in Activity rather than listed // Threads, events and classifieds are counted per space in Activity rather than listed as conversations.
// as conversations, so the inbox stays a list of messages
const activity = page.getByRole("link").filter({hasText: "1 thread"}) const activity = page.getByRole("link").filter({hasText: "1 thread"})
await expect(page.getByRole("heading", {name: "Activity"})).toBeVisible() 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.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
// The feed asks each follow's write relays for their notes, so a note is only reachable // The feed asks each follow's write relays for their notes, so a note needs a relay list naming one.
// through a relay list naming one.
seedRelays(space, user.alice) seedRelays(space, user.alice)
seedRelays(space, user.bob) 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.getByRole("heading", {name: "Network"})).toBeVisible()
await expect(page.getByText(note)).toBeVisible() await expect(page.getByText(note)).toBeVisible()
// The feed is notes only: a reply is counted on the note it answers rather than drawn // The feed is notes only: a reply is counted on the note it answers and gets no card of its own.
// underneath it, and it never gets a card of its own.
await expect(page.getByRole("button", {name: "1 reply", exact: true})).toBeVisible() await expect(page.getByRole("button", {name: "1 reply", exact: true})).toBeVisible()
await expect(page.getByText(reply)).toHaveCount(0) 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.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
// Alice reads from her space and the indexer. Bob's relay is somewhere she writes and nowhere // Bob's relay is somewhere she writes and nowhere she reads, which is enough to identify to it.
// 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.
indexer.relayList(user.alice, { indexer.relayList(user.alice, {
read: [space.url, indexer.url], read: [space.url, indexer.url],
write: [space.url, indexer.url, outbox.url], write: [space.url, indexer.url, outbox.url],
}) })
indexer.follows(user.alice, [user.bob]) 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 // Bob is in none of her spaces, so his relay list is the only thing that can point the feed at him.
// thing that can point the feed at his notes.
indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url]}) indexer.relayList(user.bob, {read: [outbox.url], write: [outbox.url]})
outbox.profile(user.bob, {name: "Bob Barker"}) 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(note)).toBeVisible()
await expect(network.getByText("Bob Barker")).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, // Alice reads from her space rather than bob's relay, so a context asked of hers comes back empty.
// so a context asked of her own read relays comes back with nothing.
await expect(network.getByRole("button", {name: "1 reply", exact: true})).toBeVisible() await expect(network.getByRole("button", {name: "1 reply", exact: true})).toBeVisible()
// Her space never held any of it. // 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}) => { 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 // Nothing serves this url: the fault is a relay that takes the socket and then says nothing.
// then says nothing, which is all the spec asks of it.
const stalled = "wss://stalled.test/" const stalled = "wss://stalled.test/"
const note = "the drawbridge has been stuck open since noon" 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.room("general", {name: "General"})
space.join(user.alice, "general") space.join(user.alice, "general")
// The relays the feed will ask are somewhere she writes and nowhere she reads, which is what // Somewhere she writes and nowhere she reads, which is what lets her client identify to them.
// lets her client identify to them: zooid answers no REQ without nip-42, and Flotilla only
// identifies to relays her own lists name.
indexer.relayList(user.alice, { indexer.relayList(user.alice, {
read: [space.url, indexer.url], read: [space.url, indexer.url],
write: [space.url, indexer.url, outbox.url, stalled], 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") .locator("section")
.filter({has: page.getByRole("heading", {name: "Network"})}) .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 // A span releases what it found once it is done waiting, so one silent relay holds it empty.
// relay it asked is a span one silent relay holds empty.
await expect(network.getByText(note)).toBeVisible({timeout: 20_000}) await expect(network.getByText(note)).toBeVisible({timeout: 20_000})
await expect(network.getByText("Bob Barker")).toBeVisible() 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") const space = scenario.space("space")
// A client only unwraps direct messages once its owner has opened chat, so a conversation the // A client only unwraps direct messages once its owner has opened chat.
// share dialog can offer is one alice has already seen.
const page = await as(users.alice, "/chat") const page = await as(users.alice, "/chat")
await expect(page.getByText("are you around?").first()).toBeVisible() 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 // The text arrives in the query string rather than through a native share intent.
// same thing src/routes/share reads either way.
await page.goto(`/share?text=${encodeURIComponent(shared)}`) await page.goto(`/share?text=${encodeURIComponent(shared)}`)
const share = dialog(page, "Share") 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() 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 // EventInfo renders the link on mount rather than during setup.
// copying an empty field
const eventLink = settingRow(page, "Event Link") const eventLink = settingRow(page, "Event Link")
await expect(eventLink.getByRole("textbox")).toHaveValue(/^nostr:nevent1/) 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 space = scenario.space("space")
const other = scenario.space("other") 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 // A phone has no room list on screen while a room is open.
// activity elsewhere can surface
const bob = await as(users.bob, roomPath(space.url, "general"), { const bob = await as(users.bob, roomPath(space.url, "general"), {
context: {viewport: {width: 390, height: 844}, hasTouch: true}, 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() 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 // Bob's own space still has an unread room, which is that space's own business.
// — that one is the space's business, and its own indicators carry it
await bob.goto(spacePath(space.url)) await bob.goto(spacePath(space.url))
await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible() await expect(unreadDot(roomLink(bob, "Random"))).toBeVisible()
await expect(unreadDot(menuButton)).toHaveCount(0) await expect(unreadDot(menuButton)).toHaveCount(0)
}) })
// SpaceMenuNavItems offers a content type once the space has an event of that kind or something // SpaceMenuNavItems offers a content type once the space has an event of that kind or an unread.
// under it is unread, so this link appearing at all is what says the seeded content loaded.
const contentNavItem = (page: Page, name: string) => const contentNavItem = (page: Page, name: string) =>
page.locator(".secondary-nav").getByRole("link", {name}) 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() await expect(his).toBeVisible()
// syncChecked marks the landed-on page read 300ms later and latestActivityByPath is throttled to // syncChecked marks the page read 300ms later and latestActivityByPath is throttled to a second.
// 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.
await bob.waitForTimeout(1500) await bob.waitForTimeout(1500)
await expect(unreadDot(hers)).toBeVisible() 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") const space = scenario.space("space")
// ThreadBoard swaps its table for a list of links when the board is too narrow for the table, // ThreadBoard swaps its table for a list of links when the board is narrow, and renders each apart.
// and the two branches render the thread separately, so a dot on one says nothing about the
// other.
const bob = await as(users.bob, `${spacePath(space.url)}/threads`, { const bob = await as(users.bob, `${spacePath(space.url)}/threads`, {
context: {viewport: {width: 390, height: 844}, hasTouch: true}, 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() 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 // Same tick and throttle as US-112: a dot read before both have run may still be about to clear.
// about to clear.
await bob.waitForTimeout(1500) await bob.waitForTimeout(1500)
await expect(unreadDot(hers)).toBeVisible() await expect(unreadDot(hers)).toBeVisible()
await expect(unreadDot(his)).toHaveCount(0) await expect(unreadDot(his)).toHaveCount(0)
}) })
// Classifieds stands in for the five boards whose items are cards rather than rows — they all // Classifieds stands in for the five boards whose items are cards, which differ only in the corner.
// render the same UnreadDot off the same content path, and only the corner it sits in differs.
test("US-114 see which listings are unread", async ({seed, as}) => { test("US-114 see which listings are unread", async ({seed, as}) => {
const scenario = await seed(({relay, user, at}) => { const scenario = await seed(({relay, user, at}) => {
const space = relay("space") 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.alice, "general")
space.join(user.bob, "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 // Older than the month the space sync asks for, so the nav can only know by asking the relay.
// the relay what the space holds rather than by reading what has loaded.
seedPoll(space, user.alice, "which sextant should we buy", at(60, DAY)) 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") space.join(user.bob, "general")
other.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. // 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.
space.event( space.event(
user.alice, 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() 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 // With nothing unread and no poll to list, the space stops offering the section at all.
// offering the section at all
await roomLink(bob, "General").click() await roomLink(bob, "General").click()
await expect(pollsNav).toHaveCount(0) 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") const space = scenario.space("space")
// Push notifications are off until they are asked for, and the tab has to be in the background // The tab has to be in the background before a push notification is raised at all.
// before one is raised at all.
const alice = await as(users.alice, "/settings/alerts", { const alice = await as(users.alice, "/settings/alerts", {
context: {permissions: ["notifications"]}, 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() 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 // A reply prepends the message it answers, so its first line is an entity rather than the reply.
// 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.
await expect await expect
.poll(() => notifications.at(-1)) .poll(() => notifications.at(-1))
.toEqual({ .toEqual({

View file

@ -11,8 +11,7 @@ const gate = (page: Page) => page.getByRole("heading", {name: "Welcome to Flotil
const nsecFor = (user: TestUser) => nsecEncode(hexToBytes(user.secret)) 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 // The only way to watch a session come into existence: one `as()` injects is re-applied on every navigation.
// session come into existence: a session injected by `as()` is re-applied on every navigation.
const logInWithKey = async (page: Page, key: string) => { const logInWithKey = async (page: Page, key: string) => {
await page.getByRole("button", {name: "Log in"}).click() await page.getByRole("button", {name: "Log in"}).click()
await page.getByRole("button", {name: "Log in with Key"}).click() await page.getByRole("button", {name: "Log in with Key"}).click()
@ -21,14 +20,11 @@ const logInWithKey = async (page: Page, key: string) => {
await page.locator("form").getByRole("button", {name: "Log in", exact: true}).click() await page.locator("form").getByRole("button", {name: "Log in", exact: true}).click()
} }
// The nav's settings link carries its label as a tooltip rather than as an accessible name — its // The nav's settings link carries its label as a tooltip, so it is addressed by where it goes.
// icon is a masked svg with no alt text — so it is addressed by where it goes.
const openSettings = (page: Page) => const openSettings = (page: Page) =>
page.locator('.primary-nav a[href="/settings/profile"]').click() 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 // A nip01 login renders a masked Private Key too, so the npub is the readonly non-password one.
// below it, so `getByRole("textbox")` on this page is two elements — the npub is the readonly one
// that isn't a password.
const npubField = (page: Page) => page.locator('input[readonly]:not([type="password"])') const npubField = (page: Page) => page.locator('input[readonly]:not([type="password"])')
test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => { 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") const {url} = scenario.space("space")
// A room url rather than the root: the gate stands in front of the whole app, so where the // A room url rather than the root: the gate stands in front of the whole app.
// visitor landed makes no difference.
const page = await visit(roomPath(url, "general")) const page = await visit(roomPath(url, "general"))
await expect(gate(page)).toBeVisible() 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(gate(page)).toHaveCount(0)
await expect(page.getByText("Back Up Your Key")).toBeVisible() 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 // A space's nav item is a button carrying the relay's nip-11 name as its tooltip.
// 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.
const spaceItem = page.locator('.primary-nav [data-tip="space"]') const spaceItem = page.locator('.primary-nav [data-tip="space"]')
await expect(spaceItem).toBeVisible() 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 openSettings(withNcryptsec)
await expect(npubField(withNcryptsec)).toHaveValue(aliceNpub) await expect(npubField(withNcryptsec)).toHaveValue(aliceNpub)
// A second browser context with its own storage: bob's session is his own, not a second view // A second browser context with its own storage: bob's session is his own.
// of alice's.
const asBob = await visit() const asBob = await visit()
await logInWithKey(asBob, nsecFor(users.bob)) 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 bunker = page.getByPlaceholder("bunker://")
const next = page.getByRole("button", {name: "Next"}) const next = page.getByRole("button", {name: "Next"})
// The relay named here belongs to no scenario, so a connection attempt would be recorded as a // The relay named here belongs to no scenario, so a connection attempt is recorded as a leak.
// leak and fail this test rather than passing unnoticed.
await bunker.fill("bunker://not-a-signer-pubkey?relay=wss://nowhere.test/") await bunker.fill("bunker://not-a-signer-pubkey?relay=wss://nowhere.test/")
await next.click() 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)}`) 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 // zooid honours the kind-62 right-to-vanish, so the profile is gone rather than renamed.
// 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).
const aliceNpub = npubEncode(users.alice.pubkey) const aliceNpub = npubEncode(users.alice.pubkey)
const fallback = aliceNpub.slice(0, 8) + "…" + aliceNpub.slice(-5) const fallback = aliceNpub.slice(0, 8) + "…" + aliceNpub.slice(-5)
await expect(bob.getByRole("heading", {name: fallback})).toBeVisible() await expect(bob.getByRole("heading", {name: fallback})).toBeVisible()
// Her relay list went with the account, so the feed has nowhere to ask and keeps looking rather // Her relay list went with the account, so the feed has nowhere to ask and keeps looking.
// than settling on its empty state. What the story is about is that nothing of hers comes back.
await expect(bob.locator(".card.card-interactive")).toHaveCount(0) await expect(bob.locator(".card.card-interactive")).toHaveCount(0)
}) })

View file

@ -23,14 +23,12 @@ import type {TestUser} from "../harness"
// A handle to a seeded event, which only reads once seed() has drained its queue. // A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string; readonly event: SignedEvent} type Seeded = {readonly id: string; readonly event: SignedEvent}
// The profile page keeps its Reputation and Spaces panels in a sidebar that only exists above // The Reputation and Spaces panels only exist above tailwind's xl breakpoint, where 1280 sits.
// tailwind's xl breakpoint, and the default 1280 viewport sits exactly on it.
const DESKTOP = {viewport: {width: 1440, height: 900}} const DESKTOP = {viewport: {width: 1440, height: 900}}
const CLIPBOARD = {...DESKTOP, permissions: ["clipboard-read", "clipboard-write"]} const CLIPBOARD = {...DESKTOP, permissions: ["clipboard-read", "clipboard-write"]}
// uploadFile appends the extension when the descriptor's url carries none, which the mock's never // uploadFile appends the extension when the descriptor's url carries none, as the mock's never does.
// does.
const uploadedUrl = (body: Buffer, extension: string) => const uploadedUrl = (body: Buffer, extension: string) =>
`${DEFAULT_BLOSSOM_ORIGIN}/${createHash("sha256").update(body).digest("hex")}.${extension}` `${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) 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 // The page's own region, so an assertion about an avatar isn't satisfied by the copy the nav renders.
// nav renders.
const pageContent = (page: Page) => page.locator(".page__content") const pageContent = (page: Page) => page.locator(".page__content")
// ProfileTrust and ProfileSharedSpaces are rendered twice — stacked for narrow viewports, and in // ProfileTrust and ProfileSharedSpaces are rendered twice, stacked for narrow viewports and here.
// the sidebar — so anything said about them is scoped to the copy that is actually on screen.
const sidebar = (page: Page) => page.locator("aside") 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 // A space path redirects as the page mounts, and modal.ts closes every open modal on navigation.
// open modal on navigation, so a dialog opened before the redirect lands is thrown away with it.
const enteredSpace = (page: Page) => expect(page).toHaveURL(new RegExp("/spaces/[^/]+/.")) const enteredSpace = (page: Page) => expect(page).toHaveURL(new RegExp("/spaces/[^/]+/."))
// Search is a dialog the nav opens rather than a page of its own. // 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() const viewProfile = (card: Locator) => card.getByRole("link", {name: "View Profile"}).first()
test("US-074 find a person", async ({seed, as}) => { 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 // Sixty of them, so the ten the dialog lists are visibly the best matches rather than everyone.
// everyone who matched.
const searchers = Array.from({length: 60}, (_, i) => makeTestUser(`searcher-${i}`)) const searchers = Array.from({length: 60}, (_, i) => makeTestUser(`searcher-${i}`))
const searcherName = (i: number) => `Searcher ${String(i).padStart(2, "0")}` const searcherName = (i: number) => `Searcher ${String(i).padStart(2, "0")}`
const searcherAvatar = (i: number) => `https://images.test/searcher-${i}.png` 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.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
// Bob is the one person whose name shares no token with the others, so narrowing the term is // Bob is the one person whose name shares no token with the others.
// visibly a filter rather than a reordering.
space.profile(user.bob, { space.profile(user.bob, {
name: "Bob Barnacle", name: "Bob Barnacle",
about: "Dockside cook and keeper of the ship's cat.", 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 openSearch(page)
await term.fill("Searcher") await term.fill("Searcher")
// Which of them ranks first is fuse's business, so a result is described by what every // Which of them ranks first is fuse's business.
// result carries rather than by which one it turned out to be.
const first = cards.first() const first = cards.first()
await expect(first).toBeVisible() 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.alice, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
// A second space alice does not belong to, so "Member" is a claim about the overlap rather // A second space alice does not belong to, so "Member" is a claim about the overlap.
// than about every space bob is in.
other.room("lounge", {name: "Lounge"}) other.room("lounge", {name: "Lounge"})
other.join(user.bob, "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. // Carol belongs to no space at all, which is what the panel's empty state is about.
space.member(user.carol) space.member(user.carol)
space.profile(user.carol, {name: "Carol Cutter"}) 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 // An expired status is one its author asked relays to stop serving.
// up to any more.
space.event( space.event(
user.carol, user.carol,
makeEvent(STATUS, { 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.bob)
space.relayList(user.carol) space.relayList(user.carol)
// Carol already follows bob, so alice following carol is the one thing that has to happen // Carol already follows bob, so alice following carol is the one thing that goes through the ui.
// through the ui for his standing to move.
space.event(user.carol, () => space.event(user.carol, () =>
space.kind(FollowList).writer().follow(user.bob.pubkey).renderTemplate(), 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 // Every hop here is a link click rather than a navigation, so the follow stays in one client.
// in the client that published it.
const page = await as(users.alice, spacePath(scenario.space("space").url), {context: DESKTOP}) const page = await as(users.alice, spacePath(scenario.space("space").url), {context: DESKTOP})
const term = searchTerm(page) const term = searchTerm(page)
const cards = searchResults(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 viewProfile(bobCard).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`)) 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 // Word-bounded: a plain "0 / 100" is also a substring of "10 / 100" and "20 / 100".
// exactly the readings this is supposed to rule out.
await expect(reputation()).toContainText(/\b0 \/ 100\b/) await expect(reputation()).toContainText(/\b0 \/ 100\b/)
await expect(reputation()).toContainText("This user is not well known in your network.") 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)}), 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 // Older than the feed's first window, so the pin is fetched by id while the feed only pages to it.
// reaches it by paging — the order in which one note arrives down both routes.
pinned = space.event( pinned = space.event(
user.alice, user.alice,
makeEvent(NOTE, {content: "PINNED how to read a tide chart", created_at: at(2, MONTH)}), 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).toBeVisible()
await expect(newest.getByText("Alice Anderson")).toBeVisible() await expect(newest.getByText("Alice Anderson")).toBeVisible()
await expect(newest.locator(`img[src="${avatar}"]`)).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 // The app renders formatTimestamp, a short date plus a clock time, as every content item does.
// 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.
await expect(newest).toContainText(/\d{1,2}\/\d{1,2}\/\d{2,4}/) await expect(newest).toContainText(/\d{1,2}\/\d{1,2}\/\d{2,4}/)
await expect(list.filter({hasText: "REPLY"})).toHaveCount(0) 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(1)).toContainText("NEWEST")
await expect(list.nth(2)).toContainText("MIDDLE") await expect(list.nth(2)).toContainText("MIDDLE")
// Nothing is scrolled here: the feed keeps widening its window until the page is full, which is // Nothing is scrolled here: the feed keeps widening its window until the page is full.
// what "loads older notes automatically" means.
await expect(list.filter({hasText: "OLDEST"})).toBeVisible() 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 // Flotilla has no composer for a kind-1 note, so it goes out through the app's own primitives.
// 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.
const alice = await as(users.alice, spacePath(url), {context: DESKTOP}) const alice = await as(users.alice, spacePath(url), {context: DESKTOP})
await alice.evaluate( 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, [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 // The profile feed fetches on load rather than subscribing live.
// opens the page — where it lands above every unpinned note, below the pin.
await page.reload() await page.reload()
await expect(list.filter({hasText: "LIVE"})).toBeVisible() 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 {url} = scenario.space("space")
const page = await as(users.alice, `${spacePath(url)}/directory`, {context: DESKTOP}) 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 // The card's aria-label keeps the npub it fell back to at first render, so it is found by the name.
// `$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.
const preview = page const preview = page
.locator(".card.card-interactive") .locator(".card.card-interactive")
.filter({hasText: "Bob Barnacle"}) .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 link = linkField.locator('input[type="text"]')
const pubkey = pubkeyField.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 // A FieldInline puts its value in the div right after its label.
// 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.
const createdAt = info const createdAt = info
.locator("label") .locator("label")
.filter({hasText: "Created At"}) .filter({hasText: "Created At"})
@ -701,8 +676,7 @@ test("US-081 inspect and share a profile", async ({seed, as}) => {
await profileMenu(page).click() await profileMenu(page).click()
await page.getByRole("button", {name: "Share"}).click() await page.getByRole("button", {name: "Share"}).click()
// The menu that opened it is still mounted behind the dialog, and its own items run together as // The menu that opened it is still mounted behind the dialog, and its items run together.
// "Share Profile Info", so the dialog's heading is matched exactly.
await expect(page.getByText("Share Profile", {exact: true})).toBeVisible() await expect(page.getByText("Share Profile", {exact: true})).toBeVisible()
const share = topDialog(page) 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 badge = page.locator(".badge").filter({hasText: "Bob Barnacle"})
const save = page.getByRole("button", {name: "Save Changes"}) const save = page.getByRole("button", {name: "Save Changes"})
// Typed rather than filled, and slower than the search's own debounce: the suggestion list is // Typed slower than the search's own debounce, since the profile only arrives after a pause.
// 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.
await page await page
.getByPlaceholder("Search for profiles...") .getByPlaceholder("Search for profiles...")
.pressSequentially("Bob Barnacle", {delay: 700}) .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!") 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 // Through the nav rather than a fresh navigation, so this is what the publishing client believes.
// just published the mute believes.
const openBobsProfile = async () => { const openBobsProfile = async () => {
await openSearch(page) await openSearch(page)
await searchTerm(page).fill("Barnacle") await searchTerm(page).fill("Barnacle")

View file

@ -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. // 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"}) 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 // Each of these menus hides itself once the pointer leaves it, and the top left corner is outside.
// 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.
const dismissMenu = (page: Page) => page.mouse.move(0, 0) const dismissMenu = (page: Page) => page.mouse.move(0, 0)
const roomDetail = (page: Page) => page.getByRole("dialog", {name: "Room details"}) 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) => const openRoomDetailMenu = (page: Page) =>
roomDetail(page).getByRole("button", {name: "Room options"}).click() 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 // The space menu's sections are flat siblings, so a room's section is a question of document order.
// section a room is in is a question about document order rather than nesting.
const roomSection = (page: Page, name: string) => const roomSection = (page: Page, name: string) =>
page.locator(".space-menu__scroll").evaluate((menu, roomName) => { page.locator(".space-menu__scroll").evaluate((menu, roomName) => {
let section: string | undefined let section: string | undefined
@ -63,8 +60,7 @@ const field = (form: Locator, label: string) =>
.locator("xpath=following-sibling::div") .locator("xpath=following-sibling::div")
.locator("input[type=text]") .locator("input[type=text]")
// RoomForm's permission toggles are a bare checkbox beside their own text rather than a labelled // RoomForm's permission toggles are a bare checkbox beside their own text.
// control.
const permission = (form: Locator, label: string) => const permission = (form: Locator, label: string) =>
form.getByText(label).locator("xpath=preceding-sibling::input") 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") 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 // Outbox routing resolves a person through their relay list, and a gift wrap needs one too.
// reaches somebody who has said where their messages go.
const seedChatter = (space: SeededSpace, user: TestUser) => { const seedChatter = (space: SeededSpace, user: TestUser) => {
space.relayList(user) space.relayList(user)
space.messagingRelayList(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 {url} = scenario.space("space")
const path = roomPath(url, "general") const path = roomPath(url, "general")
// Two browser contexts, two identities, one relay: bob's page is already listening when alice // Two browser contexts, two identities, one relay: bob's page is already listening when alice sends.
// sends, so the message reaches him over the wire.
const alice = await as(users.alice, path) const alice = await as(users.alice, path)
const bob = await as(users.bob, 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") 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, // Two messages sharing a second are ordered by event id, which is the same on every client.
// 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.
test("US-118 messages sent in the same second are in one order for everyone", async ({ test("US-118 messages sent in the same second are in one order for everyone", async ({
seed, seed,
as, 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")), 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 // The room reads newest first in the dom, so the ids run the other way from the feed.
// holds them in
expect(rendered.reverse()).toEqual(tied.map(({id}) => id).sort()) 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") 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 membership events a client listens for live are the ones naming itself.
// the ones naming itself, so somebody else's arrival is read with the rest of the room.
await alice.reload() await alice.reload()
const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"}) const joined = alice.getByText("joined the room").filter({hasText: "Bob Barnacle"})
@ -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: "Delete Room"}).click()
await admin.getByRole("button", {name: "Confirm"}).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 // The space root renders nothing on a wide screen, handing off to the page of the space open last.
// was open last, and the room that was just deleted is the page it remembers.
await expect(admin).toHaveURL(pathPattern(spacePath(url) + "/")) await expect(admin).toHaveURL(pathPattern(spacePath(url) + "/"))
await expect(roomLink(admin, "General")).toBeVisible() 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") const space = relay("space")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
// `private` is what hides the room's history from non-members; `closed` is what keeps a join // `private` hides the room's history from non-members; `closed` makes a join a request.
// request from admitting her on its own, so an admin has to act on it.
space.room("wardroom", {name: "Wardroom", private: true, closed: true}) space.room("wardroom", {name: "Wardroom", private: true, closed: true})
space.join(user.admin, "general", "wardroom") space.join(user.admin, "general", "wardroom")
space.join(user.alice, "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("You aren't currently a member of this room.")).toBeVisible()
await expect(carol.getByText("the charts are in the locker")).toHaveCount(0) await expect(carol.getByText("the charts are in the locker")).toHaveCount(0)
// A private room offers to join rather than to ask, and a closed one turns that into a request // A private room offers to join rather than to ask, and a closed one turns that into a request.
// an admin has to act on.
await carol.getByRole("button", {name: "Join Room"}).click() await carol.getByRole("button", {name: "Join Room"}).click()
await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible() await expect(carol.getByRole("button", {name: "Access Pending"})).toBeVisible()
@ -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() 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 // The history the relay refused her is fetched when the room is next read.
// finds on her way back in rather than something that fills in behind her.
await carol.reload() await carol.reload()
await expect(carol.getByText("the charts are in the locker")).toBeVisible() await expect(carol.getByText("the charts are in the locker")).toBeVisible()
@ -422,8 +408,7 @@ test("US-022 bring people into a room", async ({seed, as}) => {
space.join(user.admin, "general") space.join(user.admin, "general")
space.join(user.bob, "general") space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle"}) 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 // A space member who isn't in the room yet, whose message loads her profile into the client.
// client that goes looking for her.
space.join(dora) space.join(dora)
space.profile(dora, {name: "Dora Deckhand"}) space.profile(dora, {name: "Dora Deckhand"})
space.message(dora, "general", "passing through", at(3, HOUR)) 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() 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 // A path rather than an absolute url, which would leave the test's own dev server for the platform's.
// dev server for the platform's.
const link = new URL(await invite.inputValue()) const link = new URL(await invite.inputValue())
const carol = await as(users.carol, `/join${link.search}`) 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(admin.getByText("Members have successfully been added!")).toBeVisible()
await expect(members.getByText("Dora Deckhand")).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 // A pubkey pasted into the search field selects that person outright, with no profile needed.
// search field selects that person outright, which is how erik is reachable without a profile.
await admin.getByRole("button", {name: "Add members"}).click() await admin.getByRole("button", {name: "Add members"}).click()
await admin.getByPlaceholder("Search for profiles...").fill(erik.pubkey) await admin.getByPlaceholder("Search for profiles...").fill(erik.pubkey)
await admin.getByRole("button", {name: "Save changes"}).click() 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() await expect(message(bob, "we sail at dwan")).toBeVisible()
// Her edit republishes with her original timestamp, and two messages sharing a second are // Her edit republishes with her original timestamp, and it changes the event id US-118 ties on.
// 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.
const sent = now() const sent = now()
while (now() === sent) { 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(phone.getByRole("button", {name: "Open space menu"})).toBeVisible()
await expect(reactionPill(phone, "we made port")).toBeVisible() await expect(reactionPill(phone, "we made port")).toBeVisible()
// Her retraction has to have reached this page first: a pill she is part of toggles her reaction // A pill she is part of toggles her reaction off instead of opening the list.
// off instead of opening the list.
await expect(reactionPill(phone, "we made port")).not.toHaveClass(/button-primary/) await expect(reactionPill(phone, "we made port")).not.toHaveClass(/button-primary/)
await reactionPill(phone, "we made port").click() await reactionPill(phone, "we made port").click()
await expect(phone.getByText("Reacted to this message")).toBeVisible() await expect(phone.getByText("Reacted to this message")).toBeVisible()
// The list resolves each reactor's profile as it renders; the dialog's own title is whatever name // Exact, because the room behind the dialog names him too, as "@Bob Barnacle".
// was known when the pill was clicked, which on a page this fresh is often still an npub. Exact,
// because the room behind the dialog names him too, as "@Bob Barnacle".
await expect(phone.getByRole("button", {name: "Bob Barnacle", exact: true})).toBeVisible() 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(search).toHaveCount(0)
await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport() await expect(page.locator(`[data-event="${lastWeek.id}"]`)).toBeInViewport()
// A permalink to one message lands on it. This room is three messages long, so the window that // This room is three messages long, so the window runs to the present and the newest loads too.
// opens with it runs all the way to the present and the newest message is loaded alongside —
// US-027a is where the button that stands in for that gap is exercised.
await page.goto(`${path}?at=${older.event.created_at}`) await page.goto(`${path}?at=${older.event.created_at}`)
await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport() await expect(page.locator(`[data-event="${older.id}"]`)).toBeInViewport()
@ -839,11 +815,7 @@ test("US-027 find a past message and jump to it", async ({seed, as}) => {
await expect(jumpToNewest(page)).toHaveCount(0) await expect(jumpToNewest(page)).toHaveCount(0)
}) })
// A push notification links to the message it announced, which is near the newest end of the room, // A push notification links near the newest end, so the jump lands at the bottom with nothing below.
// 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.
test("US-027a a permalink near the newest end lands at the bottom", async ({seed, as}) => { test("US-027a a permalink near the newest end lands at the bottom", async ({seed, as}) => {
let recent!: SeededEvent 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() 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 // The quote, the mention and the url are named rather than spelled out a character at a time.
// is intelligible read a character at a time.
expect(spoken).toEqual([ expect(spoken).toEqual([
"another message\n\nheads up Alice Anchor, the notice is at a link to harbor.example", "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 // The app decodes what it is answered and rebuilds the container around the samples.
// duration is only right if that round trip kept every one of them.
await expect(alice.getByText("/ 0:10")).toBeVisible() await expect(alice.getByText("/ 0:10")).toBeVisible()
// The clip carries autoplay and the button follows the audio element's own play event, so it // The clip carries autoplay and the button follows the audio element's own play event.
// 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.
const playPause = alice.getByRole("button", {name: /^(Play|Pause) message$/}) const playPause = alice.getByRole("button", {name: /^(Play|Pause) message$/})
await expect(playPause).toHaveAttribute("aria-label", "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}) => { 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 // Zapping only gets as far as a dialog once dufflepud answers with a zapper for that endpoint.
// as far as a dialog once dufflepud answers with a zapper for that endpoint.
const lud16 = "bob@zap.test" const lud16 = "bob@zap.test"
// A zapper's receipts are signed by the recipient's lightning provider, so it is an identity of // A zapper's receipts are signed by the recipient's lightning provider, so it is an identity of its own.
// its own even where, as here, nothing is ever paid.
const provider = makeTestUser("zapper") const provider = makeTestUser("zapper")
const scenario = await seed(({relay, user}) => { 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 path = roomPath(url, "general")
const page = await as(users.alice, path, {webln: {node: {alias: "Test Node"}}}) 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()` // Registered after the page opened, so it answers ahead of the empty dufflepud `as()` installs.
// installs, and before the navigation below, since a zapper is looked up once per page load.
await mockDufflepud(page.context(), { await mockDufflepud(page.context(), {
zappers: [ 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(page.getByRole("alert")).toContainText("Wallet successfully connected!")
await expect(connect).toHaveCount(0) await expect(connect).toHaveCount(0)
// The zap dialog was underneath rather than replaced, so it answers to the wallet she now has // The zap dialog was underneath rather than replaced, so the amount she had typed survived.
// instead of still asking for one, and the amount she had typed survived the detour.
await expect(zap.getByRole("button", {name: "Connect a lightning wallet"})).toHaveCount(0) await expect(zap.getByRole("button", {name: "Connect a lightning wallet"})).toHaveCount(0)
await expect(zap.getByRole("button", {name: "Send Zap"})).toBeVisible() await expect(zap.getByRole("button", {name: "Send Zap"})).toBeVisible()
await expect(amount).toHaveValue("210") await expect(amount).toHaveValue("210")

View file

@ -16,8 +16,7 @@ import {
users, users,
} from "../harness" } from "../harness"
// Every page in a space renders one PageContent, so the number added to the document is the number // Every page in a space renders one PageContent, so the count is how often the page was built.
// of times the page was built.
const watchPageBuilds = (page: Page) => const watchPageBuilds = (page: Page) =>
page.evaluate(() => { page.evaluate(() => {
const selector = "[data-component='PageContent']" 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.getByText("only in space")).toBeVisible()
await expect(page.getByRole("link", {name: "Space Lounge"})).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 — // toHaveCount(0) is equally satisfied by an element that has not loaded yet, so wait for both.
// 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.
await expect.poll(() => deliveredByOther(event => event.content === "only in other")).toBe(true) await expect.poll(() => deliveredByOther(event => event.content === "only in other")).toBe(true)
await expect await expect
.poll(() => deliveredByOther(event => event.tags.some(spec(["name", "Other Lounge"])))) .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 page.getByRole("link", {name: "Other Lounge"}).click()
await expect(page).toHaveURL(new RegExp(`${roomPath(other.url, "lounge")}$`)) 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 // Two entries back: the other space's landing page, then the room this started on.
// used to replace that room's entry rather than push one, so the second step overshot it.
await page.goBack() await page.goBack()
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 page.getByRole("link", {name: "Space Garden"}).click()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`)) 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 // The space's entry page is the room you are on, so this navigates nowhere and replaces.
// rather than push. One step back is the room this started on, not the one it never left.
await page.locator('.primary-nav [data-tip^="space"]').click() await page.locator('.primary-nav [data-tip^="space"]').click()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`)) 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(page).toHaveURL(new RegExp(`${roomPath(space.url, "garden")}$`))
await expect(drawer).toHaveCount(0) 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 // The content is asserted as well as the url, since a back that only rewrites the url would pass.
// 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.
await page.goBack() await page.goBack()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`)) 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() 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 // The rail is in the menu on a phone, and navigating out from under it would close it.
// is picked after it. Navigating out from under the menu would close it on the first tap.
await drawer.locator('.primary-nav [data-tip^="other"]').click() await drawer.locator('.primary-nav [data-tip^="other"]').click()
await expect(page).toHaveURL(new RegExp(spacePath(other.url))) 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(page).toHaveURL(new RegExp(`${roomPath(other.url, "garden")}$`))
await expect(drawer).toHaveCount(0) 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 // The space she started in has a page behind it now, so picking it needs no second tap.
// menu has nothing left to ask.
await page.getByRole("button", {name: "Open space menu"}).click() await page.getByRole("button", {name: "Open space menu"}).click()
await drawer.locator('.primary-nav [data-tip^="space"]').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") const space = scenario.space("space")
// A relay whose document claims no nip-29. The entry path used to read that as "open the chat // A relay whose document claims no nip-29. The document arrives after the first navigation.
// 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.
const relayInfo = {[space.url]: {supported_nips: [1, 11, 42]}} const relayInfo = {[space.url]: {supported_nips: [1, 11, 42]}}
const page = await as(users.alice, spacePath(space.url), {relayInfo}) 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"}), page.locator("article header").getByRole("heading", {name: "Tending the Garden"}),
).toBeVisible() ).toBeVisible()
// The rebuild this guards against landed 20ms after the first build, which is before the article // The rebuild this guards against landed 20ms after the first build.
// itself is on screen on a slower run.
await page.waitForTimeout(250) await page.waitForTimeout(250)
await expectPageBuilds(page, 1) await expectPageBuilds(page, 1)
// Another article is the same route with different params, which SvelteKit answers by keeping the // Another article is the same route with different params, which SvelteKit answers by keeping the page.
// page it has. The page reads the address it renders once, so this one does have to be rebuilt.
await page.getByRole("link", {name: "Repotting in Winter"}).click() await page.getByRole("link", {name: "Repotting in Winter"}).click()
await expect( 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() 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 // The whole message is a button too, and its accessible name carries the author's.
// name on its own is the one that opens the profile.
await message(page, "anyone seen the anchor") await message(page, "anyone seen the anchor")
.getByRole("button", {name: "Bob Barnacle", exact: true}) .getByRole("button", {name: "Bob Barnacle", exact: true})
.click() .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 page.getByRole("button", {name: "View Full Profile"}).click()
await expect(page).toHaveURL(new RegExp(`${profilePath(users.bob.pubkey)}$`)) 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 // The modal gave its entry back before the profile page pushed its own, so one step back is the room.
// 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.
await page.goBack() await page.goBack()
await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`)) await expect(page).toHaveURL(new RegExp(`${roomPath(space.url, "lounge")}$`))

View file

@ -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. // A handle to a seeded event, which only reads once seed() has drained its queue.
type Seeded = {readonly id: string} type Seeded = {readonly id: string}
// The relay picker, which has no heading of its own — and is pushed over the list modal rather // The relay picker is pushed over the list modal, so it is the only dialog in the dom.
// than alongside it, so it is the only dialog in the dom while it is open.
const relayPicker = (page: Page) => topDialog(page) const relayPicker = (page: Page) => topDialog(page)
const relayCard = (scope: Locator, name: string) => scope.locator(".card").filter({hasText: name}) 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 // A toast clears after five seconds, which outlasts the batch a saved setting reaches disk in.
// mounts — so a reload only sees the new value after the batch has been flushed. A toast clears
// itself after five seconds, which is longer than the batch window, so waiting it out is what
// makes the assertion that follows about persistence rather than about timing.
const waitForToastToClear = (page: Page) => expect(toast(page)).toHaveCount(0) 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 // A Field lays its slider out under the row holding the label and the current value.
// around both is what a slider is named from.
const requestsCard = (page: Page) => page.locator(".card").filter({hasText: "Message Requests"}) const requestsCard = (page: Page) => page.locator(".card").filter({hasText: "Message Requests"})
const requestSliders = (page: Page) => requestsCard(page).locator('input[type="range"]') 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}) => { await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")
// A second relay for the picker to offer. Nobody is a member of it — what puts a relay in the // A relay is offered as a suggestion once its nip-11 document has been fetched.
// suggestion pool is its nip-11 document having been fetched, and every scenario relay is an
// indexer, so bob's client reads this one at startup.
relay("other") relay("other")
space.room("general", {name: "General"}) space.room("general", {name: "General"})
@ -57,8 +50,7 @@ test("US-084 block a relay you never want used", async ({seed, as}) => {
await expect(blocked).toContainText("1 Blocked") 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 // It was the picker's only offer a moment ago, which is what makes its absence about the block.
// the picker's only offer a moment ago, which is what makes its absence about the block.
await blocked.click() await blocked.click()
await dialog(page, "Blocked Relays").getByRole("button", {name: "Add Relays"}).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.getByText("125%")).toBeVisible()
await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/) 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 // Moving the slider is the save.
// without anything else having been pressed.
await page.reload() await page.reload()
await expect(page.locator("html")).toHaveAttribute("style", /font-size:\s*1\.25rem/) 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.getByPlaceholder("user@domain.com").fill("alice@example.test")
await address.getByRole("button", {name: "Save Changes"}).click() 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 // The dialog closes itself once the profile has gone out, and the page behind it has its own Save.
// "Save Changes" — so wait it out rather than leaving that name ambiguous.
await expect(address).toHaveCount(0) await expect(address).toHaveCount(0)
await expect(page.getByText("alice@example.test")).toBeVisible() await expect(page.getByText("alice@example.test")).toBeVisible()
await expect(page.getByText("Not set")).toHaveCount(0) 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(address).toHaveCount(0)
await expect(page.getByText("Not set")).toBeVisible() 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 // The same utility classes land on other rows, so this is pinned to the zap form's input rows.
// other rows (a button's spinner), so pin it to the zap-amounts form's rows that hold an input.
const zapForm = page.locator("form").filter({hasText: "Zap Amounts"}) const zapForm = page.locator("form").filter({hasText: "Zap Amounts"})
const presets = zapForm.locator("div.items-center.gap-2:has(input)") 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) await waitForToastToClear(page)
// Discarding puts back the last saved values, which are still alice's original four — the zero // Discarding puts back the last saved values, which are still alice's original four.
// never reached them.
await page.getByRole("button", {name: "Discard Changes"}).click() await page.getByRole("button", {name: "Discard Changes"}).click()
await expect(presets).toHaveCount(4) await expect(presets).toHaveCount(4)

View file

@ -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() 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. // 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}) 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 // The drawer stops short of the bottom bar, and playwright's hit-target check is what proves it.
// short of rather than covering. Playwright's hit-target check is what proves it: a drawer over
// the bar would take the click itself.
const closeButton = page.getByRole("button", {name: "Close space menu"}) const closeButton = page.getByRole("button", {name: "Close space menu"})
await closeButton.click() 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) await expect(drawer).toHaveCount(0)
// The button is the bottom bar's rather than the page's, so it still opens the menu from a page // The button is the bottom bar's rather than the page's, so it opens on the last space visited.
// that is in no space at all, on the last space the reader was in.
await page.goto("/chat") await page.goto("/chat")
await page.getByRole("button", {name: "Open space menu"}).click() await page.getByRole("button", {name: "Open space menu"}).click()

View file

@ -4,9 +4,7 @@ import type {Page} from "@playwright/test"
import type {SeededSpace} from "../harness" import type {SeededSpace} from "../harness"
import {expect, pathPattern, readCachedEvents, roomPath, spacePath, test, users} 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 // Room lists reach indexeddb in three-second batches, so a spec about a reload waits on this first.
// from. Room lists reach indexeddb in three-second batches with nothing in the ui to say when one
// has landed, so a spec about what survives a reload waits on this before it reloads.
const cachedFirstSpace = async (page: Page, pubkey: string) => { const cachedFirstSpace = async (page: Page, pubkey: string) => {
const events = (await readCachedEvents(page, pubkey)).filter(event => event.kind === ROOMS) const events = (await readCachedEvents(page, pubkey)).filter(event => event.kind === ROOMS)
const newest = sortBy(event => -event.created_at, events)[0] 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] 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 // The rail shows icons and no text, so a row is read by the tooltip naming its relay.
// spaceNavItem in notifications.spec.ts for why that is a tooltip rather than an accessible name.
const railSpaces = (page: Page) => page.locator(".primary-nav [draggable=true]") const railSpaces = (page: Page) => page.locator(".primary-nav [draggable=true]")
const expectRailFirst = (page: Page, name: string) => const expectRailFirst = (page: Page, name: string) =>
@ -24,11 +21,7 @@ const expectRailFirst = (page: Page, name: string) =>
new RegExp(`^${name}`), new RegExp(`^${name}`),
) )
// A drop reorders the list in place and publishes a new room list behind it, so the order on screen // A drop reorders the list in place and publishes behind it, so the copy on disk is the round trip.
// 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.
const expectReordered = async (page: Page, pubkey: string, space: SeededSpace) => { const expectReordered = async (page: Page, pubkey: string, space: SeededSpace) => {
await expectRailFirst(page, space.name) await expectRailFirst(page, space.name)
await expect.poll(() => cachedFirstSpace(page, pubkey)).toBe(space.url) 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 other = scenario.space("other")
const unsigned = scenario.space("unsigned") const unsigned = scenario.space("unsigned")
// The spaces page discovers unjoined spaces by pulling the room lists of the pubkeys it // The spaces page discovers unjoined spaces by pulling the room lists of its bootstrap pubkeys.
// bootstraps from, so pointing that at bob is what puts his other space in front of alice.
const page = await as(users.alice, "/spaces", {env: {VITE_DEFAULT_PUBKEYS: users.bob.pubkey}}) 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. // 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) await expectRailFirst(page, space.name)
// Html5 drag and drop, dispatched rather than mimed with the mouse: chromium's synthetic drag // Chromium's synthetic drag starts and moves it but never delivers the drop the reorder needs.
// starts the drag and moves it, but never delivers the drop the reorder is committed in, so the
// row would snap back to where it came from.
const dataTransfer = await page.evaluateHandle(() => new DataTransfer()) const dataTransfer = await page.evaluateHandle(() => new DataTransfer())
const rail = railSpaces(page) 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.getByRole("button", {name: "Join Space"})).toBeDisabled()
await expect(page.getByText("You're about to join:")).toHaveCount(0) 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 // An absolute platform url is an off-origin navigation, and parseInviteLink reads query params.
// off-origin navigation, and parseInviteLink only ever reads its query params.
await invite.fill("https://app.flotilla.social/join?r=other.test&c=") await invite.fill("https://app.flotilla.social/join?r=other.test&c=")
await expect(page.getByText("You're about to join:")).toBeVisible() 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 other = scenario.space("other")
const page = await as(users.alice, "/") 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 // A space's own path redirects, and a modal raised while that navigation is in flight is lost.
// 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.
let held = 0 let held = 0
await page.context().route( 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 closed = relay("closed")
const space = relay("space") const space = relay("space")
// This relay refuses a join that carries no claim. A claim is not an event a scenario can // The relay only registers a claim through the nip-86 method the invite dialog calls.
// 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.
closed.room("lobby", {name: "Lobby"}) closed.room("lobby", {name: "Lobby"})
space.room("general", {name: "General"}) 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 closed = scenario.space("closed")
const space = scenario.space("space") const space = scenario.space("space")
// Administering a relay is not the same as belonging to it, so the space asks admin to join // Administering a relay is not the same as belonging to it.
// like anyone else — and issues him an invite code all the same.
const admin = await as(users.admin, spacePath(closed.url) + "/about") const admin = await as(users.admin, spacePath(closed.url) + "/about")
await admin.locator("form").getByRole("button", {name: "Go back"}).click() 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/) await expect(bob).toHaveURL(/\/home/)
// In-app rather than a fresh load: leaving the space is published in the background, and a // In-app rather than a fresh load: leaving is published in the background and reaches disk late.
// page that reloads before it reaches disk reads the list he had a moment ago.
await bob.getByRole("link", {name: "All Spaces"}).click() await bob.getByRole("link", {name: "All Spaces"}).click()
await expect(railSpaces(bob)).toHaveCount(0) 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/) 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 // In-app rather than a fresh load: the updated list is published in the background.
// that reloads before it reaches disk reads the old address back.
await alice.getByRole("link", {name: "All Spaces"}).click() await alice.getByRole("link", {name: "All Spaces"}).click()
await expect(railSpaces(alice)).toHaveCount(1) await expect(railSpaces(alice)).toHaveCount(1)
@ -410,8 +392,7 @@ test("US-014 leave a space", async ({seed, as}) => {
await expect(page).toHaveURL(/\/home/) await expect(page).toHaveURL(/\/home/)
// In-app rather than a fresh load: leaving is published in the background, and a page that // In-app rather than a fresh load: leaving is published in the background.
// reloads before it reaches disk reads the list he had a moment ago.
await page.getByRole("link", {name: "All Spaces"}).click() await page.getByRole("link", {name: "All Spaces"}).click()
await expect(railSpaces(page)).toHaveCount(0) 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") const space = scenario.space("space")
// Contact, terms, privacy and the limitation warnings are nip-11 fields zooid doesn't publish // 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.
const relayInfo = { const relayInfo = {
[space.url]: { [space.url]: {
icon: "https://space.test/icon.png", 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") const space = scenario.space("space")
// The directory rather than a feed: search is reachable from every page in a space, and this is // The directory rather than a feed, since a result's own text can't be on the page behind it.
// one where a result's own text can't also be on the page behind the dialog.
const page = await as(users.alice, spacePath(space.url) + "/directory") const page = await as(users.alice, spacePath(space.url) + "/directory")
await page.locator(".secondary-nav").getByRole("button", {name: "Search"}).click() await page.locator(".secondary-nav").getByRole("button", {name: "Search"}).click()

1
src/app.d.ts vendored
View file

@ -2,7 +2,6 @@ import "@poppanator/sveltekit-svg/dist/svg"
import "vite-plugin-pwa/pwa-assets" import "vite-plugin-pwa/pwa-assets"
// See https://kit.svelte.dev/docs/types#app // See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}

View file

@ -114,13 +114,11 @@ export const publishJoinRequest = (url: string, claim?: string) => {
export const publishLeaveRequest = (url: string) => export const publishLeaveRequest = (url: string) =>
command(writer(RelayLeave).forceRoutes(relay(url))).then(publish) command(writer(RelayLeave).forceRoutes(relay(url))).then(publish)
// A relay answers a re-sent request with "duplicate:" and a membership it already has with // "duplicate:" and "already a member" both leave us where we wanted to be.
// "already a member" — both leave us where we wanted to be, so only anything else is a refusal.
const isMembershipRefusal = (error: string) => const isMembershipRefusal = (error: string) =>
Boolean(error) && !error.startsWith("duplicate:") && !error.includes("already") 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 // Joining takes two publishes: the NIP-29 request the relay can refuse, and the user's room list.
// own room list, which is what puts the room in their sidebar. `code` is a room invite code.
export const joinRoom = async (url: string, h: string, code?: string) => { export const joinRoom = async (url: string, h: string, code?: string) => {
const eventWriter = writer(RoomJoin).setRoom(url, h) const eventWriter = writer(RoomJoin).setRoom(url, h)
@ -401,8 +399,7 @@ export class Access {
sleep(300), sleep(300),
]) ])
// A relay that reports methods relay-wide rather than per-user can still come back // A relay reporting methods relay-wide can still come back "blocked" for this user.
// "blocked" for this particular user — treat that as having no claim.
if (methods?.includes("createclaim")) { if (methods?.includes("createclaim")) {
const {result: claims} = await management.listClaims() const {result: claims} = await management.listClaims()

View file

@ -6,8 +6,7 @@ import {deriveEventsForUrl} from "@app/repository"
// Action items (admin review queue) // Action items (admin review queue)
// A report is resolved by banning the event it names, a join request by allowing the pubkey, so // A report is resolved by banning the event it names, a join request by allowing the pubkey.
// the queue holds whichever of the two the user can actually act on.
export const deriveSpaceActionItems = (url: string) => export const deriveSpaceActionItems = (url: string) =>
derived( derived(
[ [

View file

@ -1,7 +1,6 @@
const WORDS_PER_MINUTE = 200 const WORDS_PER_MINUTE = 200
// Articles are markdown, so this counts syntax as words too. That's close enough for an estimate, // Articles are markdown, so this counts syntax as words too.
// and cheaper than parsing the content a second time just to strip it.
export const displayReadingTime = (content: string) => { export const displayReadingTime = (content: string) => {
const words = content.trim().split(/\s+/).filter(Boolean).length const words = content.trim().split(/\s+/).filter(Boolean).length
const minutes = Math.max(1, Math.round(words / WORDS_PER_MINUTE)) const minutes = Math.max(1, Math.round(words / WORDS_PER_MINUTE))

View file

@ -119,8 +119,7 @@ export const startsOnDay = (event: TrustedEvent, day: Date) => {
return Boolean(start && isSameDay(secondsToDate(start), day)) 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 // A malformed end date could span an unbounded number of days, so stop after a year.
// end date could span an unbounded number of days, so stop after a year.
export const groupEventsByDay = (events: TrustedEvent[]) => { export const groupEventsByDay = (events: TrustedEvent[]) => {
const result = new Map<string, TrustedEvent[]>() const result = new Map<string, TrustedEvent[]>()
@ -177,8 +176,7 @@ export type CalendarBar = {
const daysApart = (a: Date, b: Date) => const daysApart = (a: Date, b: Date) =>
Math.round((a.getTime() - b.getTime()) / (24 * 60 * 60 * 1000)) 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 // Multi-day events spanning `days` are laid out as bars, packed into as few lanes as possible.
// overlapping ones into as few vertical lanes as possible.
export const layoutMultiDayBars = ( export const layoutMultiDayBars = (
days: Date[], days: Date[],
eventsByDay: Map<string, TrustedEvent[]>, eventsByDay: Map<string, TrustedEvent[]>,
@ -247,8 +245,7 @@ export const deriveRsvps = (event: TrustedEvent) => deriveEvents([makeRsvpFilter
export const getRsvpStatus = (rsvp: TrustedEvent) => tagValue(tagSpec("status"), rsvp.tags) 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 // A relay can still hold a replaced RSVP, so the newest per person is the one that counts.
// newest per person is the one that counts.
export const getRsvpsByStatus = (rsvps: TrustedEvent[]) => { export const getRsvpsByStatus = (rsvps: TrustedEvent[]) => {
const latest = uniqBy( const latest = uniqBy(
rsvp => rsvp.pubkey, rsvp => rsvp.pubkey,

View file

@ -10,25 +10,14 @@ import {deriveEventsForUrl} from "@app/repository"
export const LIVEKIT_PARTICIPANTS = 39004 export const LIVEKIT_PARTICIPANTS = 39004
/** /** The lower aspect bound is 1:1 on landscape and 3:4 on portrait, and the upper is 16:9. */
* Aspect ratio constraints for tiles. The lower bound is dynamic
* (1:1 on landscape, 3:4 on portrait); the upper bound is 16:9.
*/
const TILE_ASPECT_PORTRAIT = 3 / 4 const TILE_ASPECT_PORTRAIT = 3 / 4
const TILE_ASPECT_LANDSCAPE = 16 / 9 const TILE_ASPECT_LANDSCAPE = 16 / 9
const TILE_GAP = 8 const TILE_GAP = 8
/** /** Below this the grid scrolls rather than forcing portrait tiles, which is ~6-8 tiles on a portrait phone. */
* 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.
*/
const MIN_TILE_HEIGHT = 120 const MIN_TILE_HEIGHT = 120
/**
* A single row in an adaptive tile grid.
*/
export type TileRow = { export type TileRow = {
columnCount: number columnCount: number
tileWidth: number tileWidth: number
@ -38,24 +27,14 @@ export type TileRow = {
aspectRatio: number aspectRatio: number
} }
/** /** All tiles share one size. Full rows fill the container width and a partial row is centered. */
* 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)
*/
export type AdaptiveTileGrid = { export type AdaptiveTileGrid = {
rows: TileRow[] rows: TileRow[]
totalWidth: number totalWidth: number
totalHeight: number totalHeight: number
} }
/** /** Score for comparing candidate layouts. Lower is better. */
* Score for comparing candidate layouts. Lower is better.
* Uses named fields instead of opaque array "keys" (per review feedback).
*/
type LayoutScore = { type LayoutScore = {
/** Penalty for vertical overflow: 0 if none, else huge */ /** Penalty for vertical overflow: 0 if none, else huge */
verticalOverflowPenalty: number verticalOverflowPenalty: number
@ -88,11 +67,7 @@ const compareScores = (a: LayoutScore, b: LayoutScore): number => {
return 0 return 0
} }
/** /** The largest tile that fits, shrunk to whichever dimension is more constraining. */
* 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.
*/
const fitTile = (availWidth: number, availHeight: number, minAspect: number) => { const fitTile = (availWidth: number, availHeight: number, minAspect: number) => {
const fillAspect = availWidth / availHeight const fillAspect = availWidth / availHeight
const aspectRatio = Math.max(minAspect, Math.min(TILE_ASPECT_LANDSCAPE, fillAspect)) 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} return {tileWidth: availWidth, tileHeight, aspectRatio}
} }
/** /** A candidate grid for a given column count, with the partial last row centered. */
* 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.
*/
const buildCandidate = ( const buildCandidate = (
tileCount: number, tileCount: number,
columnCount: number, columnCount: number,
@ -155,21 +126,7 @@ const buildCandidate = (
return {rows, totalWidth, totalHeight} return {rows, totalWidth, totalHeight}
} }
/** /** Scored on overflow first, then whitespace, then distance from 16:9, then tile size. */
* 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
*/
export const computeAdaptiveGrid = ( export const computeAdaptiveGrid = (
tileCount: number, tileCount: number,
containerWidth: number, containerWidth: number,
@ -284,8 +241,7 @@ export const deriveIsCallActiveElsewhere = (url: string | undefined, h: string |
!($targetRoom.url === url && $targetRoom.h === h), !($targetRoom.url === url && $targetRoom.h === h),
) )
// leaveVoiceRoom no-ops during Joining, since no session exists yet to leave — cancel the // leaveVoiceRoom no-ops during Joining, so cancel the in-flight join instead.
// in-flight join instead, otherwise ending a call that is still connecting does nothing.
export const endCall = async () => { export const endCall = async () => {
const engine = await import("@app/callEngine") const engine = await import("@app/callEngine")

View file

@ -1,7 +1,4 @@
/** /** Voice rooms via LiveKit. Voice does not work on localhost in Firefox, where ICE candidate gathering fails. */
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
*/
import { import {
DisconnectReason, DisconnectReason,
Room as LiveKitRoom, Room as LiveKitRoom,
@ -58,13 +55,11 @@ export const joinVoiceRoom = async (
const signal = controller.signal const signal = controller.signal
const isActive = () => joinAbortController === controller const isActive = () => joinAbortController === controller
// Self-cleaning controller: aborted in finally so whenTimeout/whenAborted // Aborted in finally, so whenTimeout and whenAborted clear their timers once the races settle.
// helpers clear their timers/listeners once the races below have settled.
const settle = new AbortController() const settle = new AbortController()
try { try {
// Tear down any existing session before joining. Bound it so a slow leave // Camera and screenshare renegotiation can take ~15s, so a slow leave is bounded.
// (camera/screenshare renegotiation can take ~15s) cannot block this join.
if (get(currentCallSession)) { if (get(currentCallSession)) {
await Promise.race([ await Promise.race([
leaveVoiceRoom(), leaveVoiceRoom(),
@ -134,8 +129,7 @@ export const joinVoiceRoom = async (
syncParticipantMedia(p) syncParticipantMedia(p)
} }
// Bounded against timeout/abort inside setUpMicrophone: a stuck permission // A stuck permission prompt resolves to muted rather than hanging the join.
// prompt resolves to muted rather than hanging the join forever.
const muted = await setUpMicrophone( const muted = await setUpMicrophone(
startMuted, startMuted,
preferredMicId, preferredMicId,
@ -144,8 +138,7 @@ export const joinVoiceRoom = async (
settle.signal, settle.signal,
) )
// A cancel during the mic step must tear down the connected room rather // A cancel during the mic step leaks the connected room unless it is torn down here.
// than leaking it.
if (signal.aborted) { if (signal.aborted) {
teardownRoom(liveKitRoom) teardownRoom(liveKitRoom)
throw new AbortError() throw new AbortError()
@ -208,14 +201,7 @@ export const leaveVoiceRoom = async () => {
// Always tear down this room's connection and listeners. // Always tear down this room's connection and listeners.
teardownRoom(session.livekit) teardownRoom(session.livekit)
// Only reset shared UI state if this session is still current. A slow leave // LocalTrackUnpublished replaces the store value with a new object, so compare the LiveKit room.
// 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.
if (get(currentCallSession)?.livekit === session.livekit) { if (get(currentCallSession)?.livekit === session.livekit) {
callState.set(CallState.Disconnected) callState.set(CallState.Disconnected)
callMicMuted.set(true) callMicMuted.set(true)
@ -314,29 +300,18 @@ export const switchCallActiveDevice = async (
} }
} }
// The room whose events are allowed to mutate shared state. Abandoned rooms // The room whose events may mutate shared state, so an abandoned one cannot clobber it.
// (after switching calls or an engine reconnect give-up) must not clobber it.
let activeRoom: LiveKitRoom | undefined let activeRoom: LiveKitRoom | undefined
let reconnectTimeout: ReturnType<typeof setTimeout> | undefined let reconnectTimeout: ReturnType<typeof setTimeout> | undefined
let reconnectAttempt = 0 let reconnectAttempt = 0
// Captured from the dropped session so a full rejoin (as opposed to LiveKit's // A full rejoin restores mic state from these, unlike LiveKit's reconnect, which reuses its tracks.
// own internal reconnect, which reuses the existing tracks) restores the
// user's mic state instead of always rejoining muted.
let reconnectMicMuted = true let reconnectMicMuted = true
let reconnectMicDeviceId: string | undefined let reconnectMicDeviceId: string | undefined
let joinAbortController: AbortController | undefined let joinAbortController: AbortController | undefined
let hadCallSession = false let hadCallSession = false
let audioResumeListener: Promise<PluginListenerHandle> | undefined let audioResumeListener: Promise<PluginListenerHandle> | undefined
/** /** Locking the screen can suspend mic capture without ending the track, and LiveKit only guards video. */
* 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.
*/
currentCallSession.subscribe(session => { currentCallSession.subscribe(session => {
if (session) { if (session) {
hadCallSession = true hadCallSession = true
@ -362,8 +337,7 @@ const teardownRoom = (livekit: LiveKitRoom) => {
activeRoom = undefined activeRoom = undefined
} }
// Dropping the listeners keeps TrackUnsubscribed from removing the hidden // Dropping the listeners first keeps TrackUnsubscribed from removing these audio elements.
// audio elements onTrackSubscribed appended, so detach them here instead.
for (const participant of livekit.remoteParticipants.values()) { for (const participant of livekit.remoteParticipants.values()) {
for (const publication of participant.audioTrackPublications.values()) { for (const publication of participant.audioTrackPublications.values()) {
publication.track?.detach().forEach(el => el.remove()) publication.track?.detach().forEach(el => el.remove())
@ -475,8 +449,7 @@ const setUpMicrophone = async (
]) ])
muted = false muted = false
} catch (e) { } catch (e) {
// Timeout or microphone rejection: join muted, the call is still usable. A // A timeout or a rejected microphone joins muted, and only a genuine abort reaches the caller.
// genuine abort is surfaced to the caller so it can tear down the room.
if (e instanceof AbortError) { if (e instanceof AbortError) {
throw e throw e
} }
@ -500,11 +473,7 @@ const reacquireMicrophoneIfNeeded = async () => {
return return
} }
// Mirrors LiveKit's own (mobile-only, video-track-only) reacquisition // A capture device that died silently still reports readyState "live", but flips `muted` on the track.
// 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.
const {mediaStreamTrack} = track const {mediaStreamTrack} = track
const needsReacquisition = const needsReacquisition =
mediaStreamTrack.readyState !== "live" || mediaStreamTrack.muted || !mediaStreamTrack.enabled mediaStreamTrack.readyState !== "live" || mediaStreamTrack.muted || !mediaStreamTrack.enabled
@ -515,8 +484,7 @@ const reacquireMicrophoneIfNeeded = async () => {
try { try {
await track.restartTrack() await track.restartTrack()
} catch { } catch {
// Best-effort: the user can still recover via mute/unmute or by // Mute and unmute or a rejoin recovers the mic if this fails.
// rejoining if reacquiring the mic fails here.
} }
} }
@ -579,14 +547,11 @@ const makeOnRoomDisconnected = (livekit: LiveKitRoom) => (reason?: DisconnectRea
return return
} }
// Livekit unsubscribes remote tracks before emitting Disconnected, so // Livekit unsubscribes remote tracks before Disconnected, so their audio elements are already gone.
// onTrackUnsubscribed has already removed their audio elements by now.
activeRoom = undefined activeRoom = undefined
livekit.removeAllListeners() livekit.removeAllListeners()
// Capture mic state before resetting it, so a subsequent full rejoin (see // Captured before the reset, so a full rejoin restores it instead of coming back muted.
// scheduleReconnect/attemptReconnect below) can restore it instead of
// silently coming back muted regardless of what the user had set.
reconnectMicMuted = get(callMicMuted) reconnectMicMuted = get(callMicMuted)
reconnectMicDeviceId = livekit.getActiveDevice(DeviceKind.AudioInput) reconnectMicDeviceId = livekit.getActiveDevice(DeviceKind.AudioInput)

View file

@ -34,9 +34,7 @@ export const makeChatId = (pubkeys: string[]) => {
export const splitChatId = (id: string) => getChatPubkeys(id.split(",")) 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 // Anything else decrypted out of a wrap addressed to the user is a rumor about other people.
// 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.
export const isUserMessage = (event: TrustedEvent) => { export const isUserMessage = (event: TrustedEvent) => {
const pubkey = user.get().pubkey const pubkey = user.get().pubkey
@ -105,13 +103,7 @@ export const chatsById = call(() => {
const removeEvents = (removed: Set<string>) => { const removeEvents = (removed: Set<string>) => {
let dirty = false let dirty = false
// Drop the removed ids from whatever chats hold them, matching on id alone. A removed event // deriveChat dedupes by reference, so an affected chat is replaced rather than mutated in place.
// 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.
for (const [chatId, chat] of chatsById) { for (const [chatId, chat] of chatsById) {
const messages = chat.messages.filter(e => !removed.has(e.id)) 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 // A listener bound to `app.get().repository` would keep reading the app login discarded.
// `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.
let repoUnsubscribe: (() => void) | undefined let repoUnsubscribe: (() => void) | undefined
const bindRepository = ($app: App) => { const bindRepository = ($app: App) => {
@ -187,8 +176,7 @@ export const CHAT_TABS = [
{value: ChatTab.Requests, label: "Requests"}, {value: ChatTab.Requests, label: "Requests"},
] ]
// Both thresholds are at least one: a pubkey nobody vouches for and a message carrying no nonce // Both thresholds are at least one, since a zero would wave the whole list through.
// have met nothing, so a zero would wave the whole list through.
export type ChatContext = { export type ChatContext = {
pubkey: string pubkey: string
follows: Set<string> follows: Set<string>
@ -198,23 +186,18 @@ export type ChatContext = {
minWot: number minWot: number
} }
// The wrap manager keeps everything about a wrap but its ciphertext, and proof of work is read off // The wrap manager drops the ciphertext, and proof of work reads off the id and the nonce tag.
// the id and the nonce tag, so an empty content stands in for what it dropped.
const getWrapPow = (wrap: WrapItem) => getPow({...wrap, content: ""}) 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 // The work is on the wrap rather than the rumor, and the best of several is what was paid.
// sealed inside. A message can arrive in more than one wrap; the best of them is what was paid.
const getMessagePow = (event: TrustedEvent) => const getMessagePow = (event: TrustedEvent) =>
Math.max(0, ...app.get().wrapManager.getWraps(event.id).map(getWrapPow)) 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 // Someone the user has a standing relationship with, written to or not.
// each other.
const isKnown = (pubkey: string, ctx: ChatContext) => const isKnown = (pubkey: string, ctx: ChatContext) =>
ctx.follows.has(pubkey) || ctx.members.has(pubkey) || (ctx.scores.get(pubkey) ?? 0) >= ctx.minWot 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 // Every other participant has to be known, so a stranger can't get in by adding the user to a group.
// 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.
export const isConversation = (chat: Chat, ctx: ChatContext) => { export const isConversation = (chat: Chat, ctx: ChatContext) => {
const others = remove(ctx.pubkey, chat.pubkeys) const others = remove(ctx.pubkey, chat.pubkeys)
@ -236,9 +219,7 @@ export const groupChatsByTab = (chats: Chat[], ctx: ChatContext): ChatsByTab =>
const userFollowList = deriveUserItem(FollowLists) const userFollowList = deriveUserItem(FollowLists)
// The spaces the user belongs to, read here rather than imported from @app/rooms: that module // Read here rather than imported from @app/rooms, which already cycles with @app/routes.
// and @app/routes already require each other, and joining the cycle leaves this one holding an
// uninitialized binding.
const userRoomList = deriveUserItem(RoomLists) const userRoomList = deriveUserItem(RoomLists)
const wotScores = fromApp($app => $app.use(Wot).scores(WotScope.Follows).$) const wotScores = fromApp($app => $app.use(Wot).scores(WotScope.Follows).$)

View file

@ -19,9 +19,7 @@ import {Domain, Network, RelayScopedDerivedPlugin, createSearch, projectFrom} fr
import type {IApp, Projection} from "@welshman/app" import type {IApp, Projection} from "@welshman/app"
import {fromApp, usePlugin} from "@app/core" import {fromApp, usePlugin} from "@app/core"
// NIP-CD definitions count where they're published, since a space curates its own command // `|` never appears in a url, and keeps these distinct from the `'`-separated room keys.
// namespace by controlling who may write to it. `|` never appears in a url, and keeps these
// distinct from the `'`-separated room keys.
const makeCommandKey = (url: string, address: string) => `${url}|${address}` const makeCommandKey = (url: string, address: string) => `${url}|${address}`
const splitCommandKey = (key: string): [string, string] => { const splitCommandKey = (key: string): [string, string] => {
@ -31,9 +29,7 @@ const splitCommandKey = (key: string): [string, string] => {
} }
export class Commands extends RelayScopedDerivedPlugin<CommandReader> { export class Commands extends RelayScopedDerivedPlugin<CommandReader> {
// Definitions are replaceable, so one pull per space keeps the repository current. The set // The set lives on the plugin, so logging in and swapping the repository starts over.
// lives on the plugin rather than the module so logging in, which swaps the app and its
// repository, starts over.
private pulled = new Set<string>() private pulled = new Set<string>()
constructor(app: IApp) { constructor(app: IApp) {
@ -79,9 +75,7 @@ export const getCommandsForTarget = (target: CommandScopeTarget) =>
.get() .get()
.filter(command => command.matches(target)) .filter(command => command.matches(target))
// A space's definitions are the same for everything rendered in it, so one store per url is // One store per url, and subscribing to it is what pulls the definitions.
// 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.
const commandsByUrl = new Map<string, Readable<CommandReader[]>>() const commandsByUrl = new Map<string, Readable<CommandReader[]>>()
const deriveCommandsForUrl = (url: string): Readable<CommandReader[]> => { const deriveCommandsForUrl = (url: string): Readable<CommandReader[]> => {
@ -106,16 +100,13 @@ const deriveCommandsForUrl = (url: string): Readable<CommandReader[]> => {
return store return store
} }
// The definitions a piece of content is read against: the ones its space publishes that also // Content outside a space has no url, and so no commands.
// scope to it. Content outside a space has no url, and so no commands.
export const deriveValidCommands = (target: CommandScopeTarget): Readable<CommandReader[]> => export const deriveValidCommands = (target: CommandScopeTarget): Readable<CommandReader[]> =>
derived(deriveCommandsForUrl(target.url ?? ""), $commands => derived(deriveCommandsForUrl(target.url ?? ""), $commands =>
$commands.filter(command => command.matches(target)), $commands.filter(command => command.matches(target)),
) )
// Without a qualifier an invocation targets every definition whose trigger matches, so more // Without a qualifier an invocation targets every definition whose trigger matches.
// 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.
export const getCommandsForInvocation = ( export const getCommandsForInvocation = (
available: CommandReader[], available: CommandReader[],
invocation: Pick<CommandInvocation, "command" | "pubkey">, invocation: Pick<CommandInvocation, "command" | "pubkey">,
@ -143,16 +134,13 @@ export const createCommandSearch = (target: CommandScopeTarget) =>
export const getCommandByAddress = (url: string, address: string) => export const getCommandByAddress = (url: string, address: string) =>
commands.get().get(makeCommandKey(url, address)) commands.get().get(makeCommandKey(url, address))
// An invocation in progress, as the composer understands it. `matches` holds every definition // `matches` holds every definition the text targets, and `command` is the one shaping the input.
// the text currently targets, so an ambiguous trigger is visible rather than guessed at, and
// `command` is the one whose arguments shape the input.
export type CommandDraft = { export type CommandDraft = {
invocation: CommandInvocation invocation: CommandInvocation
command: CommandReader command: CommandReader
matches: CommandReader[] matches: CommandReader[]
args: CommandArg[] args: CommandArg[]
// One entry per argument the text supplies, in order, so an argument already typed can be // One entry per argument the text supplies, in order.
// shown as bound or as wrong while the rest are still being entered
bindings: CommandArgBinding[] bindings: CommandArgBinding[]
activeIndex: number activeIndex: number
} }

View file

@ -52,13 +52,10 @@
let prevFirstEventId = "" let prevFirstEventId = ""
let initialScrollDone = false let initialScrollDone = false
// The item an in-progress centering has settled on. A profile name or a reaction count that // A name or a count resolving late shifts everything below it, so the item stays pinned.
// 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.
let pinnedTarget: HTMLElement | undefined = undefined let pinnedTarget: HTMLElement | undefined = undefined
// offsetTop is relative to the nearest positioned ancestor, which the scroll container itself // offsetTop is relative to the nearest positioned ancestor, which the scroll container isn't.
// isn't — comparing bounding rects instead keeps this correct regardless of that
const recenter = (target: HTMLElement) => { const recenter = (target: HTMLElement) => {
if (!element) { if (!element) {
return return
@ -80,11 +77,7 @@
pinnedTarget = undefined pinnedTarget = undefined
} }
// A resolved profile name, a reaction count, an RSVP tally, an avatar image — all arrive well // A ResizeObserver catches an image's own load-driven reflow, which a MutationObserver misses.
// 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.
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
if (pinnedTarget) { if (pinnedTarget) {
recenter(pinnedTarget) recenter(pinnedTarget)

View file

@ -25,9 +25,7 @@
const {url, date, events, context}: Props = $props() 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 // A modal's props are frozen at push time, so re-derive from the feed's own store.
// 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.
const dayEvents = $derived(groupEventsByDay($events).get(makeDayKey(date)) ?? []) const dayEvents = $derived(groupEventsByDay($events).get(makeDayKey(date)) ?? [])
const back = () => history.back() const back = () => history.back()

View file

@ -28,9 +28,7 @@
const {url, events, eventsByDay, date, context}: Props = $props() 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 // A modal's props are frozen at push time, so pass the feed's store rather than a filtered list.
// 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.
const showDay = (day: Date) => const showDay = (day: Date) =>
pushModal(CalendarDay, { pushModal(CalendarDay, {
url, url,

View file

@ -28,8 +28,7 @@
const {url, events, eventsByDay, date, context}: Props = $props() 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 // A modal's props are frozen at push time, so pass the feed's store rather than a filtered list.
// props are frozen at push time and wouldn't pick up an event created from inside it.
const showDay = (day: Date) => const showDay = (day: Date) =>
pushModal(CalendarDay, { pushModal(CalendarDay, {
url, url,

View file

@ -24,8 +24,7 @@
const {relay, h} = $derived($page.params) const {relay, h} = $derived($page.params)
const routeUrl = $derived(relay ? decodeRelay(relay) : undefined) const routeUrl = $derived(relay ? decodeRelay(relay) : undefined)
// The call's own room page already shows full controls (CallControlBar), so // CallControlBar already shows full controls on the call's own room page.
// the banner would just be redundant clutter there.
const isCallActiveElsewhere = $derived( const isCallActiveElsewhere = $derived(
deriveIsCallActiveElsewhere(routeUrl, typeof h === "string" ? h : undefined), deriveIsCallActiveElsewhere(routeUrl, typeof h === "string" ? h : undefined),
) )

View file

@ -55,8 +55,7 @@
const joiningHere = $derived($callState === CallState.Joining && isTargetingThisRoom) const joiningHere = $derived($callState === CallState.Joining && isTargetingThisRoom)
const connectedHere = $derived($callState === CallState.Connected && isTargetingThisRoom) const connectedHere = $derived($callState === CallState.Connected && isTargetingThisRoom)
// callTargetRoom isn't cleared on cancel/leave, so it can still point at this room // callTargetRoom isn't cleared on cancel or leave, so it can still point at this room.
// after the call has ended — check actual state, not just "is this the last target".
const showJoin = $derived(isVoiceRoom && !joiningHere && !connectedHere) const showJoin = $derived(isVoiceRoom && !joiningHere && !connectedHere)
const openJoinDialog = () => pushModal(VoiceRoomJoinDialog, {url, h}) const openJoinDialog = () => pushModal(VoiceRoomJoinDialog, {url, h})

View file

@ -141,8 +141,7 @@
addTemplate(DIRECT_MESSAGE, buffer.splice(0).join(""), tags) 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 // Kind 15 carries one image each per nip 17, and a second's sleep keeps the timestamps distinct.
// Sleep 1 second between each one to make sure timestamps are distinct
const thunks = await Promise.all( const thunks = await Promise.all(
Array.from(enumerate(templates)).map(([i, event]) => Array.from(enumerate(templates)).map(([i, event]) =>
$wraps.publish({ $wraps.publish({
@ -154,9 +153,7 @@
), ),
) )
// Only once the message exists. Publishing has to read each recipient's messaging relays first, // Publishing reads each recipient's messaging relays first, and a failed read throws before any thunk.
// 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.
clearParent() clearParent()
clearEventToEdit() clearEventToEdit()
@ -192,8 +189,7 @@
let eventToEdit: TrustedEvent | undefined = $state() let eventToEdit: TrustedEvent | undefined = $state()
let share: Maybe<Share> = $state() let share: Maybe<Share> = $state()
// Claim the share once we're on screen. Sharing into the conversation you're already looking at // Sharing into the conversation already on screen doesn't re-create this component.
// doesn't re-create this component, so this can't be read once on mount.
$effect(() => { $effect(() => {
if ($pendingShare) { if ($pendingShare) {
share = $pendingShare share = $pendingShare

View file

@ -65,8 +65,7 @@
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run()) const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
// Tiptap parses a string handed to insertContent as html, so an angle bracket in the // Tiptap parses a string handed to insertContent as html, so an angle bracket would eat the sentence.
// transcript would eat the rest of the sentence.
const insertTranscript = async (text: string) => { const insertTranscript = async (text: string) => {
const ed = await editor const ed = await editor

View file

@ -42,8 +42,7 @@
showStatus = true, showStatus = true,
}: Props = $props() }: Props = $props()
// Editing a listing hands this a new version of the event, so every value read off it has to be // Editing hands this a new version of the event, so every value is recomputed rather than captured.
// recomputed rather than captured when the component was created.
const classified = $derived(reader(Classified)(event)) const classified = $derived(reader(Classified)(event))
const h = $derived(classified.room()) const h = $derived(classified.room())
const topics = $derived(classified.topics() ?? []) const topics = $derived(classified.topics() ?? [])

View file

@ -19,8 +19,7 @@
const draft = $derived(describeCommandDraft($available, content)) const draft = $derived(describeCommandDraft($available, content))
// Without a qualifier an invocation reaches every executor whose trigger matches, so offer // Without a qualifier an invocation reaches every executor whose trigger matches.
// the choice rather than picking one silently.
const ambiguous = $derived((draft?.matches.length ?? 0) > 1 && !draft?.invocation.pubkey) const ambiguous = $derived((draft?.matches.length ?? 0) > 1 && !draft?.invocation.pubkey)
const activeArg = $derived(draft?.args[draft.activeIndex]) const activeArg = $derived(draft?.args[draft.activeIndex])
@ -36,8 +35,7 @@
return [] return []
}) })
// An argument the user has moved past that didn't parse. The one being typed is legitimately // The argument being typed is legitimately incomplete, so flagging it would blink on every keystroke.
// incomplete, so flagging it would just blink red on every keystroke.
const invalid = $derived( const invalid = $derived(
draft?.bindings.find((binding, i) => i < draft.activeIndex && !binding.value)?.arg, draft?.bindings.find((binding, i) => i < draft.activeIndex && !binding.value)?.arg,
) )

View file

@ -38,9 +38,7 @@
const selectFiles = () => editor.then(ed => ed.commands.selectFiles()) 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 // A reply to a room event is a NIP-22 comment, and a reply to a kind 1 note is a NIP-10 note.
// note is a NIP-10 note, which the outbox model routes to the note's author and back to the
// replier's own relays.
const renderReply = async (content: string, tags: string[][]) => { const renderReply = async (content: string, tags: string[][]) => {
if (url) { if (url) {
const eventWriter = writer(Comment) const eventWriter = writer(Comment)
@ -50,8 +48,7 @@
.setParentFromEvent(parent ?? event) .setParentFromEvent(parent ?? event)
.setProtected(await $relays.hasNip(url, 70)) .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 // An untagged comment isn't visible to the group, so the relay neither gates nor deletes it with the room.
// group at all, so the relay neither gates it with the room nor deletes it with it.
if (h) { if (h) {
eventWriter.setRoom(url, h) eventWriter.setRoom(url, h)
} }

View file

@ -63,9 +63,7 @@
url, url,
}: Props = $props() }: Props = $props()
// An invocation is plain text and carries no tags, so it's only recognizable against the // An invocation is plain text, so it is only recognizable against the space's own definitions.
// 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.
const available = deriveValidCommands({ const available = deriveValidCommands({
url, url,
kind: event.kind, kind: event.kind,

View file

@ -1,13 +1,11 @@
<script lang="ts" module> <script lang="ts" module>
import DOMPurify from "dompurify" import DOMPurify from "dompurify"
// Only the entity links linkEntities builds are internal. Testing for a leading slash would // A leading slash would also match a protocol-relative `//evil.com`.
// also match a protocol-relative `//evil.com`, which is very much not.
const isEntityPath = (href: string) => const isEntityPath = (href: string) =>
Boolean(href.match(/^\/n(event|ote|pub|profile|addr)1\w+$/)) Boolean(href.match(/^\/n(event|ote|pub|profile|addr)1\w+$/))
// DOMPurify's defaults are wider than markdown needs — they keep `style` (overlay and // DOMPurify's defaults keep `style` and `form`, so allow only what marked emits.
// click-jacking surface) and `form`. Allow only what marked actually emits.
const SANITIZE_OPTIONS = { const SANITIZE_OPTIONS = {
ALLOWED_TAGS: [ ALLOWED_TAGS: [
"a", "a",
@ -42,8 +40,7 @@
ALLOWED_ATTR: ["align", "alt", "href", "rel", "src", "start", "target", "title"], ALLOWED_ATTR: ["align", "alt", "href", "rel", "src", "start", "target", "title"],
} }
// DOMPurify has no option for target, so its docs point at this hook. It runs once at module // DOMPurify has no option for target, and its hooks live on the instance rather than per render.
// load rather than per render, since hooks live on the DOMPurify instance.
DOMPurify.addHook("afterSanitizeAttributes", node => { DOMPurify.addHook("afterSanitizeAttributes", node => {
if (node.tagName === "A" && !isEntityPath(node.getAttribute("href") ?? "")) { if (node.tagName === "A" && !isEntityPath(node.getAttribute("href") ?? "")) {
node.setAttribute("target", "_blank") node.setAttribute("target", "_blank")
@ -71,9 +68,7 @@
const entityPattern = /(nostr:)?n(event|ote|pub|profile|addr)\w{10,1000}/g const entityPattern = /(nostr:)?n(event|ote|pub|profile|addr)\w{10,1000}/g
// A display name is attacker-controlled, and it goes into markdown link syntax. Without this, // A display name is attacker-controlled, and `](` in one would re-point its own link.
// a name containing `](` re-points its own link (phishing) and a name containing `<` injects
// raw html. Escaping also means a name renders as written rather than as markdown.
const escapeMarkdown = (text: string) => text.replace(/[\\[\]<`*_]/g, "\\$&") const escapeMarkdown = (text: string) => text.replace(/[\\[\]<`*_]/g, "\\$&")
const pubkeyFromEntity = (entity: string) => { const pubkeyFromEntity = (entity: string) => {
@ -87,8 +82,7 @@
} }
} }
// Everyone the content mentions, so their names are asked for and awaited rather than read // A display is bech32 until the profile arrives, and nothing would parse the markdown again.
// once: a display is bech32 until the profile arrives, and nothing would parse it again.
const mentionedPubkeys = $derived( const mentionedPubkeys = $derived(
removeUndefined( removeUndefined(
Array.from(event.content.matchAll(entityPattern)).map(([match]) => Array.from(event.content.matchAll(entityPattern)).map(([match]) =>
@ -99,8 +93,7 @@
const displays = $derived(deriveDisplaysByPubkey(mentionedPubkeys, url)) const displays = $derived(deriveDisplaysByPubkey(mentionedPubkeys, url))
// Bech32 entities aren't markdown, so swap them for links before parsing. Profiles get their // Bech32 entities aren't markdown, so swap them for links before parsing.
// display name, so an article reads as prose rather than a wall of bech32.
const linkEntities = (markdown: string) => const linkEntities = (markdown: string) =>
markdown.replace(entityPattern, match => { markdown.replace(entityPattern, match => {
const entity = fromNostrURI(match) const entity = fromNostrURI(match)

View file

@ -41,10 +41,7 @@
const quote = deriveEvent(idOrAddress, hints) const quote = deriveEvent(idOrAddress, hints)
// Start with the hints we were handed, then widen to everything the router can work out: // Whoever quoted the event must have seen it, so the quoting event's relays are asked too.
// where the quoted event has been seen, its author's outbox, and — since whoever quoted it
// must have seen it — the relays the quoting event came from. Resolving that may take a
// round trip for the author's relay list, so only load again if the hints came up empty.
$router.resolver $router.resolver
.relays([ .relays([
...relaySelections(removeUndefined([url])), ...relaySelections(removeUndefined([url])),

View file

@ -50,8 +50,7 @@
let pending = true let pending = true
// Leaving without choosing throws the recording away, whether that was the discard button, the // Leaving without choosing throws the recording away.
// escape key or a navigation out of the conversation.
onDestroy(() => { onDestroy(() => {
if (pending) { if (pending) {
onDiscard() onDiscard()

View file

@ -28,8 +28,7 @@
} }
const start = async () => { const start = async () => {
// Granting microphone access can sit on a permission prompt for a while, so hold the button // A permission prompt can sit for a while, and a second click would open a stream nobody holds.
// until it resolves — a second click would open a stream nothing is left holding on to.
loading = true loading = true
try { try {
@ -48,8 +47,7 @@
recording = false recording = false
loud = false loud = false
// Hold the button until the recording has been spent, rather than offering a second one // Hold the button until the recording has been spent.
// behind the modal.
loading = true loading = true
choose() choose()
@ -98,8 +96,7 @@
await dictation.finished await dictation.finished
// A composer that has gone away has nowhere to put the transcript, so leave the dictation // A composer that has gone away has nowhere to put the transcript, so leave the dictation where it is.
// where it is for whichever one mounts next.
if (!destroyed) { if (!destroyed) {
if (dictation.error) { if (dictation.error) {
console.error(dictation.error) console.error(dictation.error)
@ -122,8 +119,7 @@
let destroyed = false let destroyed = false
let recording = $state(false) let recording = $state(false)
// Starts out in flight when a dictation is already waiting to be picked up, so that the composer // In flight from the start when a dictation is already waiting to be picked up.
// keeps rendering this button until the recording has been spent.
let loading = $state(Boolean(getDictation(key))) let loading = $state(Boolean(getDictation(key)))
let loud = $state(false) let loud = $state(false)
@ -139,8 +135,7 @@
dictating = recording || loading dictating = recording || loading
}) })
// Pick up a dictation an earlier composer left running, either where it was already transcribing // Pick up a dictation an earlier composer left running.
// or at the choice the speaker never got to make.
onMount(() => { onMount(() => {
const dictation = getDictation(key) const dictation = getDictation(key)

View file

@ -19,8 +19,7 @@
const {event, context, url, reactionClass = ""}: Props = $props() const {event, context, url, reactionClass = ""}: Props = $props()
// A reaction goes where the thing it is about lives: the space's own relay in a space, and // A reaction goes where its subject lives: the space's own relay, or wherever the event was seen.
// wherever the event has been seen outside one.
const getRelays = () => (url ? [url] : $router.resolver.relays([seen(event)])) const getRelays = () => (url ? [url] : $router.resolver.relays([seen(event)]))
const deleteReaction = async (reaction: TrustedEvent) => const deleteReaction = async (reaction: TrustedEvent) =>

View file

@ -68,8 +68,7 @@
.setParentFromEvent(event) .setParentFromEvent(event)
.setProtected(await $relays.hasNip(url, 70)) .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 // An untagged comment isn't visible to the group, so the relay neither gates nor deletes it with the room.
// group at all, so the relay neither gates it with the room nor deletes it with it.
if (h) { if (h) {
eventWriter.setRoom(url, h) eventWriter.setRoom(url, h)
} }

View file

@ -21,8 +21,7 @@
const openCreate = () => pushModal(RelayCreate) const openCreate = () => pushModal(RelayCreate)
// A pubkey with no tenant yet answers with a HostingError, which is the ordinary case for // A pubkey with no tenant answers with a HostingError.
// anyone who has never hosted a space.
onMount(async () => { onMount(async () => {
try { try {
relays = await listTenantRelays($user.pubkey) relays = await listTenantRelays($user.pubkey)

View file

@ -18,8 +18,7 @@
const {class: className = ""}: {class?: string} = $props() const {class: className = ""}: {class?: string} = $props()
// The hubs most of the user's follows publish to, which is the same set and the same limit // The same set and limit `syncFollowNetwork` reads the follows' relay lists from.
// `syncFollowNetwork` reads their lists from.
const RELAY_LIMIT = 8 const RELAY_LIMIT = 8
const context = makeFeedContext({ const context = makeFeedContext({
@ -36,8 +35,7 @@
let started = false let started = false
let stop: Maybe<() => void> let stop: Maybe<() => void>
// Kind 1 replies are left out - without their parent they read as half a conversation. Every // Kind 1 replies read as half a conversation without their parent.
// other kind here is a root, and an edited one is shown once, at its latest version.
const isRoot = (event: TrustedEvent) => const isRoot = (event: TrustedEvent) =>
event.kind !== NOTE || getReplyTags(event.tags).replies.length === 0 event.kind !== NOTE || getReplyTags(event.tags).replies.length === 0
@ -68,8 +66,7 @@
} }
} }
// The follow list is what the feed is made of, so it waits for one rather than asking about // The follow list is what the feed is made of, and follows added later don't rebuild it.
// nobody. Follows added later don't rebuild it - the feed keeps what is already on screen.
$effect(() => { $effect(() => {
if (!started && follows && follows.length > 0) { if (!started && follows && follows.length > 0) {
started = true started = true

View file

@ -17,8 +17,7 @@
const id = randomId() const id = randomId()
// Profile pictures are shown small (avatar-sized), so compress hard: this keeps // Compressed hard, so the base64 fallback fits in the profile event.
// the base64 fallback below small enough to embed directly in the profile event.
const compressOptions = {maxWidth: 128, maxHeight: 128, quality: 0.6, mimeType: "image/jpeg"} const compressOptions = {maxWidth: 128, maxHeight: 128, quality: 0.6, mimeType: "image/jpeg"}
const onDragEnter = () => { const onDragEnter = () => {

View file

@ -97,8 +97,7 @@
await downloadText("Nostr Secret Key.txt", cleanupCopy(instructions)) await downloadText("Nostr Secret Key.txt", cleanupCopy(instructions))
didDownload = true didDownload = true
} catch (e) { } catch (e) {
// Dismissing the native share sheet rejects with "Share canceled" — leave // Dismissing the native share sheet rejects with "Share canceled".
// the flow gated so the user can try again rather than showing an error.
if (!errorMessage(e).toLowerCase().includes("cancel")) { if (!errorMessage(e).toLowerCase().includes("cancel")) {
console.error(e) console.error(e)
pushToast({theme: "error", message: "We couldn't save your key. Please try again."}) pushToast({theme: "error", message: "We couldn't save your key. Please try again."})

View file

@ -37,8 +37,7 @@
const controller = new Nip46Controller({ const controller = new Nip46Controller({
onNostrConnect: async (response: Nip46ResponseWithResult) => { onNostrConnect: async (response: Nip46ResponseWithResult) => {
// Use the broker's current relays rather than the ones we started with, since // The signer may have asked us to switch relays during the handshake.
// the signer may have asked us to switch relays during the connection handshake.
await login( await login(
toSession(nip46, { toSession(nip46, {
clientSecret: controller.clientSecret, clientSecret: controller.clientSecret,

View file

@ -28,10 +28,7 @@
context.createMediaStreamSource(new MediaStream([t])).connect(analyser) context.createMediaStreamSource(new MediaStream([t])).connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount) const data = new Uint8Array(analyser.frequencyBinCount)
// Smooth into a plain local, not into `level` itself: reading the `$state` // Reading the `$state` this effect writes would make it depend on its own output.
// we also write would make this effect depend on its own output, so every
// frame would invalidate and re-run it, tearing down the AudioContext
// before it ever produced a reading.
let smoothed = 0 let smoothed = 0
const tick = () => { const tick = () => {

View file

@ -6,9 +6,7 @@
import Dialog from "@lib/components/Dialog.svelte" import Dialog from "@lib/components/Dialog.svelte"
import {getModal, getModalStack, navigate, popModal} from "@app/modal" import {getModal, getModalStack, navigate, popModal} from "@app/modal"
// A link inside a modal is SvelteKit's to handle, and it would stack the page it opens on // SvelteKit would stack the page a link opens on top of the modal's own history entry.
// top of the modal's own history entry. Hand it to `navigate`, which gives that entry back
// first.
beforeNavigate(navigation => { beforeNavigate(navigation => {
if (navigation.type === "link" && navigation.to && getModalStack().length > 0) { if (navigation.type === "link" && navigation.to && getModalStack().length > 0) {
navigation.cancel() navigation.cancel()

View file

@ -19,8 +19,7 @@
const {url, pin, minimal, ...props}: Props = $props() const {url, pin, minimal, ...props}: Props = $props()
// Encode nostr references as nostr: entities (i tags are already urls) so // Encoded as nostr: entities (i tags are already urls) so Content parses them into embeds.
// Content parses them into rich embeds rather than rendering raw ids/coords.
const reference = $derived(pin.reference()) const reference = $derived(pin.reference())
const content = $derived.by(() => { const content = $derived.by(() => {

View file

@ -91,8 +91,7 @@
reorderOptions(targetId) reorderOptions(targetId)
} }
// Dragover has already moved the option, so dropping only has to end the drag. Reordering again // Dragover has already moved the option, so reordering on drop would move it a second time.
// here would move it a second time, relative to the position it just took, undoing the move.
const onDrop = (e: DragEvent) => { const onDrop = (e: DragEvent) => {
e.preventDefault() e.preventDefault()
draggedOptionId = undefined draggedOptionId = undefined

View file

@ -36,8 +36,7 @@
const showSearch = () => pushModal(Search) const showSearch = () => pushModal(Search)
// The menu is reachable from every page, so it opens on the space the user is in, or the last one // The menu is reachable everywhere, so it opens on the space the user is in, or the last one.
// they were in when they're somewhere else.
const spaceUrl = $derived($lastSpaceUrl ?? PLATFORM_RELAYS[0] ?? $userSpaceUrls[0]) const spaceUrl = $derived($lastSpaceUrl ?? PLATFORM_RELAYS[0] ?? $userSpaceUrls[0])
const spaceMenuIsOpen = $derived(getModal()?.component === SpaceMenuDrawer) const spaceMenuIsOpen = $derived(getModal()?.component === SpaceMenuDrawer)

View file

@ -21,8 +21,7 @@
} }
} }
// Nothing fires on a change of scroll height, and the list has one whenever a space is joined // Nothing fires on a change of scroll height, which joining or leaving a space causes.
// or left, or the window resizes under it.
$effect(() => { $effect(() => {
const observer = new ResizeObserver(measure) const observer = new ResizeObserver(measure)

View file

@ -15,8 +15,7 @@
const status = $statuses.one(pubkey, removeUndefined([url])) const status = $statuses.one(pubkey, removeUndefined([url]))
// Empty content is how NIP-38 says there is nothing to report, and an expiration is the author // NIP-38 reports nothing with empty content, and an expiration asks relays to stop serving it.
// asking relays to stop serving it. Both read as no status at all.
const expiration = $derived($status ? getExpiration($status) : undefined) const expiration = $derived($status ? getExpiration($status) : undefined)
const text = $derived(expiration && expiration <= now() ? "" : $status?.content) const text = $derived(expiration && expiration <= now() ? "" : $status?.content)
const link = $derived($status?.tags.find(t => t[0] === "r")?.[1]) const link = $derived($status?.tags.find(t => t[0] === "r")?.[1])

View file

@ -11,8 +11,7 @@
const copy = () => clip(code) const copy = () => clip(code)
// qrcode is imported here rather than at the top of the module because svelte strips effect // Imported here because svelte strips effect bodies for SSR, leaving a top-level import unused.
// bodies when compiling for SSR, which would leave the import with no remaining use.
const draw = async (canvas: HTMLCanvasElement, wrapper: Element, code: string) => { const draw = async (canvas: HTMLCanvasElement, wrapper: Element, code: string) => {
const QRCode = await import("qrcode") const QRCode = await import("qrcode")

View file

@ -68,8 +68,7 @@
const reactions = derived(related, $related => filter(spec({kind: REACTION}), $related)) const reactions = derived(related, $related => filter(spec({kind: REACTION}), $related))
const receipts = derived(related, $related => filter(spec({kind: ZAP_RECEIPT}), $related)) const receipts = derived(related, $related => filter(spec({kind: ZAP_RECEIPT}), $related))
// A receipt can be a zap of either the event or the one it wraps; skip validating // Validating each zapper's lnurl is wasted where zaps aren't shown.
// (each zapper's lnurl) entirely when zaps won't even be shown.
const zaps = hideZaps const zaps = hideZaps
? readable<Zap[]>([]) ? readable<Zap[]>([])
: derived<typeof receipts, Zap[]>( : derived<typeof receipts, Zap[]>(

View file

@ -80,8 +80,7 @@
.slice(0, limit), .slice(0, limit),
) )
// Suggestions come from relay search, which only knows about relays whose NIP-11 // Relay search only knows relays whose NIP-11 has been fetched, so seed it from everyone's lists.
// document has been fetched — so seed it from everyone's relay lists.
onMount(() => { onMount(() => {
const urls = uniq($relayLists.all.get().flatMap(list => list.urls())) const urls = uniq($relayLists.all.get().flatMap(list => list.urls()))

View file

@ -39,8 +39,7 @@
if (error) { if (error) {
pushToast({theme: "error", message: error}) pushToast({theme: "error", message: error})
} else { } else {
// Pull the relay's freshly-signed role event so the UI reflects the change // Pull the relay's freshly-signed role event rather than waiting for the subscription.
// immediately, rather than waiting for the live subscription to catch up.
await $relayRoles.forceLoad(makeRelayRoleKey(url, id)) await $relayRoles.forceLoad(makeRelayRoleKey(url, id))
pushToast({message: "Role updated!"}) pushToast({message: "Role updated!"})

View file

@ -72,10 +72,7 @@
isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom, isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom,
) )
// During a call the chat is toggled from the call controls — it takes over the pane, // voiceConnectedHere can't stay true for a voice room the call isn't targeting.
// or sits beside the video once there's room for both. Reuses voiceConnectedHere
// rather than re-deriving isVoiceRoom/callState so it can't stay true for a voice
// room the call isn't targeting, which would hide that room's messages.
const callChatOpen = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split) const callChatOpen = $derived(voiceConnectedHere && $videoCallLayout === VideoCallLayout.Split)
const callChatHidden = $derived(voiceConnectedHere && !callChatOpen) const callChatHidden = $derived(voiceConnectedHere && !callChatOpen)
@ -103,8 +100,7 @@
return return
} }
// Land on the call view as soon as the call starts here, whether or not anyone's // Chat is one tap away on the control bar, so land on the call view whether or not a camera is on.
// camera is on — chat is one tap away via the control bar's chat toggle.
if (!wasConnectedHere) { if (!wasConnectedHere) {
videoCallLayout.set(VideoCallLayout.Video) videoCallLayout.set(VideoCallLayout.Video)
wasConnectedHere = true wasConnectedHere = true
@ -195,8 +191,7 @@
command.publishToRelays([url]) command.publishToRelays([url])
} }
// A share is a quote rather than a reply, so it goes in the content directly and // A share is a quote rather than a reply, so setParent prepends the reply's own reference ahead of it.
// setParent prepends the reply's own reference ahead of it.
if (sharedEvent) { if (sharedEvent) {
;({content, tags} = await prependParent(sharedEvent, {content, tags}, url)) ;({content, tags} = await prependParent(sharedEvent, {content, tags}, url))
} }
@ -240,8 +235,7 @@
const getElementKey = (element: {id: string}) => element.id const getElementKey = (element: {id: string}) => element.id
// Where a row sits inside the scroll container, in screen terms rather than scroll terms, so // In screen terms rather than scroll terms, so the reversed layout doesn't come into it.
// the reversed layout doesn't come into it
const topOf = (target: HTMLElement) => const topOf = (target: HTMLElement) =>
target.getBoundingClientRect().top - element!.getBoundingClientRect().top target.getBoundingClientRect().top - element!.getBoundingClientRect().top
@ -278,9 +272,7 @@
pinned = undefined pinned = undefined
} }
// The list renders from the newest message outward, so a row deep in history isn't on the page // A row deep in history renders on the next flush after it is asked for, so the lookup waits for it.
// until it's asked for — and asking renders it on the next flush, which the lookup has to wait
// for. Messages carry the id as a data attribute; the new-messages divider as its element id.
const scrollToRow = ( const scrollToRow = (
id: string, id: string,
{ {
@ -303,8 +295,7 @@
} }
if (pin) { if (pin) {
// A scroll that ended at the origin is one the browser clamped there, so the offset // A scroll that ended at the origin is one the browser clamped there.
// below is as close to centred as the row could get rather than where it was put.
pinned = {id, top: topOf(target), clamped: Math.abs(element!.scrollTop) < 1} pinned = {id, top: topOf(target), clamped: Math.abs(element!.scrollTop) < 1}
} }
} }
@ -357,9 +348,7 @@
const scrollToNewMessages = () => scrollToRow("new-messages", {behavior: "smooth"}) const scrollToNewMessages = () => scrollToRow("new-messages", {behavior: "smooth"})
// While the window stops short, dropping the anchor only takes the button away: where the reader // Anchoring a fresh feed on the present is what puts the reader at the live end.
// lands is then whatever the list settles on as it re-windows and the forward walk catches up,
// which is not reliably the live end. Anchoring a fresh feed on the present puts it there.
const scrollToBottom = async () => { const scrollToBottom = async () => {
const anchored = !isNaN(at) const anchored = !isNaN(at)
@ -372,8 +361,7 @@
element?.scrollTo({top: 0, behavior: anchored ? "auto" : "smooth"}) element?.scrollTo({top: 0, behavior: anchored ? "auto" : "smooth"})
} }
// A tab can be `visible` but unfocused (user alt-tabbed to another app), so we // A tab can be `visible` but unfocused, so `document.hidden` alone doesn't say the room is watched.
// can't rely on document.hidden alone to know the room is actually being watched.
const onActiveChange = (active: boolean) => { const onActiveChange = (active: boolean) => {
if (!active) { if (!active) {
lastVisibleAt = now() lastVisibleAt = now()
@ -409,22 +397,19 @@
let compose: RoomCompose | undefined = $state() let compose: RoomCompose | undefined = $state()
let eventToEdit: TrustedEvent | undefined = $state() let eventToEdit: TrustedEvent | undefined = $state()
// A link into history renders the newest messages first and only then scrolls, so the room is // A link into history renders the newest messages first and only then scrolls.
// held back for that frame rather than showing the wrong end of the conversation and jumping.
const awaitingJump = $derived(!isNaN(at) && !jumpSettled) const awaitingJump = $derived(!isNaN(at) && !jumpSettled)
const reachedStartOfHistory = $derived($older?.status === "exhausted") const reachedStartOfHistory = $derived($older?.status === "exhausted")
// A room paged from an anchor walks in both directions at once, and neither walk is worth a // The page bar carries one loader for both directions of an anchored walk.
// loader of its own in the transcript — the page bar carries one for the pair of them.
$effect(() => { $effect(() => {
pageLoading.set(isFeedLoading($older) || isFeedLoading($newer)) pageLoading.set(isFeedLoading($older) || isFeedLoading($newer))
return () => pageLoading.set(false) return () => pageLoading.set(false)
}) })
// Claim the share once we're on screen. Sharing into the room you're already looking at // Sharing into the room already on screen doesn't re-create this component.
// doesn't re-create this component, so this can't be read once on mount.
$effect(() => { $effect(() => {
if ($pendingShare) { if ($pendingShare) {
share = $pendingShare share = $pendingShare
@ -458,19 +443,13 @@
}), }),
) )
// The window only stops short of the present after jumping into history — anything published // The window only stops short of the present after jumping into history.
// from here on arrives through the repository rather than through a forward walk.
const windowStopsShort = $derived(!isNaN(at) && $newer?.status !== "exhausted") const windowStopsShort = $derived(!isNaN(at) && $newer?.status !== "exhausted")
// While the window stops short, the bottom of the container is not the bottom of the // While the window stops short, the bottom of the container isn't the bottom of the conversation.
// conversation, so the button is the way back to the live end rather than a scroll — which is
// why it clears `at` instead of scrolling. Once the two are the same place, scroll position is
// the whole answer.
const showScrollButton = $derived(scrolledUp || windowStopsShort) const showScrollButton = $derived(scrolledUp || windowStopsShort)
// A search result or a notification jumping into the room already on screen changes the url // `at` is NaN when there is no anchor, and NaN never equals itself.
// without re-creating this component, so the feed built on the old anchor has to be rebuilt
// on the new one. `at` is NaN when there is no anchor, and NaN never equals itself.
$effect(() => { $effect(() => {
if (!isNaN(at) && feedAnchor && at !== feedAnchor) { if (!isNaN(at) && feedAnchor && at !== feedAnchor) {
released = false released = false
@ -487,9 +466,7 @@
} }
}) })
// Content can arrive mid-scroll too, so this runs whether or not the reader is moving. The // The forward walk can reach the present after the last message does, so the effect watches both.
// forward walk reaching the present is the other thing a pin watches for, and it can land after
// the last message does, so it is read here where the effect sees it change.
$effect(() => { $effect(() => {
const caughtUp = !windowStopsShort const caughtUp = !windowStopsShort
@ -500,8 +477,7 @@
} }
}) })
// Bound here rather than in the markup: these watch for the reader taking over, they don't make // Declaring these as handlers would claim the transcript is a control.
// the transcript a control, and declaring them as handlers would claim it is one.
$effect(() => { $effect(() => {
if (element) { if (element) {
const target = element const target = element
@ -532,8 +508,7 @@
onEvent: context.add, onEvent: context.add,
}) })
// The container is reversed, so scrolling away from its origin reaches older messages and // The container is reversed, so scrolling away from the origin reaches older messages.
// sitting at the origin is the newest end.
older = makeScrollLoader(element!, feed.loadOlder) older = makeScrollLoader(element!, feed.loadOlder)
newer = makeScrollLoader(element!, feed.loadNewer, {reverse: true}) newer = makeScrollLoader(element!, feed.loadNewer, {reverse: true})

View file

@ -68,8 +68,7 @@
const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run()) const uploadFiles = () => editor.then(ed => ed.chain().selectFiles().run())
// Tiptap parses a string handed to insertContent as html, so an angle bracket in the // Tiptap parses a string handed to insertContent as html, so an angle bracket would eat the sentence.
// transcript would eat the rest of the sentence.
const insertTranscript = async (transcript: string) => { const insertTranscript = async (transcript: string) => {
const ed = await editor const ed = await editor

View file

@ -90,8 +90,7 @@
return return
} }
// The optimistic write flips isPinned while the publish is in flight, so what this did is read // The optimistic write flips isPinned while the publish is in flight, so read it before it goes out.
// before it goes out rather than after.
const wasPinned = isPinned const wasPinned = isPinned
const pins = wasPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id] const pins = wasPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id]
const command = await $roomPinLists.setPins(url, h, pins) const command = await $roomPinLists.setPins(url, h, pins)

View file

@ -113,8 +113,7 @@
history.back() history.back()
// The optimistic write flips isPinned while the publish is in flight, so what this did is read // The optimistic write flips isPinned while the publish is in flight, so read it before it goes out.
// before it goes out rather than after.
const wasPinned = isPinned const wasPinned = isPinned
const pins = wasPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id] const pins = wasPinned ? $pinIds.filter(pin => pin !== event.id) : [...$pinIds, event.id]
const command = await $roomPinLists.setPins(url, h, pins) const command = await $roomPinLists.setPins(url, h, pins)

View file

@ -80,8 +80,7 @@
doSearch(term, controller) doSearch(term, controller)
} }
// The modal container ignores Escape inside a text field, so the focused search input has to // The modal container ignores Escape inside a text field.
// close the modal itself
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") { if (event.key === "Escape") {
popModal() popModal()

View file

@ -35,8 +35,7 @@
return return
} }
// deriveSocketStatus is throttled, so a socket that opens immediately still reports // deriveSocketStatus is throttled, so a socket that opens at once still reports "Connecting".
// "Connecting" for the length of the throttle. Wait that out instead of flashing.
timeout = setTimeout(() => { timeout = setTimeout(() => {
timeout = undefined timeout = undefined
toastId = pushToast({ toastId = pushToast({

View file

@ -33,8 +33,7 @@
const canAssign = $derived($supportedMethods.includes("assignmethod")) const canAssign = $derived($supportedMethods.includes("assignmethod"))
const canUnassign = $derived($supportedMethods.includes("unassignmethod")) const canUnassign = $derived($supportedMethods.includes("unassignmethod"))
// A relay can grant methods this client knows nothing about, and a list that left them out // A relay can grant methods this client doesn't know, and leaving them out would revoke them.
// would revoke them on the next save.
const groups = $derived.by(() => { const groups = $derived.by(() => {
const known = MANAGEMENT_METHOD_GROUPS.flatMap(group => group.methods.map(({method}) => method)) const known = MANAGEMENT_METHOD_GROUPS.flatMap(group => group.methods.map(({method}) => method))
const extra = [...assigned].filter(method => !known.includes(method)) const extra = [...assigned].filter(method => !known.includes(method))

View file

@ -41,8 +41,7 @@
const pollsPath = makeSpacePath(url, "polls") const pollsPath = makeSpacePath(url, "polls")
const libraryPath = makeSpacePath(url, "library") const libraryPath = makeSpacePath(url, "library")
// Content events aren't retained across page loads, so seed with the kinds seen last time // Content events aren't retained across page loads, so seed from the kinds seen last time.
// to keep the nav from re-populating as they load in the background.
const spaceKindsKey = `space-kinds:${url}` const spaceKindsKey = `space-kinds:${url}`
const cachedKinds: number[] = getJson(spaceKindsKey) ?? [] const cachedKinds: number[] = getJson(spaceKindsKey) ?? []
@ -51,9 +50,7 @@
$events => new Set([...cachedKinds, ...$events.map(e => e.kind)]), $events => new Set([...cachedKinds, ...$events.map(e => e.kind)]),
) )
// A section is also offered while something under it is unread, or while we're in it. A comment // A comment can name content this space doesn't have, and it counts toward the space either way.
// can name content this space doesn't have, and it counts toward the space either way — hiding
// the section it belongs to is what leaves a space lit up with nothing to read.
const showSection = (kind: number, path: string) => const showSection = (kind: number, path: string) =>
$spaceKinds.has(kind) || $allNotifications.has(path) || $page.url.pathname.startsWith(path) $spaceKinds.has(kind) || $allNotifications.has(path) || $page.url.pathname.startsWith(path)

View file

@ -14,8 +14,7 @@
let currentTime = $state(0) let currentTime = $state(0)
let duration = $state(0) let duration = $state(0)
// Duration is NaN until the browser has read the metadata, so the scrubber stays at zero // Duration is NaN until the browser has read the metadata.
// length rather than rendering a range with no end.
const scrubbable = $derived(Number.isFinite(duration) ? duration : 0) const scrubbable = $derived(Number.isFinite(duration) ? duration : 0)
const toggle = () => (playing ? audio?.pause() : audio?.play()) const toggle = () => (playing ? audio?.pause() : audio?.play())

View file

@ -6,8 +6,7 @@
type Props = { type Props = {
thunk: BaseThunk thunk: BaseThunk
// A standalone usage (a DM bubble) needs to fill and justify its own row; one already // A standalone usage fills its own row, and forcing that inside a flex row costs that row a line.
// inside a flex row (an actions row) doesn't, and forcing it costs that row a line.
inline?: boolean inline?: boolean
class?: string class?: string
} }

View file

@ -42,8 +42,7 @@
const videoTiles = $derived.by(() => { const videoTiles = $derived.by(() => {
const session = $currentCallSession const session = $currentCallSession
// LiveKit mutates remoteParticipants/tracks in place; these stores are what // LiveKit mutates remoteParticipants and tracks in place, so these stores are what change.
// actually change on join/leave and track subscribe/unsubscribe.
void $participantMediaState void $participantMediaState
void $videoTrackRevision void $videoTrackRevision
if (!session || $callTargetRoom?.url !== url || $callTargetRoom?.h !== h) { if (!session || $callTargetRoom?.url !== url || $callTargetRoom?.h !== h) {
@ -188,8 +187,7 @@
{@const label = labelFor(tile.liveKitIdentity, tile.source)} {@const label = labelFor(tile.liveKitIdentity, tile.source)}
<div <div
class={cx( class={cx(
// bg-surface-more (rather than bg-surface, same as the panel behind it) so a // bg-surface-more so a camera-off tile reads as its own card instead of blending into the panel.
// camera-off tile reads as its own card instead of blending into the panel.
"relative isolate overflow-hidden rounded-2xl border border-line shadow-sm", "relative isolate overflow-hidden rounded-2xl border border-line shadow-sm",
layout === "spotlight" && "min-h-0 flex-1", layout === "spotlight" && "min-h-0 flex-1",
layout === "default" && "min-h-0 h-full w-full", layout === "default" && "min-h-0 h-full w-full",

View file

@ -66,8 +66,7 @@
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput) selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput) selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
// Mic mute/unmute publishes or unpublishes the microphone track; keep the // Mute and unmute publishes or unpublishes the microphone track.
// level meter in sync with whichever track is actually live right now.
const {livekit} = session const {livekit} = session
const refreshMicTrack = () => { const refreshMicTrack = () => {
micTrack = readMicTrack(session) micTrack = readMicTrack(session)
@ -83,9 +82,7 @@
const onInputChange = async () => { const onInputChange = async () => {
await switchCallActiveDevice(DeviceKind.AudioInput, selectedInput) await switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
// Switching the active device restarts the existing LocalAudioTrack in // Switching device restarts the LocalAudioTrack in place rather than emitting a publish event.
// place with a new MediaStreamTrack rather than emitting a publish event,
// so the meter needs an explicit re-read here.
const session = $currentCallSession const session = $currentCallSession
if (session) { if (session) {
micTrack = readMicTrack(session) micTrack = readMicTrack(session)

View file

@ -34,8 +34,7 @@
const {url, h, notification = false}: Props = $props() const {url, h, notification = false}: Props = $props()
// Beyond this many participants, a full name+avatar row per person makes the // Beyond this many, a name and avatar per person makes the sidebar item too tall.
// sidebar item too tall — fall back to the compact ProfileCircles cluster instead.
const ACTIVE_LIST_MAX = 5 const ACTIVE_LIST_MAX = 5
const participants = deriveCallParticipants(url, h) const participants = deriveCallParticipants(url, h)

View file

@ -54,12 +54,7 @@
void loadDevices() void loadDevices()
}) })
// Live mic level meter so the user can confirm their selected microphone is // The stream stays a plain local, since reading reactive state this effect writes would re-run it.
// actually picking up sound before joining, rather than finding out mid-call.
// The stream handle stays a plain local while only the track is reactive: the
// effect below both reads and writes it, and reading reactive state an effect
// also writes makes that effect depend on its own output, so it would re-run
// forever and cancel the in-flight getUserMedia every time.
let previewStream: MediaStream | undefined let previewStream: MediaStream | undefined
let previewTrack = $state<MediaStreamTrack | undefined>(undefined) let previewTrack = $state<MediaStreamTrack | undefined>(undefined)

View file

@ -57,8 +57,7 @@
dismissedAutopay = true dismissedAutopay = true
} }
// Deep link (?invoice=<id>, e.g. from the billing DM) opens the pay dialog, // A ?invoice=<id> deep link from the billing DM opens the pay dialog, once per id.
// once per id.
let handledInvoiceId: string | undefined let handledInvoiceId: string | undefined
$effect(() => { $effect(() => {
const id = $page.url.searchParams.get("invoice") ?? undefined const id = $page.url.searchParams.get("invoice") ?? undefined

View file

@ -158,8 +158,7 @@
} }
} }
// Set up the tenant's server-side wallet (nested so this dialog stays mounted); // Nested so this dialog stays mounted, with onSaved collecting and the poll as the backstop.
// onSaved collects right away and the poll is the backstop.
const connectWallet = () => { const connectWallet = () => {
pushModal( pushModal(
PaymentSetup, PaymentSetup,

View file

@ -48,13 +48,11 @@
let saved = $state(false) let saved = $state(false)
let redirecting = $state(false) let redirecting = $state(false)
// Offer to reuse the app's spending wallet instead of re-pasting its NWC url. // The two wallets stay separate records, and this reuses the same connection.
// The two wallets stay separate records, this just reuses the same connection.
const spendingWalletUrl = const spendingWalletUrl =
$wallet?.type === WalletType.NWC ? getNwcClient().nostrWalletConnectUrl : undefined $wallet?.type === WalletType.NWC ? getNwcClient().nostrWalletConnectUrl : undefined
// The tenant's autopay wallet (a backend NWC, write-only), distinct from the // The tenant's autopay wallet is a write-only backend NWC, distinct from the app's own.
// app's own wallet.
const persistNwc = async (url?: string) => { const persistNwc = async (url?: string) => {
if (!url || saving) { if (!url || saving) {
return return

View file

@ -49,8 +49,7 @@
const relay = await createRelayForActiveTenant(values) const relay = await createRelayForActiveTenant(values)
const url = getHostedRelayUrl(relay) const url = getHostedRelayUrl(relay)
// Join the space we just made, otherwise the space layout prompts us to join // Join the space now, or the space layout prompts for it and displaces the payment modals.
// it, displacing the payment modals below.
await $roomLists.addRelay(url).then(publish) await $roomLists.addRelay(url).then(publish)
await reconcileTenant($user.pubkey) await reconcileTenant($user.pubkey)

Some files were not shown because too many files have changed in this diff Show more