Add e2e tests
This commit is contained in:
parent
91a8995a98
commit
85a4ba127a
38 changed files with 2371 additions and 257 deletions
303
e2e/ARCHITECTURE.md
Normal file
303
e2e/ARCHITECTURE.md
Normal file
|
|
@ -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 `<name>.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`.
|
||||
96
e2e/harness/app/boot.ts
Normal file
96
e2e/harness/app/boot.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
30
e2e/harness/app/session.ts
Normal file
30
e2e/harness/app/session.ts
Normal file
|
|
@ -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,
|
||||
)
|
||||
165
e2e/harness/index.ts
Normal file
165
e2e/harness/index.ts
Normal file
|
|
@ -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<void>): Promise<Scenario>
|
||||
// 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<Page>
|
||||
}
|
||||
|
||||
export type HarnessFixtures = {
|
||||
harness: Harness
|
||||
seed: Harness["seed"]
|
||||
as: Harness["as"]
|
||||
}
|
||||
|
||||
export type HarnessWorkerFixtures = {
|
||||
zooid: Zooid
|
||||
}
|
||||
|
||||
export const test = base.extend<HarnessFixtures, HarnessWorkerFixtures>({
|
||||
// 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<Scenario>
|
||||
|
||||
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),
|
||||
})
|
||||
26
e2e/harness/keys.ts
Normal file
26
e2e/harness/keys.ts
Normal file
|
|
@ -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"),
|
||||
}
|
||||
315
e2e/harness/net/http.ts
Normal file
315
e2e/harness/net/http.ts
Normal file
|
|
@ -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 <img> 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<BrowserContext, BlockedRequest[]>()
|
||||
|
||||
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<string, number>
|
||||
// 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<ZapperValues, "lnurl">}[]
|
||||
}
|
||||
|
||||
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<string, {body: Buffer; type: string}>()
|
||||
|
||||
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()
|
||||
},
|
||||
)
|
||||
130
e2e/harness/net/websocket.ts
Normal file
130
e2e/harness/net/websocket.ts
Normal file
|
|
@ -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<string>
|
||||
}
|
||||
|
||||
const trafficByContext = new WeakMap<BrowserContext, Traffic>()
|
||||
|
||||
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<ClientMessage>(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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
113
e2e/harness/seed/scenario.ts
Normal file
113
e2e/harness/seed/scenario.ts
Normal file
|
|
@ -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<void>,
|
||||
): Promise<Scenario> => {
|
||||
const startedAt = now()
|
||||
const at: At = (count, unit) => startedAt - int(count, unit)
|
||||
const spaces = new Map<string, SeededSpace>()
|
||||
const writes: (() => Promise<void>)[] = []
|
||||
const roomLists = new Map<string, SignedEvent>()
|
||||
|
||||
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<string, {user: TestUser; urls: string[]; tags: string[][]}>()
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
191
e2e/harness/seed/space.ts
Normal file
191
e2e/harness/seed/space.ts
Normal file
|
|
@ -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<BaseEventReader> {}
|
||||
|
||||
// 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>) => 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<TestRelay>
|
||||
|
||||
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<SignedEvent>): SeededEvent => {
|
||||
let signed: Maybe<SignedEvent>
|
||||
|
||||
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<StampedEvent>) =>
|
||||
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,
|
||||
}
|
||||
}
|
||||
27
e2e/harness/zooid/config.ts
Normal file
27
e2e/harness/zooid/config.ts
Normal file
|
|
@ -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]]))
|
||||
37
e2e/harness/zooid/docker/compose.yaml
Normal file
37
e2e/harness/zooid/docker/compose.yaml
Normal file
|
|
@ -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
|
||||
44
e2e/harness/zooid/docker/config/other.toml
Normal file
44
e2e/harness/zooid/docker/config/other.toml
Normal file
|
|
@ -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
|
||||
43
e2e/harness/zooid/docker/config/space.toml
Normal file
43
e2e/harness/zooid/docker/config/space.toml
Normal file
|
|
@ -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
|
||||
264
e2e/harness/zooid/relay.ts
Normal file
264
e2e/harness/zooid/relay.ts
Normal file
|
|
@ -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<Promise<Maybe<string>>>
|
||||
|
||||
// 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<string, ZooidConnection>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
72
e2e/harness/zooid/testRelay.ts
Normal file
72
e2e/harness/zooid/testRelay.ts
Normal file
|
|
@ -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<void>
|
||||
}
|
||||
|
||||
// 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}))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
144
e2e/harness/zooid/transport.ts
Normal file
144
e2e/harness/zooid/transport.ts
Normal file
|
|
@ -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://<tenant>.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<RelayMessage>
|
||||
}
|
||||
|
||||
// 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<RelayMessage>(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<string, string>
|
||||
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<string, string>,
|
||||
body?: Buffer,
|
||||
): Promise<ZooidResponse> =>
|
||||
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<string, string> = {}
|
||||
|
||||
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)
|
||||
})
|
||||
34
e2e/harness/zooid/types.ts
Normal file
34
e2e/harness/zooid/types.ts
Normal file
|
|
@ -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<void>
|
||||
message(user: TestUser, h: string, content: string, createdAt: number): Promise<SignedEvent>
|
||||
// Grants relay membership, and room membership too when `h` is given.
|
||||
member(user: TestUser, h: string | undefined, createdAt: number): Promise<void>
|
||||
// Escape hatch for kinds with no affordance of their own: profiles, reactions, threads, DMs.
|
||||
event(user: TestUser, event: StampedEvent): Promise<SignedEvent>
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
@ -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})
|
||||
})
|
||||
43
e2e/specs/auth.spec.ts
Normal file
43
e2e/specs/auth.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
27
e2e/specs/multiuser.spec.ts
Normal file
27
e2e/specs/multiuser.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
57
e2e/specs/routing.spec.ts
Normal file
57
e2e/specs/routing.spec.ts
Normal file
|
|
@ -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([])
|
||||
})
|
||||
26
e2e/specs/space.spec.ts
Normal file
26
e2e/specs/space.spec.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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 `<name>.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<string, SignedEvent[]> = {
|
||||
[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] ?? []])),
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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}
|
||||
57
link_deps
57
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' })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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}}],
|
||||
})
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>("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>("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)
|
||||
|
|
|
|||
10
src/lib/test/env.ts
Normal file
10
src/lib/test/env.ts
Normal file
|
|
@ -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<string> =>
|
||||
(globalThis as {[TEST_ENV_KEY]?: Record<string, string>})[TEST_ENV_KEY]?.[key]
|
||||
|
|
@ -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<string, TrustedEvent[]>
|
||||
}
|
||||
|
||||
// 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<string, Repository>()
|
||||
|
||||
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<AdapterFactory> => {
|
||||
const config = (globalThis as {[RELAY_MOCKS_KEY]?: RelayMockConfig})[RELAY_MOCKS_KEY]
|
||||
|
||||
if (config) {
|
||||
return makeRelayMockAdapter(config)
|
||||
}
|
||||
}
|
||||
19
src/lib/test/session.ts
Normal file
19
src/lib/test/session.ts
Normal file
|
|
@ -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<Session> =>
|
||||
(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] ?? []
|
||||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue