diff --git a/e2e/harness/app/boot.ts b/e2e/harness/app/boot.ts index 707d36ba..206fdd4c 100644 --- a/e2e/harness/app/boot.ts +++ b/e2e/harness/app/boot.ts @@ -7,13 +7,13 @@ import {injectEvents, injectSession} from "./session" // Must match TEST_ENV_KEY in src/lib/test/env.ts. const TEST_ENV_KEY = "__TEST_ENV__" -// Set the first time the app reads a value out of the injected env, which is this side's only -// evidence that the hook in src/app/env.ts ran at all. +// Set the first time the app reads a value out of the injected env, the only evidence this side +// has that the hook in src/app/env.ts ran. const TEST_ENV_READ_KEY = "__TEST_ENV_READ__" export type BootOptions = { - // Every relay list the app reads at startup is pointed here, so the urls it dials on its own - // initiative can only ever be relays the scenario created. + // Every relay list the app reads at startup is pointed here, so it can only dial relays the + // scenario created. relays: string[] spaces?: string[] user?: TestUser @@ -21,7 +21,7 @@ export type BootOptions = { events?: TrustedEvent[] path?: string // VITE_ values the scenario sets for itself, applied over the relay-derived ones below. Anything - // named here still has to be something the test owns, or the app will reach for it. + // named here has to be something the test owns, or the test fails on a leak. env?: Record } @@ -73,16 +73,15 @@ export const boot = async ( await page.goto(path) // The root layout renders nothing until its async setup block resolves, so the shell appearing - // is the first point at which the app is really running. With a session injected, wait for the - // signed-in nav instead — one the app rejected renders the landing dialog, and failing on that - // here is much easier to read than the assertions it would break later. + // 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. await page .locator(user ? ".primary-nav" : ".fl") .waitFor({state: "attached", timeout: ms(int(1, MINUTE))}) - // src/app/env.ts reads every VITE_ value as it is imported, so by now the app has either resolved - // them against the env above or against .env's real relays — which would otherwise surface as - // every assertion in the suite timing out. + // 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( key => Boolean(Reflect.get(window, key)), TEST_ENV_READ_KEY, diff --git a/e2e/harness/app/cache.ts b/e2e/harness/app/cache.ts index 6736ca39..29c44c0f 100644 --- a/e2e/harness/app/cache.ts +++ b/e2e/harness/app/cache.ts @@ -7,9 +7,8 @@ const databaseName = (pubkey: string) => `flotilla-9gl-${pubkey}` /** * What this user's client has written to disk so far. Events reach indexeddb in three-second - * batches and nothing in the ui says when one has landed, so a spec about what survives a restart - * has to read the cache to know the restart is testing anything: a reload before the batch would - * fail whether or not the events were ever going to be persisted. + * 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 => page.evaluate(async name => { @@ -21,7 +20,7 @@ export const readCachedEvents = (page: Page, pubkey: string): Promise { await context.exposeBinding(TEST_NIP07_KEY, (source, call: Nip07Call) => { diff --git a/e2e/harness/app/session.ts b/e2e/harness/app/session.ts index 4e274f31..1646d42b 100644 --- a/e2e/harness/app/session.ts +++ b/e2e/harness/app/session.ts @@ -8,9 +8,8 @@ const TEST_SESSION_KEY = "__TEST_SESSION__" const TEST_EVENTS_KEY = "__TEST_EVENTS__" // A nip01 session in the {method, data} shape @welshman/app's session handlers deserialize, so -// restoreSession can build a signer from it without any of the storage encoding a real login -// would have gone through. addInitScript runs before any page script, so this must be called -// before navigating, and it is installed on the context so every page in it boots as this user. +// 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) => context.addInitScript( ([key, session]) => { @@ -19,8 +18,8 @@ export const injectSession = (context: BrowserContext, user: TestUser) => [TEST_SESSION_KEY, {method: "nip01", data: {secret: user.secret}}] as const, ) -// The repository contents the app loads once the injected session is restored — the local cache -// a user who had used the app before would boot with. +// The repository contents the app loads once the injected session is restored, the local cache a +// returning user would boot with. export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) => context.addInitScript( ([key, value]) => { diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts index c7079c85..eaf53539 100644 --- a/e2e/harness/index.ts +++ b/e2e/harness/index.ts @@ -39,8 +39,8 @@ export { } from "./net/http" export type {DufflepudFixtures, HostingFixtures, HostingHandle, HostingRecord} from "./net/http" -// Mirrors encodeRelay in src/app/relays.ts, which can't be imported here — it reaches the app's -// module graph, and with it sveltekit. +// Mirrors encodeRelay in src/app/relays.ts. Importing it reaches the app's module graph, and with +// it sveltekit. const encodeRelay = (url: string) => encodeURIComponent( normalizeRelayUrl(url) @@ -54,19 +54,17 @@ export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}` // What a page is opened with, over and above the scenario's own relays. export type PageOptions = { - // Overrides the project's context options, for a spec that needs a viewport, a colour scheme or - // a permission of its own. + // Overrides the project's context options, for a spec that needs a viewport or a permission of + // its own. context?: BrowserContextOptions - // VITE_ values applied over the ones derived from the scenario's relays, e.g. a platform space - // or the domain hosted relays are created under. Whatever is named here has to be something the - // scenario owns, or the app will reach for it and the test will fail on a leak. + // VITE_ values applied over the ones derived from the scenario's relays, e.g. a platform space or + // the domain hosted relays are created under. See BootOptions in app/boot.ts. env?: Record // A NIP-07 provider signing as this user, for a login that goes through an extension. nip07?: TestUser - // A blossom server, installed before the page boots. A spec whose server is one the app probes - // on load — a space's own url, which src/app/sync.ts asks about as soon as its page opens — - // has to name it here: mockBlossom called on the page `as()` returns arrives after that probe - // has already been answered and cached, and uploads go to the default server instead. + // A blossom server, installed before the page boots. mockBlossom called on the page `as()` + // returns arrives after src/app/sync.ts has probed and cached a space's own url, so a spec whose + // server is one the app probes on load has to name it here instead. blossom?: BlossomOptions // Fields merged over a relay's own nip-11 document, keyed by relay url. relayInfo?: RelayInfoOverrides @@ -78,11 +76,11 @@ export type PageOptions = { export type Harness = { zooid: Zooid seed(build: (tools: SeedTools) => MaybeAsync): Promise - // A logged-in page for a user: its own browser context, its own storage, its own sockets into - // the relays every other user is talking to. + // A logged-in page for a user, in its own browser context, with its own storage and its own + // sockets into the relays every other user is talking to. as(user: TestUser, path?: string, options?: PageOptions): Promise - // The same page with no session injected — the app as someone who has never logged in sees it, - // and the only way to watch a login, a reload or a logout happen. + // 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 } @@ -98,11 +96,8 @@ export type HarnessWorkerFixtures = { } export const test = base.extend({ - // Playwright's own context — and the `page` fixture built on it — is unrouted: no websocket - // interception, no http block-all, no injected env, and nothing collecting leaks from it. A page - // born there boots the app against the relays baked into .env, which is the one thing this suite - // exists to prevent, so it is refused outright and `as()`/`visit()` are the only ways to get a - // page. + // Playwright's own context, and the `page` fixture built on it, is unrouted, so a page born + // there boots the app against the relays baked into .env. context: async () => { throw new Error( "The built-in `context` and `page` fixtures reach the real network. Open a page with the " + @@ -110,9 +105,8 @@ export const test = base.extend({ "they navigate.", ) }, - // Playwright builds this one with `playwright.request.newContext()`, so it is an http client in - // this process that belongs to no browser context: `installHttpRoutes` cannot see it and nothing - // records what it sent. + // Playwright builds this one with `playwright.request.newContext()`, an http client in this + // process that belongs to no browser context, so `installHttpRoutes` cannot see it. request: async () => { throw new Error( "The built-in `request` fixture makes http requests from node, where nothing intercepts " + @@ -153,13 +147,12 @@ export const test = base.extend({ const open = async (path: string, options: PageOptions, user?: TestUser) => { const {urls, cache} = requireScenario() - // The project's own `use` first, so a viewport, colour scheme or device descriptor set in - // playwright.config.ts reaches the context rather than being silently dropped. + // The project's own `use` first, so a viewport or device descriptor set in + // playwright.config.ts reaches the context rather than being dropped. // - // A request a service worker makes is not seen by context.route, so a worker is the one - // way out of the block-all below. Sveltekit registers src/service-worker.js on every - // navigation in dev; it has no fetch handler today, and blocking registration is what keeps - // that from being the thing containment rests on. + // 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({ ...testInfo.project.use, serviceWorkers: "block", @@ -168,9 +161,9 @@ export const test = base.extend({ contexts.push(context) - // Interception before navigation, and the block-all before the mocks — playwright matches - // the most recently registered route first, and every mock falls through what it doesn't - // recognize, so the mocks have to be registered last to be reachable at all. + // Playwright matches the most recently registered route first and every mock falls through + // what it doesn't recognize, so the block-all goes in before the mocks, and all of it before + // the page navigates. await installHttpRoutes(context) await installWebSocketRoutes(context, zooid) await mockRelayInfo(context, options.relayInfo ?? {}) @@ -188,10 +181,9 @@ export const test = base.extend({ await injectNip07(context, options.nip07) } - // Playwright grants the "notifications" permission at the browser level, but headless Chromium - // still reports `Notification.permission` as "denied", so a spec that opted into notifications - // would watch the app's push-enable refuse a permission it was given. Reflect the grant into - // the Notification API the app actually reads. + // Headless Chromium reports `Notification.permission` as "denied" even where playwright has + // granted the permission at the browser level, so the grant is reflected into the API the app + // reads. if (options.context?.permissions?.includes("notifications")) { await context.addInitScript(() => { Object.defineProperty(Notification, "permission", { diff --git a/e2e/harness/keys.ts b/e2e/harness/keys.ts index 55c10053..d7087140 100644 --- a/e2e/harness/keys.ts +++ b/e2e/harness/keys.ts @@ -21,9 +21,8 @@ const makeUser = (name: string, secret: string): TestUser => { return user } -// The secrets are near-zero entropy on purpose: they never leave the test process, they are -// stable across runs so a pubkey can be asserted on directly, and the leading nibbles make an -// event's author recognizable at a glance in a failed-assertion diff. +// The secrets never leave the test process. They are stable across runs, so a pubkey can be +// asserted on directly, and the leading nibbles name an author in a failed-assertion diff. export const users = { alice: makeUser("alice", "a11ce00000000000000000000000000000000000000000000000000000000001"), bob: makeUser("bob", "b0b0000000000000000000000000000000000000000000000000000000000002"), @@ -31,14 +30,14 @@ export const users = { admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"), } -// secp256k1's group order. A secret is a scalar in [1, n), and a hash lands outside that range -// only astronomically rarely, but reducing rather than rejecting keeps the derivation total. +// secp256k1's group order. A secret is a scalar in [1, n), so the digest below is reduced into +// that range rather than rejected when it falls outside. const CURVE_ORDER = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141") /** - * A fifth, sixth, hundredth identity, named rather than listed. The secret is derived from the - * name, so the pubkey is as stable across runs as the four above, and minting one registers it — - * a scenario can seed a profile, a message or a follow for it like any other test user. + * 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) => { const digest = BigInt("0x" + createHash("sha256").update(name).digest("hex")) diff --git a/e2e/harness/net/context.ts b/e2e/harness/net/context.ts new file mode 100644 index 00000000..fb8d6fc3 --- /dev/null +++ b/e2e/harness/net/context.ts @@ -0,0 +1,23 @@ +import type {BrowserContext} from "@playwright/test" + +// State the harness keeps per browser context, outside the browser: recorded traffic, blocked +// requests, the hosting fake's store. Reading one before the route installer that owns it has run +// is a spec calling things out of order, which the error names. +export const makeContextStore = (installer: string) => { + const byContext = new WeakMap() + + return { + set: (context: BrowserContext, value: T) => { + byContext.set(context, value) + + return value + }, + get: (context: BrowserContext) => { + const value = byContext.get(context) + + if (value) return value + + throw new Error(`${installer} was never called for this browser context`) + }, + } +} diff --git a/e2e/harness/net/http.ts b/e2e/harness/net/http.ts index 8970063c..1f623b4c 100644 --- a/e2e/harness/net/http.ts +++ b/e2e/harness/net/http.ts @@ -5,10 +5,11 @@ import type {Handle} from "@welshman/util" import type {ZapperValues} from "@welshman/domain" import {tenantByUrl} from "../zooid/config" import {requestZooid} from "../zooid/transport" +import {makeContextStore} from "./context" -// Mirrors the service urls in src/app/env.ts and .env, which this process can't import: env.ts -// reads import.meta.env and pulls in Capacitor. Nothing here is ever fetched — these are route -// patterns, and every handler answers from memory. +// Mirrors the service urls in src/app/env.ts and .env, which this process can't import because +// env.ts reads import.meta.env and pulls in Capacitor. These are route patterns rather than urls +// anything fetches, and every handler answers from memory. const DUFFLEPUD_ORIGIN = "https://dufflepud.coracle.social" const PUSH_SERVER_ORIGIN = "https://nps.flotilla.social" const HOSTING_ORIGIN = "https://api.hosting.coracle.social" @@ -16,8 +17,8 @@ const HOSTING_ORIGIN = "https://api.hosting.coracle.social" // Hard-coded in src/app.html, so every navigation asks for it whatever the scenario is doing. const PLAUSIBLE_ORIGIN = "https://plausible.coracle.social" -// Where the hosting api sends a browser to pay. Nothing serves it — `.test` resolves nowhere and -// the block-all aborts the navigation — so a spec sees the redirect without one leaving. +// Where the hosting api sends a browser to pay. `.test` resolves nowhere and the block-all aborts +// the navigation, so a spec sees the redirect without one leaving. const CHECKOUT_ORIGIN = "https://checkout.test" // Relay-hosted livekit lives under a well-known path rather than an origin of its own. @@ -30,8 +31,7 @@ const PNG = Buffer.from( ) // The dev server from vite.config.ts. Traffic to it is the app loading itself rather than egress, -// so it is the one host both layers here let past, websockets included — Vite's hmr socket has to -// keep working. +// so it is the one host both layers here let past, websockets included for Vite's hmr socket. export const isDevServerUrl = (url: URL) => url.port === "1847" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) @@ -47,31 +47,19 @@ export type BlockedRequest = { url: string } -const blockedByContext = new WeakMap() - -export const getBlockedRequests = (context: BrowserContext) => { - const blocked = blockedByContext.get(context) - - if (blocked) { - return blocked - } - - throw new Error("installHttpRoutes was never called for this browser context") -} +const blockedStore = makeContextStore("installHttpRoutes") /** * Blocks every http request the app makes, except to the dev server and to a relay's own origin, * which is forwarded to the container so that the nip-11 document and the nip-86 management api the - * app reads are the real relay's. Install this first: the per-service mocks below are registered + * 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) => { - const blocked: BlockedRequest[] = [] + const blocked = blockedStore.set(context, []) - blockedByContext.set(context, blocked) - - // The dev server is left unrouted rather than matched and continued: a sveltekit page in dev is + // The dev server is left unrouted rather than matched and continued. A sveltekit page in dev is // hundreds of module requests, and none of them is egress. return context.route( url => !isDevServerUrl(url), @@ -102,12 +90,12 @@ export const installHttpRoutes = (context: BrowserContext) => { /** * Opt-in, for a spec that wants every request the app made to have been answered by something the - * scenario stood up. Some of what a page asks for is meant to be refused — the blossom probe - * against a relay with blossom off, for one — so call this from a spec that has mocked what it - * exercises rather than from teardown. + * 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) => { - const blocked = getBlockedRequests(context) + const blocked = blockedStore.get(context) if (blocked.length > 0) { throw new Error( @@ -123,12 +111,12 @@ export const assertNoBlockedRequests = (context: BrowserContext) => { export type RelayInfoOverrides = Record /** - * A relay's real document with a few fields replaced: a `redirect_to`, a `limitation`, a NIP the - * relay does not implement. Merged rather than fabricated, because `self`, `pubkey`, `name` and - * `supported_nips` are what room state is trusted from — a hand-written document breaks every - * room on the page. + * 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. + * Install it before the page navigates. The document is read at startup and cached from then on. */ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverrides) => { const overrideByOrigin = new Map( @@ -151,14 +139,14 @@ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverr if (request.method() === "GET" && host && override) { const {status, headers, body} = await requestZooid(host, "GET", "/", { ...request.headers(), - // The merge has to read the document, and khatru will compress it if invited to. + // The merge has to read the document, and khatru compresses it when invited to. "accept-encoding": "identity", }) return route.fulfill({ status, // khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's own - // headers are kept — all but the length of a body that is about to change. + // headers are kept, all but the length of a body that is about to change. headers: omit(["content-length"], headers), body: JSON.stringify({...JSON.parse(body.toString()), ...override}), }) @@ -171,9 +159,8 @@ export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverr /** * The analytics script src/app.html loads on every page. Answering with an empty body leaves - * `window.plausible` as the queueing shim src/app/analytics.ts installs, so pageviews accumulate - * in memory and nothing is ever sent — and `assertNoBlockedRequests` stays a statement about the - * scenario rather than about the page shell. + * `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) => context.route(`${PLAUSIBLE_ORIGIN}/**`, route => @@ -186,15 +173,15 @@ export type DufflepudFixtures = { // A link preview is only rendered when it carries a title or an image. preview?: {title?: string; description?: string; image?: string} handles?: {handle: string; info?: Handle}[] - // `lnurl` is hex here, not bech32 — that's the encoding dufflepud speaks. + // `lnurl` is hex here rather than bech32, which is the encoding dufflepud speaks. zappers?: {lnurl: string; info?: Omit}[] } /** * Dufflepud, whose origin is the one service url the app hard-codes rather than reading from a - * `VITE_` value. `as()` installs it with no fixtures, so a spec that needs one — a zapper for a zap - * receipt, a link preview — calls this again with its own: playwright matches the most recently - * registered route first, so the spec's answers win over the empty defaults. + * `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 = {}) => context.route(`${DUFFLEPUD_ORIGIN}/**`, route => { @@ -227,7 +214,7 @@ export type BlossomOptions = { } /** - * A blossom server that keeps what it was given: an upload is hashed exactly as the real thing + * 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 = (context: BrowserContext, {server}: BlossomOptions) => { @@ -283,7 +270,7 @@ export const mockPushServer = (context: BrowserContext) => return route.fulfill({json: {}}) } - // Registration is only usable if it comes back with both, and the relay is told to post + // Registration is only usable if it comes back with both. The relay is told to post // notifications to the callback rather than the client ever fetching it. const key = "test-push-subscription" @@ -294,8 +281,8 @@ export const mockPushServer = (context: BrowserContext) => }) // One record straight off the hosting api, whose shapes live in src/app/hosting.ts. They're left -// as loose objects here so that mocking a payment flow doesn't pull the app's module graph — and -// with it import.meta.env — into the node process. A relay record may also carry `members` and +// 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 @@ -311,31 +298,23 @@ export type HostingFixtures = { draftInvoice?: HostingRecord } -// The backend changing its mind between two of the user's clicks — a custom domain that verifies, -// an invoice that gets paid — which is the half of those flows no click can reach. +// The backend changing its mind between two of the user's clicks, such as a custom domain that +// verifies or an invoice that gets paid. It is the half of those flows no click can reach. export type HostingHandle = { setTenant(patch: HostingRecord): void setRelay(id: string, patch: HostingRecord): void setInvoice(id: string, patch: HostingRecord): void } -const hostingByContext = new WeakMap() +const hostingStore = makeContextStore("mockHosting") -export const getHosting = (context: BrowserContext) => { - const handle = hostingByContext.get(context) - - if (handle) { - return handle - } - - throw new Error("mockHosting was never called for this browser context") -} +export const getHosting = (context: BrowserContext) => hostingStore.get(context) /** - * The hosting api as a small stateful fake: a write mutates the record it names and the next read + * 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 at all. Each browser context gets its own store, so one user's - * spaces are not another's. + * 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 = {}) => { const plans = fixtures.plans ?? [] @@ -363,7 +342,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt }, } - hostingByContext.set(context, handle) + hostingStore.set(context, handle) await context.route(`${HOSTING_ORIGIN}/**`, route => { const request = route.request() @@ -487,8 +466,8 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/invoices/${id}`}}}) } - // GET and reconcile both answer with the invoice as it now stands, which is how a spec - // marks one paid: flip `paid_at` with the handle and let the dialog's next poll find it. + // GET and reconcile both answer with the invoice as it now stands, which is how a spec marks + // one paid. Flip `paid_at` with the handle and let the dialog's next poll find it. return route.fulfill({json: {data: invoice}}) } @@ -499,7 +478,7 @@ export const mockHosting = async (context: BrowserContext, fixtures: HostingFixt } export type LivekitOptions = { - // Where the client is told to connect. Point it at something the test owns — the token this + // Where the client is told to connect. Point it at something the test owns, since the token this // hands out is accepted by nothing else. serverUrl: string token?: string @@ -523,8 +502,8 @@ export const mockLivekit = (context: BrowserContext, {serverUrl, token}: Livekit ) /** - * Serves a png for anything the browser is loading as an image — avatars, banners, blossom blobs, - * video posters — so a scenario's fixtures can reference urls without any of them being 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) => context.route( diff --git a/e2e/harness/net/websocket.ts b/e2e/harness/net/websocket.ts index 4e30af3d..4b33e529 100644 --- a/e2e/harness/net/websocket.ts +++ b/e2e/harness/net/websocket.ts @@ -5,6 +5,7 @@ import {RelayMessageType, isClientEvent, isClientReq} from "@welshman/net" import type {ClientMessage, RelayMessage} from "@welshman/net" import type {RelayConnection} from "../zooid/types" import type {Zooid} from "../zooid/relay" +import {makeContextStore} from "./context" import {isDevServerUrl} from "./http" export type Direction = "toRelay" | "toClient" @@ -21,21 +22,10 @@ type Traffic = { forgotten: Set } -const trafficByContext = new WeakMap() +const trafficStore = makeContextStore("installWebSocketRoutes") -const getTraffic = (context: BrowserContext) => { - const traffic = trafficByContext.get(context) - - if (traffic) { - return traffic - } - - throw new Error("installWebSocketRoutes was never called for this browser context") -} - -// A relay that holds nothing: REQs get an immediate EOSE and events are accepted into the void. -// Only urls the container does not serve get one, so a leak fails on the assertion that names it -// rather than on a timeout three layers away. +// A relay that holds nothing. REQs get an immediate EOSE and events are accepted and dropped, so a +// leak fails on the assertion that names it rather than on a timeout three layers away. const openEmptyRelay = (): RelayConnection => { let listener: (message: RelayMessage) => void = () => undefined @@ -92,16 +82,18 @@ const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => { /** * 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 — routing is the environment a page is born into, not a step in a sequence. + * 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) => { - const traffic: Traffic = {transcript: [], leaks: new Set(), forgotten: new Set()} - - trafficByContext.set(context, traffic) + const traffic = trafficStore.set(context, { + transcript: [], + leaks: new Set(), + forgotten: new Set(), + }) return context.routeWebSocket( url => !isDevServerUrl(url), @@ -109,14 +101,13 @@ export const installWebSocketRoutes = (context: BrowserContext, zooid: Zooid) => ) } -export const getTranscript = (context: BrowserContext) => getTraffic(context).transcript +export const getTranscript = (context: BrowserContext) => trafficStore.get(context).transcript -// Retention, as this context sees it: from here on the relay answers like one that never held -// anything, while staying a url the scenario declared rather than becoming a leak. Sockets already -// open keep the relay they were opened against — `serve` resolves once, at open — so the drop takes -// effect on the next connection, which is what a reload gives it. +// Makes a relay answer like one that never held anything, without its url becoming a leak. `serve` +// resolves a relay once, at open, so sockets already open keep theirs and the drop takes effect on +// the next connection. A reload is what gives it one. export const forgetRelay = (context: BrowserContext, url: string) => - getTraffic(context).forgotten.add(normalizeRelayUrl(url)) + trafficStore.get(context).forgotten.add(normalizeRelayUrl(url)) // Every frame in both directions, oldest first. Attach it to a failing test to see what the client // actually said, and to whom. @@ -129,7 +120,7 @@ export const formatTranscript = (context: BrowserContext) => .join("\n") export const assertNoLeaks = (context: BrowserContext) => { - const {leaks} = getTraffic(context) + const {leaks} = trafficStore.get(context) if (leaks.size > 0) { throw new Error( diff --git a/e2e/harness/seed/scenario.ts b/e2e/harness/seed/scenario.ts index e7a4ef5d..6174a6aa 100644 --- a/e2e/harness/seed/scenario.ts +++ b/e2e/harness/seed/scenario.ts @@ -9,13 +9,12 @@ import type {TestUser} from "../keys" import {seedSpace} from "./space" import type {SeededSpace} from "./space" -// A fixture timestamp, as an offset from the moment the scenario started: `at(2, HOUR)` is two -// hours before the test began. Count-first, like int and ago. +// A fixture timestamp, as an offset from the moment the scenario started. `at(2, HOUR)` is two +// hours before the test began, count-first like int and ago. export type At = (count: number, unit: number) => number export type SeedTools = { - // Names a relay the container already serves — its policy is its toml in zooid/docker/config, so - // a scenario describes what is on a relay, never what the relay is. + // Names a relay the container already serves. Its policy is its toml in zooid/docker/config. relay: (name: TenantName) => SeededSpace user: typeof users at: At @@ -26,9 +25,9 @@ export type Scenario = { readonly at: At readonly urls: string[] space(name: TenantName): SeededSpace - // The events a returning user's client would already have on disk. Just the room list: the - // scenario's own relays stand in for the indexers, and a members-only relay won't serve the list - // that would tell authPolicy it may identify to it. See ARCHITECTURE.md, "Users and sessions". + // The events a returning user's client would already have on disk, which is just the room list. + // A members-only 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[] } diff --git a/e2e/harness/seed/space.ts b/e2e/harness/seed/space.ts index bedd9b55..6a069077 100644 --- a/e2e/harness/seed/space.ts +++ b/e2e/harness/seed/space.ts @@ -21,8 +21,8 @@ import {users} from "../keys" import type {TestUser} from "../keys" // @welshman/domain has no writer for NIP-29 kind-9 messages, and none of its readers describe one, -// so this pairs the base writer with the base reader. The behavior tags it renders — `h` via -// setRoom, `q`/`p` via addQuote/addMention — are everything a room message carries. +// 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 {} // A handle to an event the scenario is going to publish. Seeding calls record what to write and @@ -32,8 +32,8 @@ export type SeededEvent = { readonly id: string } -// The kind-14 a direct message really is. It is never published — each participant gets it inside -// a gift wrap — so this, rather than anything on the wire, is what a spec asserts on. +// The kind-14 a direct message really is. It is never published, since each participant gets it +// inside a gift wrap, so this is what a spec asserts on. export type SeededRumor = { readonly rumor: HashedEvent readonly id: string @@ -51,8 +51,7 @@ export type ProfileValues = { nip05?: string } -// A queued write. Seeding is ordered — a reply's parent has to exist first — so every write goes -// through the scenario's queue rather than starting when it is declared. +// A queued write, drained in declaration order by `seed` in scenario.ts. export type Enqueue = (write: () => Promise) => void // A user's membership as their own client sees it, which the scenario turns into one room list @@ -69,15 +68,15 @@ export type SeededSpace = { readonly memberships: SeededMembership[] room(h: string, options?: RoomOptions): void member(user: TestUser, h?: string): void - // Relay and room membership, plus a place in the user's own room list — what a user who joined - // this space through the ui would end up with. + // Relay and room membership, plus a place in the user's own room list, which is what a user who + // joined this space through the ui ends up with. join(user: TestUser, ...rooms: string[]): void message(user: TestUser, h: string, content: string, createdAt?: number): SeededEvent reply(user: TestUser, parent: SeededEvent, content: string, createdAt?: number): SeededEvent profile(user: TestUser, values: ProfileValues, createdAt?: number): SeededEvent event(user: TestUser, template: SeededTemplate, createdAt?: number): SeededEvent - // A nip-17 conversation: one kind-14 rumor, gift-wrapped once per participant — the sender - // included, since their own copy is the half of the thread their client reads back. + // A nip-17 conversation. One kind-14 rumor, gift-wrapped once per participant including the + // sender, whose own copy is the half of the thread their client reads back. dm(from: TestUser, to: TestUser[], content: string, createdAt?: number): SeededRumor // This space's domain kinds, bound to a resolver that answers with its url, so a writer built // here renders its relay hints as this space. For everything `event()` takes a template for: @@ -91,7 +90,7 @@ export type SeedSpaceOptions = { zooid: Zooid enqueue: Enqueue // The moment the scenario began. A fixture declared without a timestamp is stamped with it - // rather than with the wall clock, so a run's events never drift apart from one another. + // rather than with the wall clock. startedAt: number name: TenantName } @@ -181,8 +180,8 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): const message = (user: TestUser, h: string, content: string, createdAt = startedAt) => publish(() => relay().message(user, h, content, createdAt)) - // Flotilla replies in a room by quoting: Content.svelte renders a quote from the nostr uri in the - // content rather than from the q tag, so the uri has to be prepended, as prependParent does in + // Flotilla replies in a room by quoting. Content.svelte renders a quote from the nostr uri in the + // content rather than from the q tag, so the uri is prepended as prependParent does in // src/app/rooms.ts. const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) => publishTemplate(user, async () => { @@ -213,9 +212,9 @@ export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): const kind = >(factory: KindFactory) => factory.configure(context) - // Every wrap is published over the sender's own connection: a gift wrap is signed by an - // ephemeral key, so its author is nobody this process can authenticate as. zooid stores it - // anyway, because it authorizes a kind-1059 by the member named in its p tag. + // Every wrap is published over the sender's own connection, since a gift wrap's author is an + // ephemeral key nobody in this process can authenticate as. zooid stores it anyway, authorizing a + // kind-1059 by the member named in its p tag. const dm = (from: TestUser, to: TestUser[], content: string, createdAt = startedAt) => { const rumor = seeded(async () => { const writer = kind(DirectMessage).writer().setContent(content) diff --git a/e2e/harness/zooid/config.ts b/e2e/harness/zooid/config.ts index 893fa728..4bb6634b 100644 --- a/e2e/harness/zooid/config.ts +++ b/e2e/harness/zooid/config.ts @@ -1,21 +1,20 @@ /** * The virtual relays the container serves, and the only place their names are written down. * - * zooid is multi-tenant: it binds a config to a Host header and serves any number of them from one - * process, so a second space 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. + * zooid binds a config to a Host header and serves any number of them from one process, so a second + * space 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 is a name that resolves nowhere. The container is reached on loopback and told what to - * call itself, so that what it calls itself is a url a relay selection will keep — see transport.ts - * for why that matters, and `.test` is reserved by rfc 2606, so a url that escapes this process - * fails to connect rather than reaching a host. + * 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. */ export const tenants = { space: "space.test", other: "other.test", - // Policy that space.toml cannot express at the same time: one that refuses a join without an - // invite, and one that serves events with their signatures stripped. + // Policy space.toml cannot express at the same time. `closed` refuses a join without an invite, + // `unsigned` serves events with their signatures stripped. closed: "closed.test", unsigned: "unsigned.test", } as const @@ -26,6 +25,5 @@ export const tenantUrl = (name: TenantName) => `wss://${tenants[name]}/` export const tenantNames = Object.keys(tenants) as TenantName[] -// Which tenant a url belongs to, for the transport's Host header. A url the app opens that is not -// one of these is a leak, and is reported as one rather than dialled. +// Which tenant a url belongs to, for the transport's Host header. export const tenantByUrl = new Map(tenantNames.map(name => [tenantUrl(name), tenants[name]])) diff --git a/e2e/harness/zooid/relay.ts b/e2e/harness/zooid/relay.ts index 2701473d..8151ee59 100644 --- a/e2e/harness/zooid/relay.ts +++ b/e2e/harness/zooid/relay.ts @@ -17,7 +17,7 @@ import {tenantNames, tenantUrl, tenants} from "./config" import type {TenantName} from "./config" import {makeTestRelay} from "./testRelay" import type {PublishOptions} from "./types" -import {connectToZooid, requestZooid} from "./transport" +import {connectToZooid, port, requestZooid} from "./transport" import type {ZooidConnection} from "./transport" const image = "gitea.coracle.social/coracle/zooid:latest" @@ -27,32 +27,23 @@ const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url const configSource = fileURLToPath(new URL("docker/config", import.meta.url)) // What the container mounts as /app/config. zooid saves a relay's toml back whenever a nip-86 call -// edits that relay's name, description or icon, so it is handed a copy rather than the repo's own -// directory: a read-only mount fails those calls, and a writable one would leave the fixtures every -// other test reads rewritten by whatever the last one did. The copy is staged here rather than in -// an entrypoint because the image is distroless — there is no shell in it to copy anything with. +// 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 execFileAsync = promisify(execFile) -// execFile does not go through a shell, so a `docker` that exists only as an alias or a function — -// podman, colima, a wrapper in a shell rc file — is invisible to it however well it works when -// typed. Name the executable to drive with E2E_DOCKER in that case. +// execFile does not go through a shell, so a `docker` that exists only as an alias or a function is +// 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" -// The loopback port the container publishes and transport.ts dials. It is fixed rather than -// configurable on purpose: the harness talks to whatever answers here, so a copy of the suite left -// running by anyone else on the machine — under another user, even — would be seeded, queried and -// reset out from under this run. Both places have to agree, so this must match the published port -// in docker/compose.yaml and `port` in transport.ts. -const relayHostPort = 3334 - -// Whether something already answers on the relay's loopback port. Used as a pre-flight: the port is -// the harness's alone for the length of a run, so anything holding it before this run brings its own -// container up is a foreign occupant we must refuse rather than quietly share. +// Whether something already answers on the port the container publishes, asked before this run +// brings its own up. const isRelayPortTaken = () => new Promise(resolve => { - const socket = createConnection({port: relayHostPort, host: "127.0.0.1"}) + const socket = createConnection({port, host: "127.0.0.1"}) socket.setTimeout(1000) socket.once("connect", () => { @@ -66,18 +57,17 @@ const isRelayPortTaken = () => socket.once("error", () => resolve(false)) }) -// Every invocation carries ZOOID_CONFIG, `down` included: the compose file names it without a -// default, so a call that left it out would fail to interpolate rather than quietly mounting -// something else. +// Every invocation carries ZOOID_CONFIG, `down` included. The compose file names it without a +// default, so a call that left it out fails to interpolate rather than quietly mounting something +// else. const docker = (...args: string[]) => execFileAsync(dockerCommand, args, {env: {...process.env, ZOOID_CONFIG: configDir}}) const compose = (...args: string[]) => docker("compose", "-f", composeFile, ...args) -// Why the container cannot be driven, or undefined when it can. The two failures need different -// answers — a cli that is not on this process's PATH is usually a shell alias or an inherited -// environment, and telling someone to start docker sends them the wrong way — so they are reported -// apart rather than as one boolean. +// Why the container cannot be driven, or undefined when it can. A cli that is not on this process's +// PATH and a daemon that is not running need different answers, so they are reported apart rather +// than as one boolean. const probeDocker = async () => { try { await docker("compose", "version") @@ -102,8 +92,8 @@ const probeDocker = async () => { } // How far the container's clock is from this host's, in seconds, read off the Date header of the -// relay's own http answer. Positive means the container is behind, which is what makes an event the -// harness has just signed look like it comes from the future. +// 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 {headers} = await requestZooid(host, "GET", "/", {accept: "application/nostr+json"}) @@ -112,10 +102,9 @@ const getClockDrift = async (host: string) => { } } -// nip-42 accepts an auth event within ten minutes of the relay's own clock (nip42.go), and the two -// clocks here belong to different machines: the harness signs with this host's, the container -// validates with the vm's. A vm whose clock stopped while the machine slept is the usual reason -// they diverge, and the relay's own one-line detail says nothing about how to fix it. +// nip-42 accepts an auth event within ten minutes of the relay's own clock (nip42.go), and under a +// container runtime's vm the two clocks belong to different machines. The relay's own one-line +// detail says nothing about how to fix that. const describeClockDrift = (drift: number) => { const [minutes, direction] = drift > 0 ? [drift / 60, "behind"] : [Math.abs(drift) / 60, "ahead of"] @@ -146,9 +135,8 @@ const getTestUser = (pubkey: string) => { let problem: Maybe>> -// Asked once a worker: docker does not come and go mid-run, and the answer is also written to the -// terminal, because a skip reason otherwise reaches only the html report and a run that silently -// skips every test is the least useful thing this can do. +// Asked once a worker, since docker does not come and go mid-run. The answer is also written to the +// terminal, because a skip reason otherwise reaches only the html report. export const describeDockerProblem = () => (problem ??= probeDocker().then(reason => { if (reason) { @@ -161,14 +149,13 @@ export const describeDockerProblem = () => })) /** - * The relay every test runs against: one zooid container on loopback, serving a virtual relay per - * entry in `tenants`. Each relay's policy is whatever its toml in docker/config says, which is the - * only place policy is written down — a scenario describes what is *on* a relay, never what the - * relay is. + * The relay every test runs against. One 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 { - // Every socket into the container — the client's and seeding's alike — is one this process - // opened, so this map is also the definition of a url that is not a leak. + // Every socket into the container, the client's and seeding's alike, is one this process opened, + // so this map is also the definition of a url that is not a leak. relays = new Map( tenantNames.map(name => [ tenantUrl(name), @@ -180,8 +167,8 @@ export class Zooid { private started = false - // Verifying docker rather than bringing the container up: every test resets, and that is what - // starts it. Repeat calls are free, so the fixture can call this per test. + // Verifies docker rather than bringing the container up, which `reset` does. Repeat calls are + // free, so the fixture can call this per test. start = async () => { if (this.started) return @@ -204,13 +191,9 @@ export class Zooid { ) } - // Nothing should answer on the relay port yet: this run has not brought its container up. If - // something does, it is a leftover container from an aborted run or another copy of the suite — - // and because the harness dials this fixed port, sharing it silently corrupts both runs. Refuse - // loudly instead. `compose down` clears a leftover of this project's own. if (await isRelayPortTaken()) { throw new Error( - `127.0.0.1:${relayHostPort} is already in use, but the zooid relay container publishes ` + + `127.0.0.1:${port} is already in use, but the zooid relay container publishes ` + "exactly that port and the harness talks to whatever answers there. This is a leftover " + "container from an interrupted run, or another copy of this suite running on the machine " + `(under any user). Free it before running — \`${dockerCommand} compose -f ` + @@ -221,7 +204,7 @@ export class Zooid { this.started = true } - relay = async (name: TenantName) => + relay = (name: TenantName) => makeTestRelay({ name, url: tenantUrl(name), @@ -273,13 +256,12 @@ export class Zooid { throw new Error(logs ? `${summary}\n\nzooid said:\n${logs}` : summary) } - // Recreating rather than restarting is what makes this a reset: with storage in tmpfs and no - // volume mounted for it, a new container is a new database. + // Storage is tmpfs with no volume mounted for it, so a new container is a new database and + // recreating is what makes this a reset. private up = async () => { - // A fresh copy on every recreate is what makes a reset restore pristine relay metadata after a - // test has edited some through nip-86. The modes are permissive because the container runs as - // uid 65532 while the copy belongs to whoever ran the suite, and a runtime that keeps host - // ownership — rootless podman, docker on linux — would otherwise leave zooid unable to write + // A fresh copy on every recreate is what restores relay metadata a test edited through nip-86. + // The modes are permissive because the container runs as uid 65532 while the copy belongs to + // whoever ran the suite, and a runtime that keeps host ownership leaves zooid unable to write // the file it was told to save. await rm(configDir, {recursive: true, force: true}) await cp(configSource, configDir, {recursive: true}) @@ -331,9 +313,8 @@ export class Zooid { } // NIP-42 binds an identity to a connection, so each test user seeds over its own, per relay. The - // url signed into the auth event is the one the client uses, because that is the one khatru - // rebuilds from the headers transport.ts sends — a fixture is written over the same relay the app - // talks to. + // 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) => { const key = `${host} ${user.pubkey}` const session = this.sessions.get(key) @@ -354,8 +335,8 @@ export class Zooid { const summary = `Failed to authenticate as ${user.name} on ${host}: ${detail}` const drift = await getClockDrift(host).catch(() => undefined) - // Nearly always the clock rather than the key: every identity here is one zooid's toml names, - // and the signature is checked last, after the timestamp the relay compares against its own. + // Nearly always the clock rather than the key. Every identity here is one zooid's toml names, + // and the signature is checked after the timestamp the relay compares against its own. if (drift !== undefined && Math.abs(drift) > int(10, MINUTE)) { throw new Error(`${summary}\n\n${describeClockDrift(drift)}`) } @@ -363,8 +344,8 @@ export class Zooid { throw new Error(summary) } - // Both halves of seeding — the auth that opens a connection and every event written over it — - // are answered with an OK naming the event that was sent. + // Both halves of seeding, the auth that opens a connection and every event written over it, are + // answered with an OK naming the event that was sent. private send = async (connection: ZooidConnection, message: ClientMessage, id: string) => { connection.send(message) diff --git a/e2e/harness/zooid/testRelay.ts b/e2e/harness/zooid/testRelay.ts index b9a730c8..6f647882 100644 --- a/e2e/harness/zooid/testRelay.ts +++ b/e2e/harness/zooid/testRelay.ts @@ -20,9 +20,8 @@ export type TestRelayOptions = { // Seeding signs the events a real client would send and publishes them over a socket. Room // administration is signed by `admin`, the only test identity the tomls grant can_manage. // -// Every timestamp is the caller's: seeding runs off the scenario's clock, and reading the wall -// clock here would leave two fixtures that describe the same thing — a room's membership, say — -// stamped seconds apart, which is enough to decide which of them wins. +// 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 => { const event = async (user: TestUser, template: StampedEvent) => { const signed = await user.signer.sign(template) diff --git a/e2e/harness/zooid/transport.ts b/e2e/harness/zooid/transport.ts index ac369e1c..fe796b82 100644 --- a/e2e/harness/zooid/transport.ts +++ b/e2e/harness/zooid/transport.ts @@ -5,22 +5,23 @@ import type {ClientMessage, RelayMessage} from "@welshman/net" import type {RelayConnection} from "./types" /** - * How the harness reaches the zooid container, and the only place that knows the container has a - * loopback address at all. + * How the harness reaches the zooid container, and the only place that knows it has a loopback + * address at all. * - * The client is given `wss://.test/` and never learns anything else. It has to be that - * rather than the container's own `ws://localhost:3334/`, because a relay selection drops a url - * that is local or insecure unless the caller opts in — see isLocalUrl and RelaySelection.getUrls - * in @welshman/util — which Flotilla never does, so the Router would resolve every outbox load, - * profile and relay hint to nothing. + * The client is given `wss://.test/` and never learns anything else. A relay selection + * drops a url that is local or insecure unless the caller opts in, which Flotilla never does + * (isLocalUrl and RelaySelection.getUrls in @welshman/util), so the container's own + * `ws://localhost:3334/` would leave the Router resolving every outbox load, profile and relay hint + * to nothing. * - * The two headers below are what makes the container answer to that name. 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), so - * both the relay the config describes and the url it expects to have been signed are the tenant's. - * They are exactly what a tls terminator adds in front of a real deployment. + * 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). */ -const port = 3334 + +// Must match the published port in zooid/docker/compose.yaml. +export const port = 3334 const headersFor = (host: string) => ({Host: host, "X-Forwarded-Proto": "https"}) @@ -31,7 +32,7 @@ export type ZooidConnection = RelayConnection & { } // One connection to the container, as one of its virtual relays. Node's own WebSocket cannot carry -// a Host header — fetch forbids it — so this is the one thing in the harness that `ws` is here for. +// a Host header, which fetch forbids, so the harness depends on `ws` for this alone. export const connectToZooid = (host: string): ZooidConnection => { const socket = new WebSocket(`ws://127.0.0.1:${port}/`, {headers: headersFor(host)}) const listeners = new Set<(message: RelayMessage) => void>() @@ -61,8 +62,8 @@ export const connectToZooid = (host: string): ZooidConnection => { onMessage: listener => { listeners.add(listener) }, - // A client may speak before the container has finished its handshake — the page's socket is - // open as soon as playwright has a route for it, which is well before this one is. + // A client may speak before the container has finished its handshake. The page's socket is open + // as soon as playwright has a route for it, well before this one is. send: message => { if (socket.readyState === WebSocket.OPEN) { write(message) @@ -91,8 +92,8 @@ export type ZooidResponse = { body: Buffer } -// One http request to the container, carrying the same two headers, so that the nip-11 document -// and the nip-86 management api the browser reads are the real relay's rather than a mock's. +// One http request to the container, carrying the same two headers, so the nip-11 document and the +// nip-86 management api the browser reads are the real relay's. export const requestZooid = ( host: string, method: string, @@ -121,8 +122,8 @@ export const requestZooid = ( const responseHeaders: Record = {} for (const [name, value] of Object.entries(response.headers)) { - // Hop-by-hop headers describe the connection this answer arrived on rather than the one - // the browser is waiting on, which playwright frames itself. + // Hop-by-hop headers describe the connection this answer arrived on, not the one the + // browser is waiting on, which playwright frames itself. if (value && !["connection", "keep-alive", "transfer-encoding"].includes(name)) { responseHeaders[name] = Array.isArray(value) ? value.join(", ") : value } diff --git a/e2e/harness/zooid/types.ts b/e2e/harness/zooid/types.ts index c85c57c1..1171c192 100644 --- a/e2e/harness/zooid/types.ts +++ b/e2e/harness/zooid/types.ts @@ -4,8 +4,8 @@ import type {TestUser} from "../keys" export type PublishOptions = { // Which identity the seeding connection authenticates as. Defaults to the event's own author, - // which then has to be a test identity — a gift wrap is signed by an ephemeral key, so nothing - // in this process can sign as its author and the connection has to belong to the sender. + // 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 } @@ -19,8 +19,7 @@ export type RoomOptions = { // A handle to one relay, plus the seeding affordances scenarios build on. Every seeding call // publishes over a real socket rather than inserting into storage, so the relay stores exactly what -// it would have stored for a real client. Each one is stamped by its caller rather than off the -// wall clock, so a scenario's fixtures share one clock. +// it would have stored for a real client. export type TestRelay = { readonly name: string readonly url: string @@ -30,13 +29,13 @@ export type TestRelay = { member(user: TestUser, h: string | undefined, createdAt: number): Promise // Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs. event(user: TestUser, event: StampedEvent): Promise - // An event this process did not sign, sent over `as`'s connection — a gift wrap, whose author - // is the ephemeral key that wrapped it. + // An event this process did not sign, sent over `as`'s connection. A gift wrap, whose author is + // the ephemeral key that wrapped it, is the case that needs this. publish(event: SignedEvent, options: PublishOptions): Promise } -// One client's connection to a relay. Every connection to a url reaches the same container, which -// is what lets one browser context observe another's writes over the wire. +// One client's connection to a relay. Every connection to a url reaches the same container, so one +// browser context observes another's writes over the wire. export type RelayConnection = { onMessage(listener: (message: RelayMessage) => void): void send(message: ClientMessage): void