diff --git a/e2e/ARCHITECTURE.md b/e2e/ARCHITECTURE.md new file mode 100644 index 00000000..bafc6a07 --- /dev/null +++ b/e2e/ARCHITECTURE.md @@ -0,0 +1,303 @@ +# E2E architecture + +Flotilla's end-to-end suite runs the real app against a relay network that is entirely under the +test's control. Nothing in this directory may ever open a connection to a host the test did not +create. That is the single invariant everything else is arranged around. + +## The relay + +Every spec runs against a real [zooid](https://github.com/coracle-social/zooid) relay in Docker, the +implementation Flotilla is built for, so protocol drift between the client's assumptions and a real +relay shows up as a failing test. + +zooid is multi-tenant: it binds a config to a `Host` header and serves any number of virtual relays +from one process. `harness/zooid/config.ts` names them, one toml apiece in `harness/zooid/docker/`, +and a scenario picks one by name. A second space costs a config file and no extra process, which is +what keeps outbox routing and cross-space isolation testable. + +A relay's policy is its toml and nothing else. A scenario says what is _on_ a relay — its rooms, its +members, its messages — never what the relay _is_, so there is no policy negotiation anywhere in the +harness. + +### Why the relays are called `.test` + +The container listens on plaintext loopback, but a url that is local or insecure is dropped from +every relay selection unless the caller opts in — see `isLocalUrl` and `RelaySelection.getUrls` in +`@welshman/util` — and Flotilla never opts in, since in production neither belongs in a routing +decision. Handed `ws://localhost:3334/`, the client would load a space by its explicit url and +resolve nothing else: no outbox loads for profiles or relay lists, no relay hints. + +So the client is given `wss://space.test/` and never learns the container has an address. Everything +that speaks to it goes through `zooid/transport.ts`, which dials `127.0.0.1:3334` carrying the two +headers a TLS terminator adds in front of a real deployment: + +- `Host: space.test` — zooid's dispatcher binds a config to a Host (`cmd/relay/main.go`), so this is + what selects which toml answers. +- `X-Forwarded-Proto: https` — khatru derives the url it checks NIP-42 and NIP-86 signatures against + from Host plus this (`getBaseURL` in `khatru/relay.go`), arriving at `wss://space.test/`, which is + exactly what the client signed into its `relay` tag. Seeding signs the same url, so a fixture is + written over the same relay the app talks to. + +`.test` is reserved by RFC 2606 and resolves nowhere, so a url that ever escapes this process fails +to connect rather than reaching a host. + +## Transport: one interception point + +All relay traffic is intercepted in the **Node** process via Playwright's `routeWebSocket`, applied +to the `BrowserContext` so every page in it is covered: + +``` +browser context ──▶ context.routeWebSocket(everything but vite's hmr socket) + │ + ▼ + zooid.relays.get(url) + │ + ┌─────────────────┴─────────────────┐ + │ │ + a socket this process opens unknown url: a relay that + to the container, as that holds no events, and the + relay's virtual host url is recorded as a leak +``` + +Every socket the browser opens is terminated here, in this process, and the only one that leaves it +is the loopback connection `zooid/transport.ts` makes to the container the test started. +`assertNoLeaks()` fails a test that touched a url the scenario never declared. + +Interception is installed by `as()`, on a context it creates, so a page that came from anywhere else +has none of it. Playwright's own `context` fixture — and the `page` fixture built on it — is +therefore overridden to throw, so a spec written the ordinary way fails immediately with a message +saying so rather than quietly dialling the relays in `.env`. The built-in `request` fixture goes the +same way: an `APIRequestContext` is an http client in the node process that belongs to no browser +context, so the block-all below cannot see it and nothing records what it sent. + +`playwright` is the one fixture left alone, and it has to be: it is where the run's own browser comes +from, so every test would fail if it threw. A spec that goes around `as()` through +`playwright.request` or `playwright.chromium.launch()` reaches the network unwatched, and no fixture +can refuse that without refusing the suite. + +Interception in Node rather than in the page is what makes multi-user testing work. Three browser +contexts logged in as three different users all dispatch into the _same_ relay, so one user +genuinely observes another user's writes, over the wire, through the client's real socket stack. + +### Why not an `AdapterFactory` + +The obvious alternative — `new App({getAdapter})` returning a `Repository`-backed adapter — cannot +test authentication. NIP-42 lives on `Socket`: `AuthState` listens to `SocketEvent.Receiving`/ +`Sending`, and `socketPolicyAuthBuffer` replays messages that were rejected with `auth-required:`. +An `AbstractAdapter` whose `sockets` getter returns `[]` never constructs any of that. Patching the +transport instead leaves `Pool → Socket → SocketAdapter` untouched, so auth, message buffering, +replay-after-auth and reconnect are all exercised as written. + +## HTTP + +Relays are not the only egress. `installHttpRoutes` routes every url that is not the dev server — a +predicate rather than a `"**/*"` pattern, so the hundreds of module requests a sveltekit page makes +in dev are never even matched — and aborts what it catches. Two origins get past it: the dev server +on `localhost:1847`, which is left unrouted, and each relay's own origin, which is forwarded to the +container by the same transport carrying the same two headers, so the NIP-11 document and the NIP-86 +management API the app reads are the real relay's answers, signed against the url the client used. +Every other request is aborted and recorded. + +A relay's NIP-11 document decides whether a space is synced by reconciliation or a plain REQ +(NIP-77), whether a message the UI composes carries a protected `-` tag (NIP-70), what the space is +called, and which pubkey room state is trusted from. Its NIP-86 answers decide whether the user is an +admin, since a relay refuses management calls from anyone else and the method list that comes back +doubles as the client's permission set — the space, room, event and pin menus, the directory and the +library are all gated on it. + +Services the app talks to are then mocked back in per-scenario, so a blocked request is always a bug +rather than ambient noise: Dufflepud (`dufflepud.coracle.social`), Blossom uploads, the push server +(`nps.flotilla.social`), the hosting API, LiveKit token endpoints, and image/thumbnail fetches. The +analytics script hard-coded in `src/app.html` is mocked with an empty body: it is requested on every +navigation whatever the scenario is doing, so leaving it to the block-all would make +`assertNoBlockedRequests()` a statement about the page shell rather than about the test. + +## Containment + +A browser has a fixed set of ways to put bytes on a wire. + +| how the app can reach the network | what stops it | +| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `WebSocket` — `@welshman/net`'s `Socket`, the only constructor call | `context.routeWebSocket(url => !isDevServerUrl(url))`. Terminated in node; an undeclared url gets a relay holding nothing and `assertNoLeaks()` fails the test | +| `fetch` / `XMLHttpRequest` — app code, welshman, `@pomade/core` | `context.route(url => !isDevServerUrl(url))`, which aborts unless a mock registered later answers first | +| `img`, `script`, `link`, fonts, media, and every other subresource | the same route. Images never reach it — `mockImages` answers anything with `resourceType() === "image"` with a 1×1 png | +| navigation, including the external links the ui offers | the same route: a document request is routed like any other, and a popup opens in the context that owns it | +| `EventSource` | the same route. Neither `src` nor welshman constructs one | +| `navigator.sendBeacon` | no call site. The one script that would use it is the plausible tag in `src/app.html`, served with an empty body | +| a service worker | `serviceWorkers: "block"` on the context. Playwright does not route a worker's requests, so the worker is refused instead | +| a web worker | no call site in `src`; `@pomade/core`'s is argon2, which is cpu and no socket | +| Capacitor's native http and push plugins | not reachable from a browser, and the zooid config leaves `[push]` disabled so nothing is asked to register | + +Two connections leave this process, both to something the test started: the browser's to the vite dev +server on `localhost:1847`, and `zooid/transport.ts`'s to `127.0.0.1:3334`. Everything else the app +initiates dies in node. + +The two ends are enforced differently. A websocket to a url no scenario declared is answered rather +than refused — by a relay that EOSEs every REQ and accepts every event into the void — and the url is +recorded, so the test fails on `assertNoLeaks()` naming it rather than on a timeout somewhere +downstream. An http request nothing mocked is aborted outright, but noticing it is opt-in: +`assertNoBlockedRequests()` is for a spec that has mocked what it exercises, because some of what a +page asks for is meant to be refused. + +Configuration is the other half. `boot()` overrides every `VITE_` value that names a relay — default, +indexer, search, messaging, signer, platform, blocked, the default space list — with the scenario's +own urls, through the hook `src/app/env.ts` reads them with, and points `VITE_PUSH_BRIDGE` at `ws://localhost:1/`, which nothing serves, so a push +bridge connection is reported as a leak rather than blending into a relay's traffic. The `VITE_` +values it does not override still name real hosts — the blossom server, the pomade signers, the +thumbnail service, the push server, the hosting api — and none of them is contacted at boot. Blossom +is read only when an upload starts, the thumbnail url only on android, pomade only when a signup uses +it, and the hosting api and dufflepud are mocked. They are contained by the block-all rather than by +configuration, which is the weaker of the two guarantees. + +Grepping the repo for hostnames accounts for all of them, and there are only three kinds. Most are an +`href` in help text — nostr.com, nostrapps.com, nsec.app, nosta.me, nostr.how, github.com, +fountain.fm, cal.com, figma.com, coracle.tools, gitea.coracle.social, nwc.getalby.com, and the +`coracle.social` entity links `src/app/env.ts` builds — reachable only by clicking, and routed if +clicked. Two are fetched: dufflepud, whose origin is the one service url hard-coded rather than read +from `VITE_`, and the plausible tag in `src/app.html`. Both are mocked. The rest are in comments. +Under `e2e` the only hostnames are the four service origins `net/http.ts` matches on, the virtual +relays' own `.test` names, and the loopback address `zooid/transport.ts` dials. + +Three things this does not cover: + +- **WebRTC.** `livekit-client` opens an `RTCPeerConnection`, and Playwright cannot see one. A voice + or video room reaches its sfu over ice and dtls with nothing in between. `mockLivekit` decides + where the client is pointed, which is why its `serverUrl` has to be something the test owns, and + why `[livekit]` is omitted from the zooid config entirely. A spec that joins a call escapes this + document's guarantee and needs its own answer. +- **Playwright's own fixtures.** `context`, `page` and `request` are overridden to throw, but + `playwright` cannot be — it is where the browser comes from. A spec that reaches the network + through `playwright.request` or `playwright.chromium.launch()` is unwatched. +- **The browser itself.** A browser's own traffic is not the app's and is not routed. Playwright + launches chromium with `--disable-background-networking`, `--disable-component-update` and + `--disable-breakpad`, which is the whole of the mitigation, and which is chromium's alone — + `E2E_BROWSER=firefox` or `webkit` runs the suite under an engine those flags say nothing about. + +## Determinism without freezing the clock + +Timestamps are never patched. Instead every fixture is generated fresh at the start of each test and +signed with timestamps relative to the moment the test began: + +```ts +const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + space.message(user.alice, "general", "morning all", at(2, HOUR)) + space.message(user.bob, "general", "morning!", at(90, MINUTE)) +}) +``` + +`at(2, HOUR)` is `now() - int(2, HOUR)` evaluated once per test, so "2 hours ago" renders the same +way on every run without the app's `now()` being touched. Fixtures are published over a real socket, +authenticated as the identity that signed them, so the relay stores exactly what it would have stored +for a real client: there are no pre-signed JSON blobs to drift out of date, and a fixture the relay +would have refused fails the test instead of appearing in a query. + +## Users and sessions + +Test identities are deterministic secp256k1 keypairs derived from fixed secrets (`alice`, `bob`, +`carol`, `admin`), so a pubkey is stable across runs and can be asserted on directly. + +A logged-in user is created by injecting a NIP-01 session before the app boots. `src/app/session.ts` +carries a DEV-only hook: `restoreSession()` prefers `window.__TEST_SESSION__` when it is present, and +`window.__TEST_EVENTS__` becomes the repository contents a returning user's client would have found +on disk. Both branches are stripped from production builds, and injecting through the hook avoids +writing Capacitor's `SecureStorage`/`Preferences` localStorage encoding by hand. + +The injected events go in _after_ `storage.ready`. Storage loads what the last session left behind +with `Repository.load`, which clears the repository before inserting, so anything put there while +IndexedDB is still opening is wiped a few milliseconds later — before the layout has rendered +anything, and long before `authPolicy` reads the room list to decide whether to answer a relay's AUTH +challenge. + +The cache exists for the harness rather than for the app. In production the client bootstraps itself: +asking a keyed collection for a pubkey loads it, so `authPolicy` reading the user's room and relay +lists to decide whether to answer a challenge is itself what fetches them, as is any outbox-routed +load of the user's own data. Kind-10002 comes back from the public indexers, and `syncRelayList` +cascades into the room list from there. + +Here the indexers _are_ the scenario's relays, because `boot()` points every relay list at them, and +those are members-only — so that first load is refused with `auth-required:`, while `authPolicy`, +conservative by default, will not sign for a url no list has named yet. Each waits on the other. The +injected room list breaks the circle, and it is what a user who joined a space through the UI would +have on disk anyway. + +The price is that no spec exercises the bootstrap: every one of them starts already knowing which +relays it belongs to, so a regression in the chain from relay list to room list, or in `authPolicy`'s +conservative gate, leaves the suite green. Covering it needs a scenario with a public relay standing +in for an indexer. + +Each user is a separate `BrowserContext`, which also gives each one its own IndexedDB and +localStorage, so nothing bleeds between users. + +## Layout + +``` +e2e/ + ARCHITECTURE.md this document + harness/ + index.ts everything a spec imports: `test`, `expect`, helpers + keys.ts deterministic keypairs + zooid/ + config.ts the virtual relays and their hosts — the one place they are named + relay.ts docker lifecycle, reset, seeding over its own authenticated socket + transport.ts the only thing that knows the container's address: ws and http to it + testRelay.ts the seeding affordances a scenario builds on + types.ts TestRelay, RoomOptions, RelayConnection + docker/ + compose.yaml no data volume; tmpfs for /app/data and /app/media + config/ one toml per virtual relay: its host, and its whole policy + net/ + websocket.ts routeWebSocket install, dispatch, transcript, leak detection + http.ts block-all + per-service mocks + app/ + boot.ts env overrides, navigate, wait for mount and for the app to have read + the overrides + session.ts NIP-01 session injection + seed/ + scenario.ts the `seed()` builder and relative-time helpers + space.ts one space's fixtures: rooms, members, messages, replies, profiles + specs/ + *.spec.ts +``` + +One piece lives outside this directory: `src/lib/test/session.ts` holds the two window keys and the +getters `src/app/session.ts` reads them through. It is the only file the app ships on the harness's +behalf, and `harness/app/session.ts` duplicates the key names rather than importing them, since +importing anything under `src` would pull sveltekit into the node process. + +## Resetting between tests + +`Zooid.reset()` recreates the container rather than restarting it: with no volume mounted for +`/app/data`, storage lives in the container's writable layer and a tmpfs, so a fresh container is a +fresh database. The configs are bind-mounted read-only and survive, so every test starts against the +same relays with nothing in them. The container is a worker fixture, so the docker start-up cost is +paid once per worker rather than once per test. + +Seeding then writes over a real authenticated socket, one per identity per relay, held open for the +rest of the test. A fixture must therefore be signed by one of the identities in `harness/keys.ts`, +since zooid authenticates every write. + +## Running + +The suite is not run by agents (see CLAUDE.md). + +```sh +pnpm exec playwright install # once +docker pull gitea.coracle.social/coracle/zooid:latest # once; the harness never pulls +pnpm test # starts and stops the container itself +``` + +Every test skips when docker is unavailable, rather than failing. + +One engine per run rather than a project per engine: these specs exercise sockets, auth and sync, so +a third copy of each buys much less than it costs. `E2E_BROWSER=webkit pnpm test` runs the whole +suite under another one. The container listens on a fixed port and cannot be sharded, which is why +`workers` is 1. + +`src/app/env.ts` resolves every `VITE_` value through a DEV-only hook that prefers +`window.__TEST_ENV__`, which `boot()` injects per browser context, so any dev server will do and a +server already listening on `:1847` is reused. `boot()` still checks that the app read the injected +values, and fails naming the hook rather than letting a run drift onto the relays in `.env`. diff --git a/e2e/harness/app/boot.ts b/e2e/harness/app/boot.ts new file mode 100644 index 00000000..0ff377a8 --- /dev/null +++ b/e2e/harness/app/boot.ts @@ -0,0 +1,96 @@ +import type {BrowserContext} from "@playwright/test" +import {MINUTE, int, ms} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import type {TestUser} from "../keys" +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. +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. + relays: string[] + spaces?: string[] + user?: TestUser + // What this user's client already has in local storage, e.g. their room list. + events?: TrustedEvent[] + path?: string +} + +export const boot = async ( + context: BrowserContext, + {relays, spaces = [], user, events = [], path = "/"}: BootOptions, +) => { + const urls = relays.join(",") + + await context.addInitScript( + ([key, readKey, env]) => { + Object.assign(window, { + [key]: new Proxy(env, { + get(target, prop) { + Object.assign(window, {[readKey]: true}) + + return Reflect.get(target, prop) + }, + }), + }) + }, + [ + TEST_ENV_KEY, + TEST_ENV_READ_KEY, + { + VITE_DEFAULT_RELAYS: urls, + VITE_INDEXER_RELAYS: urls, + VITE_DEFAULT_SEARCH_RELAYS: urls, + VITE_DEFAULT_MESSAGING_RELAYS: urls, + VITE_SIGNER_RELAYS: urls, + VITE_DEFAULT_SPACES: spaces.join(","), + VITE_PLATFORM_RELAYS: "", + VITE_BLOCKED_RELAYS: "", + // Nothing serves this url, so a push bridge connection is reported as a leak instead of + // blending into the traffic of a relay the scenario did create. + VITE_PUSH_BRIDGE: "ws://localhost:1/", + }, + ] as const, + ) + + if (user) { + await injectSession(context, user) + await injectEvents(context, events) + } + + const page = await context.newPage() + + 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. + 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. + const usedTestEnv = await page.evaluate( + key => Boolean(Reflect.get(window, key)), + TEST_ENV_READ_KEY, + ) + + if (!usedTestEnv) { + throw new Error( + "The app read none of its VITE_ values from the harness, so it is pointed at the relays in " + + ".env rather than at this scenario's. Check that src/app/env.ts still resolves them " + + "through maybeGetTestEnv, and that the dev server is running in dev mode.", + ) + } + + return page +} diff --git a/e2e/harness/app/session.ts b/e2e/harness/app/session.ts new file mode 100644 index 00000000..4e274f31 --- /dev/null +++ b/e2e/harness/app/session.ts @@ -0,0 +1,30 @@ +import type {BrowserContext} from "@playwright/test" +import type {TrustedEvent} from "@welshman/util" +import type {TestUser} from "../keys" + +// Must match TEST_SESSION_KEY and TEST_EVENTS_KEY in src/lib/test/session.ts. +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. +export const injectSession = (context: BrowserContext, user: TestUser) => + context.addInitScript( + ([key, session]) => { + Object.assign(window, {[key]: session}) + }, + [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. +export const injectEvents = (context: BrowserContext, events: TrustedEvent[]) => + context.addInitScript( + ([key, value]) => { + Object.assign(window, {[key]: value}) + }, + [TEST_EVENTS_KEY, events] as const, + ) diff --git a/e2e/harness/index.ts b/e2e/harness/index.ts new file mode 100644 index 00000000..6db81b91 --- /dev/null +++ b/e2e/harness/index.ts @@ -0,0 +1,165 @@ +import {test as base, expect} from "@playwright/test" +import type {BrowserContext, BrowserContextOptions, Page} from "@playwright/test" +import type {Maybe, MaybeAsync} from "@welshman/lib" +import {normalizeRelayUrl} from "@welshman/util" +import {Zooid, describeDockerProblem} from "./zooid/relay" +import { + installHttpRoutes, + mockAnalytics, + mockDufflepud, + mockHosting, + mockImages, + mockPushServer, +} from "./net/http" +import {assertNoLeaks, installWebSocketRoutes} from "./net/websocket" +import {boot} from "./app/boot" +import {seed} from "./seed/scenario" +import type {Scenario, SeedTools} from "./seed/scenario" +import type {TestUser} from "./keys" + +export {expect} +export {users} from "./keys" +export type {TestUser} from "./keys" +export type {Scenario} from "./seed/scenario" +export type {SeededSpace} from "./seed/space" +export type {TenantName} from "./zooid/config" +export type {TranscriptEntry} from "./net/websocket" +export {formatTranscript, getTranscript} from "./net/websocket" +export {assertNoBlockedRequests, mockBlossom, mockLivekit} 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. +const encodeRelay = (url: string) => + encodeURIComponent( + normalizeRelayUrl(url) + .replace(/^wss:\/\//, "") + .replace(/\/$/, ""), + ) + +export const spacePath = (url: string) => `/spaces/${encodeRelay(url)}` + +export const roomPath = (url: string, h: string) => `${spacePath(url)}/${h}` + +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. `options` overrides the project's context options, + // for a spec that needs a viewport or colour scheme of its own. + as(user: TestUser, path?: string, options?: BrowserContextOptions): Promise +} + +export type HarnessFixtures = { + harness: Harness + seed: Harness["seed"] + as: Harness["as"] +} + +export type HarnessWorkerFixtures = { + zooid: Zooid +} + +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()` is the only way to get a page. + context: async () => { + throw new Error( + "The built-in `context` and `page` fixtures reach the real network. Open a page with the " + + "harness's `as(user, path)` fixture, which installs interception before it navigates.", + ) + }, + // 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. + request: async () => { + throw new Error( + "The built-in `request` fixture makes http requests from node, where nothing intercepts " + + "them. Anything the app fetches belongs in a mock installed by `as(user, path)`.", + ) + }, + // One container per worker, torn down when the worker ends. It is reset between tests rather + // than recreated, so the docker start-up cost is paid once. + zooid: [ + // playwright reads a fixture's dependencies off this pattern, so it has to stay a pattern + // even when there are none. + // eslint-disable-next-line no-empty-pattern + async ({}, use) => { + const zooid = new Zooid() + + await use(zooid) + await zooid.stop() + }, + {scope: "worker"}, + ], + harness: async ({zooid, browser}, use, testInfo) => { + const dockerProblem = await describeDockerProblem() + + testInfo.skip(Boolean(dockerProblem), dockerProblem) + + await zooid.start() + await zooid.reset() + + const contexts: BrowserContext[] = [] + + let scenario: Maybe + + const requireScenario = () => { + if (scenario) return scenario + + throw new Error("Seed a scenario before opening a page for a user") + } + + const as = async (user: TestUser, path = "/", options: BrowserContextOptions = {}) => { + 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. + // + // 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. + const context = await browser.newContext({ + ...testInfo.project.use, + serviceWorkers: "block", + ...options, + }) + + 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. + await installHttpRoutes(context) + await installWebSocketRoutes(context, zooid) + await mockAnalytics(context) + await mockDufflepud(context) + await mockHosting(context) + await mockPushServer(context) + await mockImages(context) + + return boot(context, {user, path, relays: urls, spaces: urls, events: cache(user)}) + } + + await use({ + zooid, + as, + seed: async build => { + scenario = await seed(zooid, build) + + return scenario + }, + }) + + for (const context of contexts) { + await context.close() + } + + for (const context of contexts) { + assertNoLeaks(context) + } + }, + seed: async ({harness}, use) => use(harness.seed), + as: async ({harness}, use) => use(harness.as), +}) diff --git a/e2e/harness/keys.ts b/e2e/harness/keys.ts new file mode 100644 index 00000000..d17e7a69 --- /dev/null +++ b/e2e/harness/keys.ts @@ -0,0 +1,26 @@ +import {getPubkey} from "@welshman/util" +import {Nip01Signer} from "@welshman/signer" + +export type TestUser = { + name: string + secret: string + pubkey: string + signer: Nip01Signer +} + +const makeUser = (name: string, secret: string): TestUser => ({ + name, + secret, + pubkey: getPubkey(secret), + signer: new Nip01Signer(secret), +}) + +// 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. +export const users = { + alice: makeUser("alice", "a11ce00000000000000000000000000000000000000000000000000000000001"), + bob: makeUser("bob", "b0b0000000000000000000000000000000000000000000000000000000000002"), + carol: makeUser("carol", "ca20100000000000000000000000000000000000000000000000000000000003"), + admin: makeUser("admin", "ad31100000000000000000000000000000000000000000000000000000000004"), +} diff --git a/e2e/harness/net/http.ts b/e2e/harness/net/http.ts new file mode 100644 index 00000000..5c14b8d6 --- /dev/null +++ b/e2e/harness/net/http.ts @@ -0,0 +1,315 @@ +import {createHash} from "node:crypto" +import type {BrowserContext} from "@playwright/test" +import {now} from "@welshman/lib" +import type {Handle} from "@welshman/util" +import type {ZapperValues} from "@welshman/domain" +import {tenantByUrl} from "../zooid/config" +import {requestZooid} from "../zooid/transport" + +// 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. +const DUFFLEPUD_ORIGIN = "https://dufflepud.coracle.social" +const PUSH_SERVER_ORIGIN = "https://nps.flotilla.social" +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" + +// Relay-hosted livekit lives under a well-known path rather than an origin of its own. +const LIVEKIT_PATH = "/.well-known/nip29/livekit" + +// A 1x1 transparent png, small enough to inline and real enough for an to decode. +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=", + "base64", +) + +// The dev server from vite.config.ts. Traffic to it is the app loading itself rather than egress, +// so it is the one host both layers here let past, websockets included — Vite's hmr socket has to +// keep working. +export const isDevServerUrl = (url: URL) => + url.port === "1847" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) + +export type BlockedRequest = { + method: string + 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") +} + +/** + * 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) => { + const blocked: BlockedRequest[] = [] + const hostByOrigin = new Map( + Array.from(tenantByUrl, ([url, host]): [string, string] => [ + new URL(url.replace(/^ws/, "http")).origin, + host, + ]), + ) + + blockedByContext.set(context, blocked) + + // 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), + async route => { + const request = route.request() + const {origin, pathname, search} = new URL(request.url()) + + const host = hostByOrigin.get(origin) + + if (host) { + return route.fulfill( + await requestZooid( + host, + request.method(), + pathname + search, + request.headers(), + request.postDataBuffer() ?? undefined, + ), + ) + } + + blocked.push({method: request.method(), url: request.url()}) + + return route.abort() + }, + ) +} + +/** + * 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. + */ +export const assertNoBlockedRequests = (context: BrowserContext) => { + const blocked = getBlockedRequests(context) + + if (blocked.length > 0) { + throw new Error( + [ + `The app made ${blocked.length} http request(s) nothing served:`, + ...blocked.map(({method, url}) => ` ${method} ${url}`), + ].join("\n"), + ) + } +} + +/** + * 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. + */ +export const mockAnalytics = (context: BrowserContext) => + context.route(`${PLAUSIBLE_ORIGIN}/**`, route => + route.fulfill({contentType: "application/javascript", body: ""}), + ) + +export type DufflepudFixtures = { + // The remote half of the read-state sync in src/app/notifications.ts, keyed by path. + checked?: Record + // 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. + zappers?: {lnurl: string; info?: Omit}[] +} + +export const mockDufflepud = (context: BrowserContext, fixtures: DufflepudFixtures = {}) => + context.route(`${DUFFLEPUD_ORIGIN}/**`, route => { + const {pathname} = new URL(route.request().url()) + + if (pathname === "/link/preview") { + return route.fulfill({json: fixtures.preview ?? {}}) + } + + if (pathname === "/handle/info") { + return route.fulfill({json: {data: fixtures.handles ?? []}}) + } + + if (pathname === "/zapper/info") { + return route.fulfill({json: {data: fixtures.zappers ?? []}}) + } + + if (pathname === "/kv/checked") { + return route.fulfill({ + json: route.request().method() === "GET" ? (fixtures.checked ?? {}) : {}, + }) + } + + return route.fallback() + }) + +export type BlossomOptions = { + // The blossom server the scenario expects an upload to land on, e.g. a space's own url. + server: string +} + +/** + * 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) => { + const {origin} = new URL(server) + const blobs = new Map() + + return context.route(`${origin}/**`, route => { + const request = route.request() + const method = request.method() + const {pathname} = new URL(request.url()) + + if (pathname === "/upload") { + if (method === "HEAD") { + return route.fulfill({status: 200, body: ""}) + } + + if (method === "PUT") { + const body = request.postDataBuffer() ?? Buffer.alloc(0) + const type = request.headers()["content-type"] ?? "application/octet-stream" + const sha256 = createHash("sha256").update(body).digest("hex") + + blobs.set(sha256, {body, type}) + + return route.fulfill({ + json: {sha256, type, url: `${origin}/${sha256}`, size: body.length, uploaded: now()}, + }) + } + } + + const blob = blobs.get(pathname.slice(1).split(".")[0]) + + if (blob) { + return route.fulfill({ + contentType: blob.type, + body: method === "HEAD" ? "" : blob.body, + }) + } + + return route.fallback() + }) +} + +/** + * 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) => + context.route(`${PUSH_SERVER_ORIGIN}/**`, route => { + const [resource] = new URL(route.request().url()).pathname.split("/").filter(Boolean) + + if (resource === "subscription") { + if (route.request().method() === "DELETE") { + return route.fulfill({json: {}}) + } + + // Registration is only usable if it comes back with both, and the relay is told to post + // notifications to the callback rather than the client ever fetching it. + const key = "test-push-subscription" + + return route.fulfill({json: {key, callback: `${PUSH_SERVER_ORIGIN}/callback/${key}`}}) + } + + return route.fallback() + }) + +// Records straight off the hosting api, whose shapes live in src/app/hosting.ts. They're typed as +// plain 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. +export type HostingFixtures = { + plans?: object[] + // Provisioning runs on every login and ignores what it gets back, so the empty default carries + // any spec that isn't actually exercising the hosting ui. + tenant?: object + relays?: object[] + invoices?: object[] + draftInvoice?: object +} + +export const mockHosting = (context: BrowserContext, fixtures: HostingFixtures = {}) => + context.route(`${HOSTING_ORIGIN}/**`, route => { + const [resource, id, sub, detail] = new URL(route.request().url()).pathname + .split("/") + .filter(Boolean) + + if (resource === "plans") { + return route.fulfill({json: {data: fixtures.plans ?? []}}) + } + + if (resource === "tenants") { + if (sub) { + if (sub === "relays") { + return route.fulfill({json: {data: fixtures.relays ?? []}}) + } + + if (sub === "invoices") { + const data = detail === "draft" ? fixtures.draftInvoice : (fixtures.invoices ?? []) + + return route.fulfill({json: {data}}) + } + } else { + return route.fulfill({json: {data: {pubkey: id, ...fixtures.tenant}}}) + } + } + + return route.fallback() + }) + +export type LivekitOptions = { + // Where the client is told to connect. Point it at something the test owns — the token this + // hands out is accepted by nothing else. + serverUrl: string + token?: string +} + +export const mockLivekit = (context: BrowserContext, {serverUrl, token}: LivekitOptions) => + context.route( + url => url.pathname.startsWith(LIVEKIT_PATH), + route => { + const roomId = new URL(route.request().url()).pathname.slice(LIVEKIT_PATH.length + 1) + + if (roomId) { + return route.fulfill({ + json: {server_url: serverUrl, participant_token: token ?? "test-participant-token"}, + }) + } + + // The support probe in src/lib/livekit.ts reads support off the status alone. + return route.fulfill({status: 204, body: ""}) + }, + ) + +/** + * 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. + */ +export const mockImages = (context: BrowserContext) => + context.route( + url => !isDevServerUrl(url), + route => { + if (route.request().resourceType() === "image") { + return route.fulfill({contentType: "image/png", body: PNG}) + } + + return route.fallback() + }, + ) diff --git a/e2e/harness/net/websocket.ts b/e2e/harness/net/websocket.ts new file mode 100644 index 00000000..81763dd0 --- /dev/null +++ b/e2e/harness/net/websocket.ts @@ -0,0 +1,130 @@ +import type {BrowserContext, WebSocketRoute} from "@playwright/test" +import {call, parseJson} from "@welshman/lib" +import {normalizeRelayUrl} from "@welshman/util" +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 {isDevServerUrl} from "./http" + +export type Direction = "toRelay" | "toClient" + +export type TranscriptEntry = { + url: string + direction: Direction + message: ClientMessage | RelayMessage +} + +type Traffic = { + transcript: TranscriptEntry[] + leaks: Set +} + +const trafficByContext = new WeakMap() + +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. +const openEmptyRelay = (): RelayConnection => { + let listener: (message: RelayMessage) => void = () => undefined + + return { + onMessage(f) { + listener = f + }, + send(message) { + if (isClientReq(message)) { + listener([RelayMessageType.Eose, message[1]]) + } else if (isClientEvent(message)) { + listener([RelayMessageType.Ok, message[1].id, true, ""]) + } + }, + close() {}, + } +} + +const serve = (traffic: Traffic, zooid: Zooid, route: WebSocketRoute) => { + const url = normalizeRelayUrl(route.url()) + const connection = call(() => { + const relay = zooid.relays.get(url) + + if (relay) { + return relay.connect() + } + + traffic.leaks.add(url) + + return openEmptyRelay() + }) + + connection.onMessage(message => { + traffic.transcript.push({url, direction: "toClient", message}) + route.send(JSON.stringify(message)) + }) + + route.onMessage(frame => { + const message = parseJson(frame.toString()) + + if (message) { + traffic.transcript.push({url, direction: "toRelay", message}) + connection.send(message) + } + }) + + route.onClose(() => connection.close()) +} + +/** + * The single interception point for relay traffic. It goes on the context rather than a page, so + * every page in it is covered including ones opened later, and it is safe to call before any page + * exists — routing is the environment a page is born into, not a step in a sequence. + * + * 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()} + + trafficByContext.set(context, traffic) + + return context.routeWebSocket( + url => !isDevServerUrl(url), + route => serve(traffic, zooid, route), + ) +} + +export const getTranscript = (context: BrowserContext) => getTraffic(context).transcript + +// Every frame in both directions, oldest first. Attach it to a failing test to see what the client +// actually said, and to whom. +export const formatTranscript = (context: BrowserContext) => + getTranscript(context) + .map( + ({url, direction, message}) => + `${direction === "toRelay" ? ">>" : "<<"} ${url} ${JSON.stringify(message)}`, + ) + .join("\n") + +export const assertNoLeaks = (context: BrowserContext) => { + const {leaks} = getTraffic(context) + + if (leaks.size > 0) { + throw new Error( + [ + `The app opened a websocket to ${leaks.size} url(s) the scenario never created:`, + ...Array.from(leaks).map(url => ` ${url}`), + ].join("\n"), + ) + } +} diff --git a/e2e/harness/seed/scenario.ts b/e2e/harness/seed/scenario.ts new file mode 100644 index 00000000..e7a4ef5d --- /dev/null +++ b/e2e/harness/seed/scenario.ts @@ -0,0 +1,113 @@ +import {int, now} from "@welshman/lib" +import type {MaybeAsync} from "@welshman/lib" +import {ROOMS, makeEvent} from "@welshman/util" +import type {SignedEvent} from "@welshman/util" +import type {Zooid} from "../zooid/relay" +import type {TenantName} from "../zooid/config" +import {users} from "../keys" +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. +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. + relay: (name: TenantName) => SeededSpace + user: typeof users + at: At +} + +export type Scenario = { + readonly startedAt: number + 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". + cache(user: TestUser): SignedEvent[] +} + +export const seed = async ( + zooid: Zooid, + build: (tools: SeedTools) => MaybeAsync, +): Promise => { + const startedAt = now() + const at: At = (count, unit) => startedAt - int(count, unit) + const spaces = new Map() + const writes: (() => Promise)[] = [] + const roomLists = new Map() + + const relay = (name: TenantName) => { + const space = seedSpace({ + zooid, + startedAt, + name, + enqueue: write => writes.push(write), + }) + + spaces.set(name, space) + + return space + } + + await build({relay, user: users, at}) + + // Seeding is async and fixtures depend on one another, so the builder only records what to + // write. Draining the queue here publishes each fixture in the order it was declared. + for (const write of writes) { + await write() + } + + const seeded = Array.from(spaces.values()) + + // A room list is replaceable and covers every space at once, so it can only be written after + // all of them have been seeded. + const membershipsByPubkey = new Map() + + for (const space of seeded) { + for (const {user, rooms} of space.memberships) { + const membership = membershipsByPubkey.get(user.pubkey) ?? {user, urls: [], tags: []} + + membership.urls.push(space.url) + membership.tags.push(["r", space.url], ...rooms.map(h => ["group", h, space.url])) + membershipsByPubkey.set(user.pubkey, membership) + } + } + + for (const {user, urls, tags} of membershipsByPubkey.values()) { + const event = await user.signer.sign(makeEvent(ROOMS, {tags, created_at: startedAt})) + + for (const url of urls) { + await zooid.publish(url, event) + } + + roomLists.set(user.pubkey, event) + } + + const getSpace = (name: TenantName) => { + const space = spaces.get(name) + + if (space) return space + + throw new Error(`No space named "${name}" was seeded`) + } + + const cache = (user: TestUser) => { + const roomList = roomLists.get(user.pubkey) + + return roomList ? [roomList] : [] + } + + return { + startedAt, + at, + cache, + urls: seeded.map(space => space.url), + space: getSpace, + } +} diff --git a/e2e/harness/seed/space.ts b/e2e/harness/seed/space.ts new file mode 100644 index 00000000..d4cb9992 --- /dev/null +++ b/e2e/harness/seed/space.ts @@ -0,0 +1,191 @@ +import {neventEncode} from "nostr-tools/nip19" +import type {Maybe, MaybeAsync} from "@welshman/lib" +import { + MESSAGE, + ROOM_ADD_MEMBER, + Resolver, + makeEvent, + tagSpec, + tagValue, + toNostrURI, +} from "@welshman/util" +import type {SignedEvent, StampedEvent} from "@welshman/util" +import {EventWriter, Profile} from "@welshman/domain" +import type {BaseEventReader} from "@welshman/domain" +import type {RoomOptions, TestRelay} from "../zooid/types" +import type {Zooid} from "../zooid/relay" +import type {TenantName} from "../zooid/config" +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. +class MessageWriter extends EventWriter {} + +// A handle to an event the scenario is going to publish. Seeding calls record what to write and +// return before anything is written, so the event is filled in when its turn in the queue comes up. +export type SeededEvent = { + readonly event: SignedEvent + readonly id: string +} + +export type ProfileValues = { + name?: string + about?: string + picture?: string + 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. +export type Enqueue = (write: () => Promise) => void + +// A user's membership as their own client sees it, which the scenario turns into one room list +// per user once every space has been seeded. +export type SeededMembership = { + user: TestUser + rooms: string[] +} + +export type SeededSpace = { + readonly name: TenantName + // The url is read off the relay handle, so this only reads once seeding has run. + readonly url: string + 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. + 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: StampedEvent): SeededEvent +} + +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. + startedAt: number + name: TenantName +} + +export const seedSpace = ({zooid, enqueue, startedAt, name}: SeedSpaceOptions): SeededSpace => { + const memberships: SeededMembership[] = [] + + let testRelay: Maybe + + const relay = () => { + if (testRelay) return testRelay + + throw new Error(`Space "${name}" has not been seeded yet, await seed() first`) + } + + enqueue(async () => { + testRelay = await zooid.relay(name) + }) + + // Every fixture is published to this space, so a relay hint always resolves to its url. + const context = {resolver: new Resolver(() => [relay().url])} + + const publish = (write: () => Promise): SeededEvent => { + let signed: Maybe + + const event = () => { + if (signed) return signed + + throw new Error(`An event seeded into "${name}" was read before seed() published it`) + } + + enqueue(async () => { + signed = await write() + }) + + return { + get event() { + return event() + }, + get id() { + return event().id + }, + } + } + + const publishTemplate = (user: TestUser, build: () => MaybeAsync) => + publish(async () => relay().event(user, await build())) + + const room = (h: string, roomOptions: RoomOptions = {}) => + enqueue(() => relay().room(h, roomOptions, startedAt)) + + const member = (user: TestUser, h?: string) => enqueue(() => relay().member(user, h, startedAt)) + + const event = (user: TestUser, template: StampedEvent) => publishTemplate(user, () => template) + + const join = (user: TestUser, ...roomIds: string[]) => { + memberships.push({user, rooms: roomIds}) + member(user) + + for (const h of roomIds) { + event( + users.admin, + makeEvent(ROOM_ADD_MEMBER, { + created_at: startedAt, + tags: [ + ["h", h], + ["p", user.pubkey], + ], + }), + ) + } + } + + 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 + // src/app/rooms.ts. + const reply = (user: TestUser, parent: SeededEvent, content: string, createdAt = startedAt) => + publishTemplate(user, async () => { + const h = tagValue(tagSpec("h"), parent.event.tags) + + if (h) { + const url = relay().url + const nevent = neventEncode({...parent.event, relays: [url]}) + const template = await new MessageWriter(MESSAGE, context) + .setRoom(url, h) + .addQuote(parent.event) + .addMention(parent.event.pubkey) + .setContent(toNostrURI(nevent) + "\n\n" + content) + .renderTemplate() + + return {...template, created_at: createdAt} + } + + throw new Error(`Cannot reply to ${parent.id}, it is not in a room`) + }) + + const profile = (user: TestUser, values: ProfileValues, createdAt = startedAt) => + publishTemplate(user, async () => ({ + ...(await Profile.configure(context).writer().update(values).renderTemplate()), + created_at: createdAt, + })) + + return { + name, + get url() { + return relay().url + }, + memberships, + room, + member, + join, + message, + reply, + profile, + event, + } +} diff --git a/e2e/harness/zooid/config.ts b/e2e/harness/zooid/config.ts new file mode 100644 index 00000000..ed92de58 --- /dev/null +++ b/e2e/harness/zooid/config.ts @@ -0,0 +1,27 @@ +/** + * 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. + * + * 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. + */ +export const tenants = { + space: "space.test", + other: "other.test", +} as const + +export type TenantName = keyof typeof tenants + +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. +export const tenantByUrl = new Map(tenantNames.map(name => [tenantUrl(name), tenants[name]])) diff --git a/e2e/harness/zooid/docker/compose.yaml b/e2e/harness/zooid/docker/compose.yaml new file mode 100644 index 00000000..4e708166 --- /dev/null +++ b/e2e/harness/zooid/docker/compose.yaml @@ -0,0 +1,37 @@ +# The relay the whole suite runs against. Bound to loopback only, and the one server this harness is +# allowed to connect to for real. +# +# The image is distroless and runs as `nonroot`. zooid creates DATA and MEDIA itself, but it cannot +# create their parent /app, so both have to arrive already mounted — and writable by uid 65532, +# which is what `mode` below is for: docker defaults a tmpfs to 1777 and podman does not, and a +# tmpfs the relay cannot write is a container that exits 1 the moment it opens its database. +# Nothing is persisted, so `up --force-recreate` is a guaranteed-empty database. +name: flotilla-e2e-zooid + +services: + relay: + image: gitea.coracle.social/coracle/zooid:latest + # The harness dials this published port and sends a `Host` per virtual relay, which is what the + # dispatcher matches against the `host` in each file under config/. + ports: + - "127.0.0.1:3334:3334" + environment: + PORT: "3334" + CONFIG: /app/config + DATA: /app/data + MEDIA: /app/media + volumes: + - type: bind + source: ./config + target: /app/config + read_only: true + - type: tmpfs + target: /app/data + tmpfs: + size: 268435456 + mode: 01777 + - type: tmpfs + target: /app/media + tmpfs: + size: 33554432 + mode: 01777 diff --git a/e2e/harness/zooid/docker/config/other.toml b/e2e/harness/zooid/docker/config/other.toml new file mode 100644 index 00000000..68eb1e87 --- /dev/null +++ b/e2e/harness/zooid/docker/config/other.toml @@ -0,0 +1,44 @@ +# A second virtual relay, identical in policy to space.toml, for specs that need two spaces on +# different relays — outbox routing, cross-space isolation. zooid is multi-tenant, so this costs a +# file and no extra process. +# +# `host` must equal the Host header the harness sends, which is the hostname of the url the client +# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with +# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name +# that resolves nowhere. +host = "other.test" +schema = "e2e_other" +secret = "07be7000000000000000000000000000000000000000000000000000000000e7" + +[info] +# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the +# name scenarios pass to `relay()`. `pubkey` is the owner, and here it is the relay's own key (the +# pubkey for `secret`) so that [roles.admin] below is the only thing granting a test identity +# can_manage. +name = "other" +pubkey = "d9220af5f36469d17322f41902b6b30dad1869cbb473b35f5e8f8d8e924f36b8" +description = "Throwaway relay for Flotilla's end-to-end suite." + +[policy] +public_read = false +public_write = false +public_join = true +strip_signatures = false + +[groups] +enabled = true + +[management] +enabled = true + +# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have +# the relay or the client talk to a service the test did not create. +[blossom] +enabled = false + +[push] +enabled = false + +[roles.admin] +pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"] +can_manage = true diff --git a/e2e/harness/zooid/docker/config/space.toml b/e2e/harness/zooid/docker/config/space.toml new file mode 100644 index 00000000..16f60c99 --- /dev/null +++ b/e2e/harness/zooid/docker/config/space.toml @@ -0,0 +1,43 @@ +# One of the virtual relays the container serves, and the only statement of this relay's policy — +# a scenario says what is on a relay, never what the relay is. +# +# `host` must equal the Host header the harness sends, which is the hostname of the url the client +# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with +# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name +# that resolves nowhere. +host = "space.test" +schema = "e2e" +secret = "5e1f000000000000000000000000000000000000000000000000000000000005" + +[info] +# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the +# name scenarios pass to `relay()`. `pubkey` is the owner, and here it is the relay's own key (the +# pubkey for `secret`) so that [roles.admin] below is the only thing granting a test identity +# can_manage. +name = "space" +pubkey = "da00132eaf7ef6bbde47e5dc409fa1d481e518db0621af31591e0306eb48ed8a" +description = "Throwaway relay for Flotilla's end-to-end suite." + +[policy] +public_read = false +public_write = false +public_join = true +strip_signatures = false + +[groups] +enabled = true + +[management] +enabled = true + +# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have +# the relay or the client talk to a service the test did not create. +[blossom] +enabled = false + +[push] +enabled = false + +[roles.admin] +pubkeys = ["6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e"] +can_manage = true diff --git a/e2e/harness/zooid/relay.ts b/e2e/harness/zooid/relay.ts new file mode 100644 index 00000000..fb419da2 --- /dev/null +++ b/e2e/harness/zooid/relay.ts @@ -0,0 +1,264 @@ +import {execFile} from "node:child_process" +import {fileURLToPath} from "node:url" +import {promisify} from "node:util" +import {ms, sleep} from "@welshman/lib" +import type {Maybe} from "@welshman/lib" +import {makeRelayAuth} from "@welshman/util" +import type {SignedEvent} from "@welshman/util" +import {ClientMessageType, isRelayAuth, isRelayOk} from "@welshman/net" +import type {ClientMessage} from "@welshman/net" +import {users} from "../keys" +import {tenantNames, tenantUrl, tenants} from "./config" +import type {TenantName} from "./config" +import {makeTestRelay} from "./testRelay" +import {connectToZooid, requestZooid} from "./transport" +import type {ZooidConnection} from "./transport" + +const image = "gitea.coracle.social/coracle/zooid:latest" + +const composeFile = fileURLToPath(new URL("docker/compose.yaml", import.meta.url)) + +const testUsersByPubkey = new Map(Object.values(users).map(user => [user.pubkey, user])) + +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. +const dockerCommand = process.env.E2E_DOCKER ?? "docker" + +const docker = (...args: string[]) => execFileAsync(dockerCommand, args) + +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. +const probeDocker = async () => { + try { + await docker("compose", "version") + + return undefined + } catch (error) { + const {code, stderr} = Object(error) as {code?: string; stderr?: string} + + if (code === "ENOENT") { + return ( + `\`${dockerCommand}\` is not on PATH for the test runner. If it works in your shell it is ` + + "an alias or a function rather than an executable, which execFile cannot see: set " + + "E2E_DOCKER to the real command, e.g. `E2E_DOCKER=podman pnpm test`." + ) + } + + return ( + `\`${dockerCommand} compose version\` failed, so the daemon is likely not running. ` + + (stderr?.trim() || String(error)) + ) + } +} + +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. +export const describeDockerProblem = () => + (problem ??= probeDocker().then(reason => { + if (reason) { + console.warn( + `\nThe e2e suite cannot drive a container, so every test will skip:\n ${reason}\n`, + ) + } + + return reason + })) + +/** + * 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. + */ +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. + relays = new Map( + tenantNames.map(name => [ + tenantUrl(name), + {connect: () => connectToZooid(tenants[name]), host: tenants[name]}, + ]), + ) + + private sessions = new Map() + + 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. + start = async () => { + if (this.started) return + + const problem = await describeDockerProblem() + + if (problem) { + throw new Error(problem) + } + + const hasImage = await docker("image", "inspect", image).then( + () => true, + () => false, + ) + + if (!hasImage) { + throw new Error( + `The zooid image ${image} is not present locally. Fetch it with ` + + `\`${dockerCommand} pull ${image}\`, or build it from a zooid checkout with ` + + `\`${dockerCommand} build -t ${image} .\`.`, + ) + } + + this.started = true + } + + relay = async (name: TenantName) => + makeTestRelay({ + name, + url: tenantUrl(name), + publish: event => this.publish(tenantUrl(name), event), + }) + + publish = async (url: string, event: SignedEvent) => { + const relay = this.relays.get(url) + + if (!relay) { + throw new Error(`Attempted to publish to ${url}, which is not one of this container's relays`) + } + + const connection = await this.authenticate(relay.host, event.pubkey) + const {ok, detail} = await this.send(connection, [ClientMessageType.Event, event], event.id) + + if (!ok) { + throw new Error(`zooid refused a kind ${event.kind} fixture: ${detail}`) + } + } + + reset = async () => { + this.closeSessions() + + await this.up() + } + + stop = async () => { + if (this.started) { + this.closeSessions() + + await compose("down") + + this.started = false + } + } + + // Whatever the relay wrote before it died. Compose reports only that a container exited, so + // without this a startup failure is a status code and no reason. + private logs = () => + compose("logs", "--no-color", "--tail", "50").then( + ({stdout, stderr}) => [stdout, stderr].filter(Boolean).join("\n").trim(), + () => "", + ) + + private fail = async (summary: string) => { + const logs = await this.logs() + + 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. + private up = async () => { + try { + await compose("up", "-d", "--force-recreate", "--wait") + } catch (error) { + const {stderr} = Object(error) as {stderr?: string} + + await this.fail((stderr?.trim() || String(error)).split("\n").slice(-3).join("\n")) + } + + const deadline = Date.now() + ms(30) + + while (Date.now() < deadline) { + // A 404 means the dispatcher has no relay bound to that host yet, so the configs are still + // loading. Anything a relay itself answers means every tenant is ready. + const isUp = await Promise.all( + tenantNames.map(name => + requestZooid(tenants[name], "GET", "/", {accept: "application/nostr+json"}).then( + response => response.status === 200, + () => false, + ), + ), + ).then(results => results.every(Boolean)) + + if (isUp) { + return + } + + await sleep(200) + } + + await this.fail( + `zooid did not answer as ${tenantNames.map(name => tenants[name]).join(" and ")} within 30 ` + + "seconds.", + ) + } + + // 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. + private authenticate = async (host: string, pubkey: string) => { + const key = `${host} ${pubkey}` + const session = this.sessions.get(key) + + if (session) return session + + const user = testUsersByPubkey.get(pubkey) + + if (!user) { + throw new Error( + `Cannot publish as ${pubkey}: zooid authenticates every write, so seeded events must be ` + + "signed by one of the test identities in e2e/harness/keys.ts", + ) + } + + const connection = connectToZooid(host) + const [, challenge] = await connection.wait(isRelayAuth) + const event = await user.signer.sign(makeRelayAuth(`wss://${host}/`, challenge)) + const {ok, detail} = await this.send(connection, [ClientMessageType.Auth, event], event.id) + + if (ok) { + this.sessions.set(key, connection) + + return connection + } + + throw new Error(`Failed to authenticate as ${user.name} on ${host}: ${detail}`) + } + + // 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) + + const [, , ok, detail] = await connection.wait(reply => isRelayOk(reply) && reply[1] === id) + + return {ok, detail} + } + + private closeSessions = () => { + for (const connection of this.sessions.values()) { + connection.close() + } + + this.sessions.clear() + } +} diff --git a/e2e/harness/zooid/testRelay.ts b/e2e/harness/zooid/testRelay.ts new file mode 100644 index 00000000..741aaa58 --- /dev/null +++ b/e2e/harness/zooid/testRelay.ts @@ -0,0 +1,72 @@ +import { + MESSAGE, + RELAY_JOIN, + ROOM_ADD_MEMBER, + ROOM_CREATE, + ROOM_EDIT_META, + makeEvent, +} from "@welshman/util" +import type {SignedEvent, StampedEvent} from "@welshman/util" +import {users} from "../keys" +import type {TestUser} from "../keys" +import type {TestRelay} from "./types" + +export type TestRelayOptions = { + name: string + url: string + publish: (event: SignedEvent) => Promise +} + +// Seeding signs the events a real client would send and publishes them over a socket. Room +// administration is signed by `admin`, the only test identity the tomls grant can_manage. +// +// Every timestamp is the caller's: 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. +export const makeTestRelay = ({name, url, publish}: TestRelayOptions): TestRelay => { + const event = async (user: TestUser, template: StampedEvent) => { + const signed = await user.signer.sign(template) + + await publish(signed) + + return signed + } + + return { + name, + url, + event, + room: async (h, meta, createdAt) => { + // A relay stamps the metadata it derives from these ops with the op's own created_at, so + // creation has to be strictly older than the edit or the edit is dropped as stale. + await event( + users.admin, + makeEvent(ROOM_CREATE, {tags: [["h", h]], created_at: createdAt - 1}), + ) + + const tags = [["h", h]] + + if (meta.name) tags.push(["name", meta.name]) + if (meta.about) tags.push(["about", meta.about]) + if (meta.picture) tags.push(["picture", meta.picture]) + if (meta.closed) tags.push(["closed"]) + if (meta.private) tags.push(["private"]) + + await event(users.admin, makeEvent(ROOM_EDIT_META, {tags, created_at: createdAt})) + }, + message: (user, h, content, createdAt) => + event(user, makeEvent(MESSAGE, {content, tags: [["h", h]], created_at: createdAt})), + member: async (user, h, createdAt) => { + await event(user, makeEvent(RELAY_JOIN, {created_at: createdAt})) + + if (h) { + const tags = [ + ["h", h], + ["p", user.pubkey], + ] + + await event(users.admin, makeEvent(ROOM_ADD_MEMBER, {tags, created_at: createdAt})) + } + }, + } +} diff --git a/e2e/harness/zooid/transport.ts b/e2e/harness/zooid/transport.ts new file mode 100644 index 00000000..ac369e1c --- /dev/null +++ b/e2e/harness/zooid/transport.ts @@ -0,0 +1,144 @@ +import {request as httpRequest} from "node:http" +import WebSocket from "ws" +import {parseJson} from "@welshman/lib" +import type {ClientMessage, RelayMessage} from "@welshman/net" +import type {RelayConnection} from "./types" + +/** + * How the harness reaches the zooid container, and the only place that knows the container 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 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. + */ +const port = 3334 + +const headersFor = (host: string) => ({Host: host, "X-Forwarded-Proto": "https"}) + +export type ZooidConnection = RelayConnection & { + // Resolves with the first message the relay sends that matches. Seeding waits for its auth + // challenge and for the OK answering each write this way. + wait(match: (message: RelayMessage) => boolean): Promise +} + +// One connection to the container, as one of its virtual relays. Node's own WebSocket cannot carry +// a Host header — fetch forbids it — so this is the one thing in the harness that `ws` is here for. +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>() + const pending: ClientMessage[] = [] + + const write = (message: ClientMessage) => socket.send(JSON.stringify(message)) + + socket.on("open", () => pending.splice(0).forEach(write)) + + socket.on("message", data => { + const message = parseJson(data.toString()) + + if (message) { + for (const listener of listeners) { + listener(message) + } + } + }) + + // The likeliest error here is a 404 from zooid's dispatcher, which means `tenants` in config.ts + // and the `host` in the tenant's toml have drifted apart. + socket.on("error", error => + console.error(`The zooid container refused a socket for ${host}: ${error.message}`), + ) + + return { + 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. + send: message => { + if (socket.readyState === WebSocket.OPEN) { + write(message) + } else { + pending.push(message) + } + }, + close: () => socket.close(), + wait: match => + new Promise(resolve => { + const listener = (message: RelayMessage) => { + if (match(message)) { + listeners.delete(listener) + resolve(message) + } + } + + listeners.add(listener) + }), + } +} + +export type ZooidResponse = { + status: number + headers: Record + 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. +export const requestZooid = ( + host: string, + method: string, + path: string, + requestHeaders: Record, + body?: Buffer, +): Promise => + new Promise((resolve, reject) => { + const request = httpRequest( + { + method, + path, + host: "127.0.0.1", + port, + headers: { + // A pseudo-header describes the request line rather than the request, and node:http + // rejects a header name with a colon in it. + ...Object.fromEntries( + Object.entries(requestHeaders).filter(([name]) => !name.startsWith(":")), + ), + ...headersFor(host), + }, + }, + response => { + const chunks: Buffer[] = [] + 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. + if (value && !["connection", "keep-alive", "transfer-encoding"].includes(name)) { + responseHeaders[name] = Array.isArray(value) ? value.join(", ") : value + } + } + + response.on("data", chunk => chunks.push(chunk)) + response.on("end", () => + resolve({ + status: response.statusCode ?? 500, + headers: responseHeaders, + body: Buffer.concat(chunks), + }), + ) + }, + ) + + request.on("error", reject) + request.end(body) + }) diff --git a/e2e/harness/zooid/types.ts b/e2e/harness/zooid/types.ts new file mode 100644 index 00000000..021b688c --- /dev/null +++ b/e2e/harness/zooid/types.ts @@ -0,0 +1,34 @@ +import type {SignedEvent, StampedEvent} from "@welshman/util" +import type {ClientMessage, RelayMessage} from "@welshman/net" +import type {TestUser} from "../keys" + +export type RoomOptions = { + name?: string + about?: string + picture?: string + closed?: boolean + private?: boolean +} + +// A handle to one relay, plus the seeding affordances scenarios build on. Every seeding call +// publishes over a real socket rather than inserting into storage, so the relay stores exactly what +// it would have stored for a real client. Each one is stamped by its caller rather than off the +// wall clock, so a scenario's fixtures share one clock. +export type TestRelay = { + readonly name: string + readonly url: string + room(h: string, options: RoomOptions, createdAt: number): Promise + message(user: TestUser, h: string, content: string, createdAt: number): Promise + // Grants relay membership, and room membership too when `h` is given. + 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 +} + +// 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. +export type RelayConnection = { + onMessage(listener: (message: RelayMessage) => void): void + send(message: ClientMessage): void + close(): void +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts deleted file mode 100644 index 38899da3..00000000 --- a/e2e/smoke.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import {expect, test} from "@playwright/test" - -test("boots the SPA on the home page", async ({page}) => { - const response = await page.goto("/") - - expect(response?.ok()).toBeTruthy() - - // adapter-static serves an empty shell that hydrates client-side, so the presence of - // rendered text proves the Svelte app actually mounted (not just that a file was served). - // TODO: tighten this to assert concrete onboarding UI once the markup is settled. - await expect(page.locator("body")).toContainText(/\S/, {timeout: 15_000}) -}) diff --git a/e2e/specs/auth.spec.ts b/e2e/specs/auth.spec.ts new file mode 100644 index 00000000..52a42166 --- /dev/null +++ b/e2e/specs/auth.spec.ts @@ -0,0 +1,43 @@ +import {CLIENT_AUTH} from "@welshman/util" +import {ClientMessageType, RelayMessageType} from "@welshman/net" +import {expect, getTranscript, roomPath, test, users} from "../harness" + +test("authenticates over nip-42 before a members-only relay serves anything", async ({ + seed, + as, +}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("backroom", {name: "Backroom"}) + space.join(user.alice, "backroom") + space.message(user.alice, "backroom", "the secret handshake") + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "backroom")) + + await expect(page.getByText("the secret handshake")).toBeVisible() + + const transcript = getTranscript(page.context()).filter(entry => entry.url === url) + const challengedAt = transcript.findIndex( + ({direction, message}) => direction === "toClient" && message[0] === RelayMessageType.Auth, + ) + const authenticatedAt = transcript.findIndex( + ({direction, message}) => direction === "toRelay" && message[0] === ClientMessageType.Auth, + ) + const firstEventAt = transcript.findIndex( + ({direction, message}) => direction === "toClient" && message[0] === RelayMessageType.Event, + ) + + expect(challengedAt).toBeGreaterThanOrEqual(0) + expect(authenticatedAt).toBeGreaterThan(challengedAt) + expect(transcript[authenticatedAt].message[1]).toMatchObject({ + kind: CLIENT_AUTH, + pubkey: users.alice.pubkey, + }) + + // The whole point of intercepting the transport rather than swapping in an adapter: until the + // client proves who it is, the relay hands it nothing at all. + expect(firstEventAt).toBeGreaterThan(authenticatedAt) +}) diff --git a/e2e/specs/multiuser.spec.ts b/e2e/specs/multiuser.spec.ts new file mode 100644 index 00000000..3c02be17 --- /dev/null +++ b/e2e/specs/multiuser.spec.ts @@ -0,0 +1,27 @@ +import {expect, roomPath, test, users} from "../harness" + +test("delivers one user's message to another user's open page", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + }) + + const {url} = scenario.space("space") + const path = roomPath(url, "general") + + // Two browser contexts, two identities, one relay: bob's page is already listening when alice + // sends, so the message reaches him over the wire rather than out of a shared repository. + const alice = await as(users.alice, path) + const bob = await as(users.bob, path) + + await expect(bob.getByRole("link", {name: "General"})).toBeVisible() + + await alice.locator(".chat-editor [contenteditable=true]").pressSequentially("anyone there?") + await alice.locator(".chat-editor [contenteditable=true]").press("Enter") + + await expect(alice.getByText("anyone there?")).toBeVisible() + await expect(bob.getByText("anyone there?")).toBeVisible() +}) diff --git a/e2e/specs/routing.spec.ts b/e2e/specs/routing.spec.ts new file mode 100644 index 00000000..2c2dc576 --- /dev/null +++ b/e2e/specs/routing.spec.ts @@ -0,0 +1,57 @@ +import {spec} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import {RelayMessageType} from "@welshman/net" +import {expect, getTranscript, roomPath, test, users} from "../harness" + +test("keeps two spaces' contents on their own relays", async ({seed, as}) => { + const scenario = await seed(({relay, user}) => { + const space = relay("space") + const other = relay("other") + + // Same room id on both relays, so anything that keys rooms by id alone conflates them. + space.room("lounge", {name: "Space Lounge"}) + other.room("lounge", {name: "Other Lounge"}) + space.join(user.alice, "lounge") + other.join(user.alice, "lounge") + space.message(user.alice, "lounge", "only in space") + other.message(user.alice, "lounge", "only in other") + }) + + const space = scenario.space("space") + const other = scenario.space("other") + const page = await as(users.alice, roomPath(space.url, "lounge")) + + const deliveredByOther = (match: (event: TrustedEvent) => boolean) => + getTranscript(page.context()).some( + ({url, direction, message}) => + url === other.url && + direction === "toClient" && + message[0] === RelayMessageType.Event && + match(message[2]), + ) + + await expect(page.getByText("only in space")).toBeVisible() + await expect(page.getByRole("link", {name: "Space Lounge"})).toBeVisible() + + // Alice belongs to both spaces, so the client is talking to the other relay at the same time — + // its room and its messages just don't belong in this one. It syncs independently, so wait until + // both have actually reached this page: `toHaveCount(0)` is equally satisfied by an element that + // has not loaded yet, which would pass against a client that does conflate them. + await expect.poll(() => deliveredByOther(event => event.content === "only in other")).toBe(true) + await expect + .poll(() => deliveredByOther(event => event.tags.some(spec(["name", "Other Lounge"])))) + .toBe(true) + + await expect(page.getByText("only in other")).toHaveCount(0) + await expect(page.getByRole("link", {name: "Other Lounge"})).toHaveCount(0) + + const strays = getTranscript(page.context()).filter( + ({url, direction, message}) => + direction === "toClient" && + message[0] === RelayMessageType.Event && + message[2].content === "only in space" && + url !== space.url, + ) + + expect(strays).toEqual([]) +}) diff --git a/e2e/specs/space.spec.ts b/e2e/specs/space.spec.ts new file mode 100644 index 00000000..51882fd4 --- /dev/null +++ b/e2e/specs/space.spec.ts @@ -0,0 +1,26 @@ +import {HOUR, MINUTE} from "@welshman/lib" +import {expect, roomPath, test, users} from "../harness" + +test("renders a seeded space, its rooms and its messages", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("space") + + space.room("general", {name: "General"}) + space.room("random", {name: "Random"}) + space.join(user.alice, "general") + space.join(user.bob, "general") + space.message(user.bob, "general", "morning all", at(2, HOUR)) + space.message(user.alice, "general", "morning!", at(90, MINUTE)) + }) + + const {url} = scenario.space("space") + const page = await as(users.alice, roomPath(url, "general")) + + // The room the user belongs to and the one they don't are both advertised by the relay, so + // both appear in the space menu. + await expect(page.getByRole("link", {name: "General"})).toBeVisible() + await expect(page.getByRole("link", {name: "Random"})).toBeVisible() + + await expect(page.getByText("morning all")).toBeVisible() + await expect(page.getByText("morning!")).toBeVisible() +}) diff --git a/e2e/support/fixtures.ts b/e2e/support/fixtures.ts deleted file mode 100644 index 778b2664..00000000 --- a/e2e/support/fixtures.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type {SignedEvent} from "@welshman/util" -import type {RelayMockConfig} from "../../src/lib/test/relayMocks" - -import relay1Events from "./fixtures/relay1.json" - -// Fake relay urls used by tests. Each maps to a json fixture under ./fixtures/ and an entry in -// EVENTS_BY_RELAY below. To add a relay: drop a `.json` file in ./fixtures/, import it, add a -// url here, and wire it into EVENTS_BY_RELAY. -export const FIXTURE_RELAYS = { - relay1: "wss://relay1.test/", -} as const - -// The events each fake relay serves. The json files hold static, pre-signed events: schnorr -// signatures are non-deterministic, so events are signed once and committed verbatim (they pass -// verifyEvent, which netContext.isEventValid enforces). Regenerate with @welshman/signer: -// await Nip01Signer.fromSecret(secret).sign(makeEvent(kind, {content, created_at})) -const EVENTS_BY_RELAY: Record = { - [FIXTURE_RELAYS.relay1]: relay1Events as SignedEvent[], -} - -// Build a RelayMockConfig populating the given fixture relays (all of them when none are passed). -// Any relay not included returns nothing, keeping tests offline. -export const relayFixtures = (...urls: string[]): RelayMockConfig => { - const selected = urls.length > 0 ? urls : Object.keys(EVENTS_BY_RELAY) - - return { - relays: Object.fromEntries(selected.map(url => [url, EVENTS_BY_RELAY[url] ?? []])), - } -} diff --git a/e2e/support/fixtures/relay1.json b/e2e/support/fixtures/relay1.json deleted file mode 100644 index 671c88f5..00000000 --- a/e2e/support/fixtures/relay1.json +++ /dev/null @@ -1,29 +0,0 @@ -[ - { - "kind": 0, - "content": "{\"name\":\"Alice\"}", - "tags": [], - "created_at": 1700000000, - "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - "id": "9b3d138641b38364945b20d800268006c2cb7d974bb4b1d63a9f90f5ab974b90", - "sig": "de6b86274e7bcf6c02aa881ada1feee9e01ba320691711d4975916b3cd231ab43cf469a47c5db99503ed72707d5db85fede1ad3763c4fbd7c998d04f00eda6bc" - }, - { - "kind": 1, - "content": "hello from the fixture relay", - "tags": [], - "created_at": 1700000000, - "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", - "id": "b9874875bfa8d830c5c9ef3673104360cf21b94848a311febfaf52f0a652b1a9", - "sig": "85df94a2e9884ac3d280145492d5191cde2948d49a824c443a1f5d2143633eff1e1789fa7e8843b6efc3dd2dc0d7e33322edb628125d8e35de8ddca1d06ca970" - }, - { - "kind": 1, - "content": "reply from bob", - "tags": [], - "created_at": 1700000001, - "pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", - "id": "171dcbdd63d474ba46da609e8b0104cbcf4801fbb581b6c343d9426280f9e1be", - "sig": "eecf26e616a6b70dc67c7eae16fc4fe159314647ba1d4581332257de7f070410aa9a9e41f571b9403ff145d16c9b32766846fce08516201263e25cf08c1ed8f1" - } -] diff --git a/e2e/support/relayMocks.ts b/e2e/support/relayMocks.ts deleted file mode 100644 index 46eaf581..00000000 --- a/e2e/support/relayMocks.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type {Page} from "@playwright/test" -import type {RelayMockConfig} from "../../src/lib/test/relayMocks" - -// Must match RELAY_MOCKS_KEY in src/lib/test/relayMocks.ts. -const RELAY_MOCKS_KEY = "__RELAY_MOCKS__" - -// Hard safety net: intercept every real websocket so a test can never reach the network, even if -// some code path opens a socket directly (e.g. relay AUTH) rather than going through the adapter -// layer. We never call route.connectToServer(), so the socket connects to Playwright's in-process -// mock and simply receives nothing. -export const blockWebsockets = (page: Page) => page.routeWebSocket(/^wss?:\/\//, () => {}) - -// Inject the relay-mock config the app reads on startup. addInitScript runs before any page script -// on every navigation, so this must be called before page.goto(). -export const injectRelayConfig = (page: Page, config: RelayMockConfig) => - page.addInitScript( - ([key, value]) => { - Object.assign(window, {[key]: value}) - }, - [RELAY_MOCKS_KEY, config] as const, - ) - -// Full network isolation plus optional fixtures, in one call. With no config, every relay returns -// nothing (requirement 1). Pass {relays: {url: events}} to populate specific relays (requirement 2). -export const setupRelayMocks = async (page: Page, config: RelayMockConfig = {}) => { - await blockWebsockets(page) - await injectRelayConfig(page, config) -} - -export type {RelayMockConfig} diff --git a/link_deps b/link_deps index 6a929c18..b6b4d64b 100755 --- a/link_deps +++ b/link_deps @@ -10,27 +10,44 @@ if (execSync('git status --porcelain', { encoding: 'utf8' }).trim() && !force) { process.exit(1) } -const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')) +// One entry per directory under ../welshman/packages. +const packages = [ + 'app', + 'content', + 'domain', + 'editor', + 'feeds', + 'lib', + 'net', + 'signer', + 'store', + 'util', +] -pkg.pnpm = pkg.pnpm || {} -pkg.pnpm.overrides = pkg.pnpm.overrides || {} -pkg.pnpm.overrides["@welshman/app"] = "link:../welshman/packages/app" -pkg.pnpm.overrides["@welshman/content"] = "link:../welshman/packages/content" -pkg.pnpm.overrides["@welshman/editor"] = "link:../welshman/packages/editor" -pkg.pnpm.overrides["@welshman/feeds"] = "link:../welshman/packages/feeds" -pkg.pnpm.overrides["@welshman/lib"] = "link:../welshman/packages/lib" -pkg.pnpm.overrides["@welshman/net"] = "link:../welshman/packages/net" -pkg.pnpm.overrides["@welshman/router"] = "link:../welshman/packages/router" -pkg.pnpm.overrides["@welshman/signer"] = "link:../welshman/packages/signer" -pkg.pnpm.overrides["@welshman/store"] = "link:../welshman/packages/store" -pkg.pnpm.overrides["@welshman/util"] = "link:../welshman/packages/util" -// pkg.pnpm.overrides["nostr-editor"] = "link:../nostr-editor" -// pkg.pnpm.overrides["@pomade/core"] = "link:../pomade/packages/core" -// pkg.pnpm.overrides["nostr-signer-capacitor-plugin"] = "link:../nostr-signer-capacitor-plugin" +// pnpm 11 no longer reads the `pnpm` field in package.json, so overrides live in the workspace +// file. They are written for the install and reverted straight after: a committed link: override +// points every clone and every ci run at a checkout that only exists on one machine. +const file = 'pnpm-workspace.yaml' +const workspace = fs.readFileSync(file, 'utf8') -fs.writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n') +if (!/^overrides:$/m.test(workspace)) { + console.error(`Error: ${file} has no \`overrides:\` block to link welshman into.`) + process.exit(1) +} -execSync('pnpm i', { stdio: 'inherit' }) +const links = packages + .map(name => ` '@welshman/${name}': link:../welshman/packages/${name}`) + .join('\n') -execSync('git checkout -f pnpm-lock.yaml', { stdio: 'inherit' }) -execSync('git checkout -f package.json', { stdio: 'inherit' }) +// Other packages worth linking now and then: +// '@pomade/core': link:../pomade/packages/core +// nostr-editor: link:../nostr-editor +// nostr-signer-capacitor-plugin: link:../nostr-signer-capacitor-plugin + +fs.writeFileSync(file, workspace.replace(/^overrides:$/m, `overrides:\n${links}`)) + +try { + execSync('pnpm i', { stdio: 'inherit' }) +} finally { + execSync(`git checkout -f ${file} pnpm-lock.yaml`, { stdio: 'inherit' }) +} diff --git a/package.json b/package.json index 8db6574a..4ee51538 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "release:android": "./build.sh && cap build android --androidreleasetype APK --signing-type apksigner", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check src && eslint src", + "lint": "prettier --check src e2e playwright.config.ts && eslint src e2e", "test": "playwright test", "test:ui": "playwright test --ui", "format": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write", @@ -26,6 +26,7 @@ "@tailwindcss/postcss": "^4.2.2", "@types/eslint": "^9.6.1", "@types/node": "^25.9.1", + "@types/ws": "^8.18.1", "autoprefixer": "^10.4.23", "classnames": "^2.5.1", "eslint": "^9.39.2", @@ -41,7 +42,8 @@ "tailwindcss": "^4.2.2", "typescript": "^5.9.3", "typescript-eslint": "^8.53.1", - "vite": "^6.4.2" + "vite": "^6.4.2", + "ws": "^8.18.3" }, "type": "module", "dependencies": { @@ -101,5 +103,5 @@ "throttle-debounce": "^5.0.2", "tippy.js": "^6.3.7" }, - "packageManager": "pnpm@11.5.1" + "packageManager": "pnpm@11.20.0" } diff --git a/playwright.config.ts b/playwright.config.ts index 7b3bde87..5a83b315 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,27 +1,47 @@ import {defineConfig, devices} from "@playwright/test" -// E2E tests live in ./e2e and run against the dev server (port 1847 from vite.config.ts). -// Run with `pnpm test:e2e` (after `pnpm exec playwright install` to fetch browsers). +// One engine per run rather than a project per engine: these specs exercise protocol behaviour — +// sockets, auth, sync — so a third copy of each buys much less than it costs. Run the whole suite +// under another engine with `E2E_BROWSER=webkit pnpm test`. +const deviceNames: Record = { + chromium: "Desktop Chrome", + firefox: "Desktop Firefox", + webkit: "Desktop Safari", +} + +const device = devices[deviceNames[process.env.E2E_BROWSER ?? "chromium"]] + export default defineConfig({ - testDir: "e2e", - fullyParallel: true, + testDir: "e2e/specs", forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - reporter: "html", + // Fixture setup is charged to the test, and the harness fixture does a lot of it: the container + // is recreated and waits for the relay to answer, seeding authenticates a socket per + // identity, and the first test of a run also pays vite's on-demand compile of the whole + // sveltekit graph. Playwright's 30s default kills that partway through as a flake. + timeout: 120_000, + // An assertion carries its own deadline, which the test's does not bound, and the first one in a + // spec runs the moment the shell mounts: it waits out the socket opening, the nip-42 exchange, + // the three negentropy pulls a space is synced with and a render. Playwright's 5s default is a + // coin flip on a loaded machine. + expect: {timeout: 30_000}, + // One container on a fixed port, so the suite can't be sharded. + workers: 1, + // list alongside html: the html report is where a failure is investigated, but a run that skips + // has to say so on the terminal, where the person who started it is looking. + reporter: [["list"], ["html"]], use: { baseURL: "http://localhost:1847", trace: "on-first-retry", }, - // Boots the SvelteKit dev server before the suite and reuses one if already running locally. + // Boots the SvelteKit dev server before the suite and reuses one if already running locally. The + // app resolves its VITE_ values against a key the harness injects per browser context, so any + // dev server will do. webServer: { command: "pnpm dev", url: "http://localhost:1847", reuseExistingServer: !process.env.CI, timeout: 120_000, }, - projects: [ - {name: "chromium", use: {...devices["Desktop Chrome"]}}, - {name: "firefox", use: {...devices["Desktop Firefox"]}}, - {name: "webkit", use: {...devices["Desktop Safari"]}}, - ], + projects: [{name: "zooid", use: {...device}}], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0758c76..d421bbaa 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f0dddb1..8c27bfba 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ allowBuilds: cbor-extract: false esbuild: false sharp: true + nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47: true minimumReleaseAgeExclude: - '@pomade/core' - '@welshman/app' @@ -16,14 +17,4 @@ minimumReleaseAgeExclude: - '@welshman/store' - '@welshman/util' overrides: - '@welshman/app': link:../welshman/packages/app - '@welshman/content': link:../welshman/packages/content - '@welshman/domain': link:../welshman/packages/domain - '@welshman/editor': link:../welshman/packages/editor - '@welshman/feeds': link:../welshman/packages/feeds - '@welshman/lib': link:../welshman/packages/lib - '@welshman/net': link:../welshman/packages/net - '@welshman/signer': link:../welshman/packages/signer - '@welshman/store': link:../welshman/packages/store - '@welshman/util': link:../welshman/packages/util sharp: ^0.35.3 diff --git a/src/app/core.ts b/src/app/core.ts index 21cb5f3c..d79c6f80 100644 --- a/src/app/core.ts +++ b/src/app/core.ts @@ -39,7 +39,6 @@ import { } from "@welshman/app" import type {AppPolicy, DerivedPlugin, Plugin, Session} from "@welshman/app" import type {BaseEventReader, EventWriter, KindFactory} from "@welshman/domain" -import {maybeMakeRelayMockAdapter} from "@lib/test/relayMocks" import {DEFAULT_RELAYS, DEFAULT_SEARCH_RELAYS, DUFFLEPUD_URL, INDEXER_RELAYS} from "@app/env" // Flotilla's own policies (ingest, sockets, storage) can't be imported here — they depend on @@ -55,9 +54,6 @@ export const appPolicies: AppPolicy[] = [ const makeApp = (user?: User) => { const instance: App = new App({ user, - // Test-only: when Playwright has injected window.__RELAY_MOCKS__, serve relays from in-memory - // fixtures instead of the network. No-op for real users; stripped from production builds. - getAdapter: import.meta.env.DEV ? maybeMakeRelayMockAdapter() : undefined, config: { dufflepudUrl: DUFFLEPUD_URL, getDefaultRelays: always(DEFAULT_RELAYS), diff --git a/src/app/env.ts b/src/app/env.ts index 313d4daf..172d63bf 100644 --- a/src/app/env.ts +++ b/src/app/env.ts @@ -2,69 +2,76 @@ import {Capacitor} from "@capacitor/core" import * as nip19 from "nostr-tools/nip19" import {identity} from "@welshman/lib" import {normalizeRelayUrl} from "@welshman/util" +import {maybeGetTestEnv} from "@lib/test/env" const fromCsv = (s: string) => (s || "").split(",").filter(identity) -export const PUSH_SERVER = import.meta.env.VITE_PUSH_SERVER +// Test-only: when Playwright has injected window.__TEST_ENV__, VITE_ values resolve against it, so +// each browser context can be pointed at the relays its own test created. Vite folds DEV to false +// in a production build, so the branch and the import are stripped from it. +const env = (key: string): string => + (import.meta.env.DEV ? maybeGetTestEnv(key) : undefined) ?? import.meta.env[key] -export const PUSH_BRIDGE = normalizeRelayUrl(import.meta.env.VITE_PUSH_BRIDGE) +export const PUSH_SERVER = env("VITE_PUSH_SERVER") + +export const PUSH_BRIDGE = normalizeRelayUrl(env("VITE_PUSH_BRIDGE")) export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios" -export const SIGNER_RELAYS = fromCsv(import.meta.env.VITE_SIGNER_RELAYS).map(normalizeRelayUrl) +export const SIGNER_RELAYS = fromCsv(env("VITE_SIGNER_RELAYS")).map(normalizeRelayUrl) -export const BLOCKED_RELAYS = fromCsv(import.meta.env.VITE_BLOCKED_RELAYS).map(normalizeRelayUrl) +export const BLOCKED_RELAYS = fromCsv(env("VITE_BLOCKED_RELAYS")).map(normalizeRelayUrl) -export const INDEXER_RELAYS = fromCsv(import.meta.env.VITE_INDEXER_RELAYS).map(normalizeRelayUrl) +export const INDEXER_RELAYS = fromCsv(env("VITE_INDEXER_RELAYS")).map(normalizeRelayUrl) -export const DEFAULT_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_RELAYS).map(normalizeRelayUrl) +export const DEFAULT_RELAYS = fromCsv(env("VITE_DEFAULT_RELAYS")).map(normalizeRelayUrl) -export const DEFAULT_SEARCH_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_SEARCH_RELAYS).map( +export const DEFAULT_SEARCH_RELAYS = fromCsv(env("VITE_DEFAULT_SEARCH_RELAYS")).map( normalizeRelayUrl, ) -export const DEFAULT_MESSAGING_RELAYS = fromCsv(import.meta.env.VITE_DEFAULT_MESSAGING_RELAYS).map( +export const DEFAULT_MESSAGING_RELAYS = fromCsv(env("VITE_DEFAULT_MESSAGING_RELAYS")).map( normalizeRelayUrl, ) -export const PLATFORM_RELAYS = fromCsv(import.meta.env.VITE_PLATFORM_RELAYS).map(normalizeRelayUrl) +export const PLATFORM_RELAYS = fromCsv(env("VITE_PLATFORM_RELAYS")).map(normalizeRelayUrl) -export const PLATFORM_URL = import.meta.env.VITE_PLATFORM_URL +export const PLATFORM_URL = env("VITE_PLATFORM_URL") -export const PLATFORM_TERMS = import.meta.env.VITE_PLATFORM_TERMS +export const PLATFORM_TERMS = env("VITE_PLATFORM_TERMS") -export const PLATFORM_PRIVACY = import.meta.env.VITE_PLATFORM_PRIVACY +export const PLATFORM_PRIVACY = env("VITE_PLATFORM_PRIVACY") export const PLATFORM_LOGO = import.meta.env.PROD ? PLATFORM_URL + "/logo.png" - : import.meta.env.VITE_PLATFORM_LOGO.replace(/^static/, "") || PLATFORM_URL + "/logo.png" + : env("VITE_PLATFORM_LOGO").replace(/^static/, "") || PLATFORM_URL + "/logo.png" -export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME +export const PLATFORM_NAME = env("VITE_PLATFORM_NAME") -export const PLATFORM_ACCENT = import.meta.env.VITE_PLATFORM_ACCENT +export const PLATFORM_ACCENT = env("VITE_PLATFORM_ACCENT") // components visual preset (see src/lib/components/theme.css). Selected per // deployment via VITE_THEME, which is assumed to be set to a known theme // (e.g. "clay" or "flat") — there is no default. -export const FL_THEME = import.meta.env.VITE_THEME +export const FL_THEME = env("VITE_THEME") -export const PLATFORM_DESCRIPTION = import.meta.env.VITE_PLATFORM_DESCRIPTION +export const PLATFORM_DESCRIPTION = env("VITE_PLATFORM_DESCRIPTION") -export const POMADE_SIGNERS = fromCsv(import.meta.env.VITE_POMADE_SIGNERS) +export const POMADE_SIGNERS = fromCsv(env("VITE_POMADE_SIGNERS")) -export const DEFAULT_BLOSSOM_SERVERS = fromCsv(import.meta.env.VITE_DEFAULT_BLOSSOM_SERVERS) +export const DEFAULT_BLOSSOM_SERVERS = fromCsv(env("VITE_DEFAULT_BLOSSOM_SERVERS")) -export const DEFAULT_SPACES = fromCsv(import.meta.env.VITE_DEFAULT_SPACES).map(normalizeRelayUrl) +export const DEFAULT_SPACES = fromCsv(env("VITE_DEFAULT_SPACES")).map(normalizeRelayUrl) -export const DEFAULT_PUBKEYS = import.meta.env.VITE_DEFAULT_PUBKEYS +export const DEFAULT_PUBKEYS = env("VITE_DEFAULT_PUBKEYS") -export const HOSTING_BACKEND_URL = import.meta.env.VITE_HOSTING_BACKEND_URL +export const HOSTING_BACKEND_URL = env("VITE_HOSTING_BACKEND_URL") -export const HOSTING_RELAY_DOMAIN = import.meta.env.VITE_HOSTING_RELAY_DOMAIN +export const HOSTING_RELAY_DOMAIN = env("VITE_HOSTING_RELAY_DOMAIN") export const DUFFLEPUD_URL = "https://dufflepud.coracle.social" -export const THUMBNAIL_URL = import.meta.env.VITE_THUMBNAIL_URL +export const THUMBNAIL_URL = env("VITE_THUMBNAIL_URL") export const dufflepud = (path: string) => DUFFLEPUD_URL + "/" + path diff --git a/src/app/session.ts b/src/app/session.ts index 33b10a72..06817cb5 100644 --- a/src/app/session.ts +++ b/src/app/session.ts @@ -2,6 +2,7 @@ import type {ClientOptions} from "@pomade/core" import type {Wallet} from "@welshman/util" import {nip01, nip07, nip46, nip55, pomade, toSession} from "@welshman/app" import type {Session} from "@welshman/app" +import {getTestEvents, maybeGetTestSession} from "@lib/test/session" import {app, login, session} from "@app/core" import {wallet} from "@app/lightning" import {kv, ss, storage} from "@app/storage" @@ -61,12 +62,27 @@ const readLegacySession = async () => { // The session is derived from the app's user, so it can't be synced to storage directly — // read it back once at startup, then persist it whenever the identity changes. export const restoreSession = async () => { - const $session = (await ss.get("session")) ?? (await readLegacySession()) + // Test-only: when Playwright has injected window.__TEST_SESSION__, that identity wins over + // whatever is in storage. No-op for real users; stripped from production builds. + const testSession = import.meta.env.DEV ? maybeGetTestSession() : undefined + const $session = testSession ?? (await ss.get("session")) ?? (await readLegacySession()) if ($session) { await login($session) } + // Logging in builds a fresh app, so the test's cached events go into its repository afterwards, + // the same way storage loads what the last session left behind. Storage gets there first and + // loads with Repository.load, which clears the repository before inserting, so these have to + // wait for it or they are wiped before anything reads them. + if (testSession) { + await storage.get()?.ready + + for (const event of getTestEvents()) { + app.get().repository.publish(event) + } + } + return session.subscribe($session => { if ($session) { ss.set("session", $session) diff --git a/src/lib/test/env.ts b/src/lib/test/env.ts new file mode 100644 index 00000000..0115f3b7 --- /dev/null +++ b/src/lib/test/env.ts @@ -0,0 +1,10 @@ +import type {Maybe} from "@welshman/lib" + +// The window key Playwright writes to (see e2e/harness/app/boot.ts). Keep it in sync with the +// literal duplicated there. +export const TEST_ENV_KEY = "__TEST_ENV__" + +// Consulted when the app resolves a VITE_ value, so each browser context can be pointed at the +// relays its own test created. Yields nothing for real users. +export const maybeGetTestEnv = (key: string): Maybe => + (globalThis as {[TEST_ENV_KEY]?: Record})[TEST_ENV_KEY]?.[key] diff --git a/src/lib/test/relayMocks.ts b/src/lib/test/relayMocks.ts deleted file mode 100644 index 2d1a1514..00000000 --- a/src/lib/test/relayMocks.ts +++ /dev/null @@ -1,83 +0,0 @@ -import {on} from "@welshman/lib" -import type {Maybe} from "@welshman/lib" -import {isRelayUrl, normalizeRelayUrl} from "@welshman/util" -import type {TrustedEvent} from "@welshman/util" -import {AbstractAdapter, AdapterEvent, LocalAdapter, Repository} from "@welshman/net" -import type {AdapterFactory, ClientMessage, RelayMessage} from "@welshman/net" - -// The window key Playwright writes the mock config to (see e2e/support/relayMocks.ts). Keep it in -// sync with the literal duplicated there. -export const RELAY_MOCKS_KEY = "__RELAY_MOCKS__" - -export type RelayMockConfig = { - // Map of relay url -> events that relay should return. Any relay NOT listed returns nothing (an - // immediate EOSE), which is what keeps tests offline and reproducible by default. - relays?: Record -} - -// Wraps welshman's LocalAdapter so we reuse its REQ/EVENT/CLOSE handling against an in-memory -// Repository, but re-emits its messages under the real relay url instead of LOCAL_RELAY_URL. That -// keeps relay attribution / relay-scoped behaviour (e.g. NIP-29 groups) working as it would over a -// real socket. (Composition rather than inheritance because LocalAdapter emits via a private method -// that hardcodes LOCAL_RELAY_URL.) -class FixtureAdapter extends AbstractAdapter { - readonly local: LocalAdapter - - constructor( - readonly url: string, - repository: Repository, - ) { - super() - - this.local = new LocalAdapter(repository) - - const forward = (message: RelayMessage) => this.emit(AdapterEvent.Receive, message, this.url) - - this._unsubscribers.push(on(this.local, AdapterEvent.Receive, forward), () => - this.local.cleanup(), - ) - } - - get sockets() { - return this.local.sockets - } - - get urls() { - return [this.url] - } - - send(message: ClientMessage) { - this.local.send(message) - } -} - -// An adapter factory serving every real relay url from memory, so no websocket is ever created for -// one. Non-relay urls (e.g. the local:// repository relay) fall through to welshman's default -// handling by returning undefined. -export const makeRelayMockAdapter = (config: RelayMockConfig): AdapterFactory => { - const reposByUrl = new Map() - - for (const [url, events] of Object.entries(config.relays ?? {})) { - const repository = new Repository() - repository.load(events) - reposByUrl.set(normalizeRelayUrl(url), repository) - } - - const emptyRepository = new Repository() - - return (url: string) => { - if (isRelayUrl(url)) { - return new FixtureAdapter(url, reposByUrl.get(normalizeRelayUrl(url)) ?? emptyRepository) - } - } -} - -// Called when the app is created. Yields an adapter factory only when Playwright has injected a -// config, so it is a no-op for real users. -export const maybeMakeRelayMockAdapter = (): Maybe => { - const config = (globalThis as {[RELAY_MOCKS_KEY]?: RelayMockConfig})[RELAY_MOCKS_KEY] - - if (config) { - return makeRelayMockAdapter(config) - } -} diff --git a/src/lib/test/session.ts b/src/lib/test/session.ts new file mode 100644 index 00000000..10f5bc33 --- /dev/null +++ b/src/lib/test/session.ts @@ -0,0 +1,19 @@ +import type {Maybe} from "@welshman/lib" +import type {TrustedEvent} from "@welshman/util" +import type {Session} from "@welshman/app" + +// The window keys Playwright writes to (see e2e/harness/app/session.ts). Keep them in sync with +// the literals duplicated there. +export const TEST_SESSION_KEY = "__TEST_SESSION__" + +export const TEST_EVENTS_KEY = "__TEST_EVENTS__" + +// Called when the session is restored at startup. Yields a session only when Playwright has +// injected one, so it is a no-op for real users. +export const maybeGetTestSession = (): Maybe => + (globalThis as {[TEST_SESSION_KEY]?: Session})[TEST_SESSION_KEY] + +// The events a returning user's client would have found in storage, loaded into the repository +// alongside the injected session so a test boots into a state the app can reach on its own. +export const getTestEvents = (): TrustedEvent[] => + (globalThis as {[TEST_EVENTS_KEY]?: TrustedEvent[]})[TEST_EVENTS_KEY] ?? [] diff --git a/tsconfig.json b/tsconfig.json index 593dc196..d0138305 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,22 @@ "sourceMap": true, "strict": true, "moduleResolution": "bundler" - } + }, + // TypeScript does not merge includes, so this repeats .svelte-kit/tsconfig.json's list in order + // to add the e2e harness, which runs in node and is otherwise checked by nothing. + "include": [ + ".svelte-kit/ambient.d.ts", + ".svelte-kit/non-ambient.d.ts", + ".svelte-kit/types/**/$types.d.ts", + "vite.config.ts", + "playwright.config.ts", + "src/**/*.js", + "src/**/*.ts", + "src/**/*.svelte", + "test/**/*.ts", + "tests/**/*.ts", + "e2e/**/*.ts" + ] // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files // diff --git a/vite.config.ts b/vite.config.ts index 23db1456..752a5bed 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,23 @@ config({path: ".env.local"}) config({path: ".env"}) export default defineConfig({ + // @welshman/editor takes tiptap as peer dependencies, so an installed copy resolves them from + // here and there is one prosemirror. link_deps symlinks its source instead, and that directory + // carries node_modules from welshman's own install — a second prosemirror, whose Plugin and + // PluginKey classes tiptap refuses to mix with ours ("Adding different instances of a keyed + // plugin"). Deduping resolves these to this project's copy wherever they are imported from, and + // does nothing when only one copy exists. + resolve: { + dedupe: [ + "@tiptap/core", + "@tiptap/pm", + "@tiptap/suggestion", + "prosemirror-model", + "prosemirror-state", + "prosemirror-transform", + "prosemirror-view", + ], + }, server: { port: 1847, // host: "0.0.0.0",