2026-08-06 15:43:57 +00:00
|
|
|
import {createHash} from "node:crypto"
|
|
|
|
|
import type {BrowserContext} from "@playwright/test"
|
2026-08-20 20:33:20 +00:00
|
|
|
import {HOUR, int, now, omit} from "@welshman/lib"
|
2026-08-06 15:43:57 +00:00
|
|
|
import type {Handle} from "@welshman/util"
|
|
|
|
|
import type {ZapperValues} from "@welshman/domain"
|
|
|
|
|
import {tenantByUrl} from "../zooid/config"
|
|
|
|
|
import {requestZooid} from "../zooid/transport"
|
2026-09-02 13:50:57 +00:00
|
|
|
import {makeContextStore} from "./context"
|
2026-08-06 15:43:57 +00:00
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// Mirrors the service urls in src/app/env.ts and .env, which this process can't import because
|
|
|
|
|
// env.ts reads import.meta.env and pulls in Capacitor. These are route patterns rather than urls
|
|
|
|
|
// anything fetches, and every handler answers from memory.
|
2026-08-06 15:43:57 +00:00
|
|
|
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"
|
|
|
|
|
|
2026-09-09 14:43:55 +00:00
|
|
|
// Transcription and speech both go here, against whichever key the user has saved.
|
|
|
|
|
const OPENROUTER_ORIGIN = "https://openrouter.ai"
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// Where the hosting api sends a browser to pay. `.test` resolves nowhere and the block-all aborts
|
|
|
|
|
// the navigation, so a spec sees the redirect without one leaving.
|
2026-08-20 20:33:20 +00:00
|
|
|
const CHECKOUT_ORIGIN = "https://checkout.test"
|
|
|
|
|
|
2026-08-06 15:43:57 +00:00
|
|
|
// 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",
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-09 01:07:48 +00:00
|
|
|
// The dev server from vite.config.ts, on the port playwright.config.ts started it on. Traffic to it
|
|
|
|
|
// is the app loading itself rather than egress, so it is the one host both layers here let past,
|
|
|
|
|
// websockets included for Vite's hmr socket.
|
2026-08-06 15:43:57 +00:00
|
|
|
export const isDevServerUrl = (url: URL) =>
|
2026-09-09 01:07:48 +00:00
|
|
|
url.port === (process.env.E2E_PORT ?? "1847") &&
|
|
|
|
|
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
2026-08-06 15:43:57 +00:00
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
// A relay is reached over wss and its own http origin is where nip-11 and nip-86 live.
|
|
|
|
|
const relayOrigin = (url: string) => new URL(url.replace(/^ws/, "http")).origin
|
|
|
|
|
|
|
|
|
|
const hostByOrigin = new Map(
|
|
|
|
|
Array.from(tenantByUrl, ([url, host]): [string, string] => [relayOrigin(url), host]),
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-06 15:43:57 +00:00
|
|
|
export type BlockedRequest = {
|
|
|
|
|
method: string
|
|
|
|
|
url: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
const blockedStore = makeContextStore<BlockedRequest[]>("installHttpRoutes")
|
2026-08-06 15:43:57 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
2026-09-02 13:50:57 +00:00
|
|
|
* app reads are the real relay's. Install this first. The per-service mocks below are registered
|
2026-08-06 15:43:57 +00:00
|
|
|
* later and therefore take priority, so a request that still reaches this handler is one nothing
|
|
|
|
|
* has mocked.
|
|
|
|
|
*/
|
|
|
|
|
export const installHttpRoutes = (context: BrowserContext) => {
|
2026-09-02 13:50:57 +00:00
|
|
|
const blocked = blockedStore.set(context, [])
|
2026-08-06 15:43:57 +00:00
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// The dev server is left unrouted rather than matched and continued. A sveltekit page in dev is
|
2026-08-06 15:43:57 +00:00
|
|
|
// 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
|
2026-09-02 13:50:57 +00:00
|
|
|
* scenario stood up. Some of what a page asks for is meant to be refused, such as the blossom probe
|
|
|
|
|
* against a relay with blossom off, so call this from a spec that has mocked what it exercises
|
|
|
|
|
* rather than from teardown.
|
2026-08-06 15:43:57 +00:00
|
|
|
*/
|
|
|
|
|
export const assertNoBlockedRequests = (context: BrowserContext) => {
|
2026-09-02 13:50:57 +00:00
|
|
|
const blocked = blockedStore.get(context)
|
2026-08-06 15:43:57 +00:00
|
|
|
|
|
|
|
|
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"),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
// Fields to merge over a relay's own nip-11 document, keyed by its url.
|
|
|
|
|
export type RelayInfoOverrides = Record<string, object>
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-02 13:50:57 +00:00
|
|
|
* A relay's real document with a few fields replaced, such as a `redirect_to`, a `limitation`, or a
|
|
|
|
|
* NIP the relay does not implement. It is merged rather than fabricated because `self`, `pubkey`,
|
|
|
|
|
* `name` and `supported_nips` are what room state is trusted from, and a hand-written document
|
|
|
|
|
* breaks every room on the page.
|
2026-08-20 20:33:20 +00:00
|
|
|
*
|
2026-09-02 13:50:57 +00:00
|
|
|
* Install it before the page navigates. The document is read at startup and cached from then on.
|
2026-08-20 20:33:20 +00:00
|
|
|
*/
|
|
|
|
|
export const mockRelayInfo = (context: BrowserContext, overrides: RelayInfoOverrides) => {
|
|
|
|
|
const overrideByOrigin = new Map(
|
|
|
|
|
Object.entries(overrides).map(([url, override]): [string, object] => [
|
|
|
|
|
relayOrigin(url),
|
|
|
|
|
override,
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return context.route(
|
|
|
|
|
url => url.pathname === "/" && overrideByOrigin.has(url.origin),
|
|
|
|
|
async route => {
|
|
|
|
|
const request = route.request()
|
|
|
|
|
const {origin} = new URL(request.url())
|
|
|
|
|
const host = hostByOrigin.get(origin)
|
|
|
|
|
const override = overrideByOrigin.get(origin)
|
|
|
|
|
|
|
|
|
|
// The nip-86 management api posts to this same path, and what it answers decides what the
|
|
|
|
|
// admin ui offers, so only the document request is touched.
|
|
|
|
|
if (request.method() === "GET" && host && override) {
|
|
|
|
|
const {status, headers, body} = await requestZooid(host, "GET", "/", {
|
|
|
|
|
...request.headers(),
|
2026-09-02 13:50:57 +00:00
|
|
|
// The merge has to read the document, and khatru compresses it when invited to.
|
2026-08-20 20:33:20 +00:00
|
|
|
"accept-encoding": "identity",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return route.fulfill({
|
|
|
|
|
status,
|
|
|
|
|
// khatru's Access-Control-Allow-Origin is what makes the fetch legal, so the relay's own
|
2026-09-02 13:50:57 +00:00
|
|
|
// headers are kept, all but the length of a body that is about to change.
|
2026-08-20 20:33:20 +00:00
|
|
|
headers: omit(["content-length"], headers),
|
|
|
|
|
body: JSON.stringify({...JSON.parse(body.toString()), ...override}),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return route.fallback()
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 15:43:57 +00:00
|
|
|
/**
|
|
|
|
|
* The analytics script src/app.html loads on every page. Answering with an empty body leaves
|
2026-09-02 13:50:57 +00:00
|
|
|
* `window.plausible` as the queueing shim src/app/analytics.ts installs, so pageviews accumulate in
|
|
|
|
|
* memory and nothing is ever sent.
|
2026-08-06 15:43:57 +00:00
|
|
|
*/
|
|
|
|
|
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}[]
|
2026-09-02 13:50:57 +00:00
|
|
|
// `lnurl` is hex here rather than bech32, which is the encoding dufflepud speaks.
|
2026-08-06 15:43:57 +00:00
|
|
|
zappers?: {lnurl: string; info?: Omit<ZapperValues, "lnurl">}[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
/**
|
|
|
|
|
* Dufflepud, whose origin is the one service url the app hard-codes rather than reading from a
|
2026-09-02 13:50:57 +00:00
|
|
|
* `VITE_` value. `as()` installs it with no fixtures, so a spec that needs one calls this again
|
|
|
|
|
* with its own. Playwright matches the most recently registered route first, so the spec's answers
|
|
|
|
|
* win over the empty defaults.
|
2026-08-20 20:33:20 +00:00
|
|
|
*/
|
2026-08-06 15:43:57 +00:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-09 14:43:55 +00:00
|
|
|
// Silence as a wav, built rather than inlined so a spec can ask for a length and then assert the
|
|
|
|
|
// duration the player reads off it. 16 bit mono pcm is the shortest header a browser will decode.
|
|
|
|
|
const silence = (seconds: number) => {
|
|
|
|
|
const rate = 8000
|
|
|
|
|
const bytes = seconds * rate * 2
|
|
|
|
|
const wav = Buffer.alloc(44 + bytes)
|
|
|
|
|
|
|
|
|
|
wav.write("RIFF", 0)
|
|
|
|
|
wav.writeUInt32LE(36 + bytes, 4)
|
|
|
|
|
wav.write("WAVEfmt ", 8)
|
|
|
|
|
wav.writeUInt32LE(16, 16)
|
|
|
|
|
wav.writeUInt16LE(1, 20)
|
|
|
|
|
wav.writeUInt16LE(1, 22)
|
|
|
|
|
wav.writeUInt32LE(rate, 24)
|
|
|
|
|
wav.writeUInt32LE(rate * 2, 28)
|
|
|
|
|
wav.writeUInt16LE(2, 32)
|
|
|
|
|
wav.writeUInt16LE(16, 34)
|
|
|
|
|
wav.write("data", 36)
|
|
|
|
|
wav.writeUInt32LE(bytes, 40)
|
|
|
|
|
|
|
|
|
|
return wav
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* OpenRouter's text to speech, answering every request with the same silence. The array it returns
|
|
|
|
|
* collects what the app asked to have read, in the order it asked.
|
|
|
|
|
*/
|
|
|
|
|
export const mockOpenRouterSpeech = async (context: BrowserContext, seconds = 3) => {
|
|
|
|
|
const spoken: string[] = []
|
|
|
|
|
|
|
|
|
|
await context.route(`${OPENROUTER_ORIGIN}/api/v1/audio/speech`, route => {
|
|
|
|
|
spoken.push(JSON.parse(route.request().postData() ?? "{}").input)
|
|
|
|
|
|
|
|
|
|
return route.fulfill({contentType: "audio/wav", body: silence(seconds)})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return spoken
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 15:43:57 +00:00
|
|
|
export type BlossomOptions = {
|
|
|
|
|
// The blossom server the scenario expects an upload to land on, e.g. a space's own url.
|
|
|
|
|
server: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-02 13:50:57 +00:00
|
|
|
* A blossom server that keeps what it was given. An upload is hashed exactly as the real thing
|
2026-08-06 15:43:57 +00:00
|
|
|
* 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: {}})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// Registration is only usable if it comes back with both. The relay is told to post
|
2026-08-06 15:43:57 +00:00
|
|
|
// 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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
// One record straight off the hosting api, whose shapes live in src/app/hosting.ts. They're left
|
2026-09-02 13:50:57 +00:00
|
|
|
// as loose objects here so that mocking a payment flow doesn't pull the app's module graph, and
|
|
|
|
|
// with it import.meta.env, into the node process. A relay record may also carry `members` and
|
2026-08-20 20:33:20 +00:00
|
|
|
// `activity`, and an invoice `items` and `bolt11`, which is where those endpoints answer from.
|
|
|
|
|
export type HostingRecord = Record<string, unknown>
|
|
|
|
|
|
|
|
|
|
// The backend's state when the page opens. Everything after that is what the app wrote, plus
|
|
|
|
|
// whatever the handle below changed.
|
2026-08-06 15:43:57 +00:00
|
|
|
export type HostingFixtures = {
|
2026-08-20 20:33:20 +00:00
|
|
|
plans?: HostingRecord[]
|
2026-08-06 15:43:57 +00:00
|
|
|
// 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.
|
2026-08-20 20:33:20 +00:00
|
|
|
tenant?: HostingRecord
|
|
|
|
|
relays?: HostingRecord[]
|
|
|
|
|
invoices?: HostingRecord[]
|
|
|
|
|
draftInvoice?: HostingRecord
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// The backend changing its mind between two of the user's clicks, such as a custom domain that
|
|
|
|
|
// verifies or an invoice that gets paid. It is the half of those flows no click can reach.
|
2026-08-20 20:33:20 +00:00
|
|
|
export type HostingHandle = {
|
|
|
|
|
setTenant(patch: HostingRecord): void
|
|
|
|
|
setRelay(id: string, patch: HostingRecord): void
|
|
|
|
|
setInvoice(id: string, patch: HostingRecord): void
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
const hostingStore = makeContextStore<HostingHandle>("mockHosting")
|
2026-08-20 20:33:20 +00:00
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
export const getHosting = (context: BrowserContext) => hostingStore.get(context)
|
2026-08-06 15:43:57 +00:00
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
/**
|
2026-09-02 13:50:57 +00:00
|
|
|
* The hosting api as a small stateful fake. A write mutates the record it names and the next read
|
2026-08-20 20:33:20 +00:00
|
|
|
* sees it, which is what makes editing a space's details, deactivating it, changing its plan or
|
2026-09-02 13:50:57 +00:00
|
|
|
* saving a custom domain observable. Each browser context gets its own store, so one user's spaces
|
|
|
|
|
* are not another's.
|
2026-08-20 20:33:20 +00:00
|
|
|
*/
|
|
|
|
|
export const mockHosting = async (context: BrowserContext, fixtures: HostingFixtures = {}) => {
|
|
|
|
|
const plans = fixtures.plans ?? []
|
|
|
|
|
const relays = new Map((fixtures.relays ?? []).map(relay => [String(relay.id), relay]))
|
|
|
|
|
const invoices = new Map((fixtures.invoices ?? []).map(invoice => [String(invoice.id), invoice]))
|
|
|
|
|
|
|
|
|
|
let tenant: HostingRecord = {
|
|
|
|
|
pubkey: "",
|
|
|
|
|
return_url: "",
|
|
|
|
|
nwc_is_set: false,
|
|
|
|
|
stripe_customer_id: "",
|
|
|
|
|
created_at: now(),
|
|
|
|
|
...fixtures.tenant,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handle: HostingHandle = {
|
|
|
|
|
setTenant: patch => {
|
|
|
|
|
tenant = {...tenant, ...patch}
|
|
|
|
|
},
|
|
|
|
|
setRelay: (id, patch) => {
|
|
|
|
|
relays.set(id, {...relays.get(id), ...patch})
|
|
|
|
|
},
|
|
|
|
|
setInvoice: (id, patch) => {
|
|
|
|
|
invoices.set(id, {...invoices.get(id), ...patch})
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
hostingStore.set(context, handle)
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
await context.route(`${HOSTING_ORIGIN}/**`, route => {
|
|
|
|
|
const request = route.request()
|
|
|
|
|
const method = request.method()
|
|
|
|
|
const body: HostingRecord = request.postDataJSON() ?? {}
|
|
|
|
|
const [resource, id, sub, detail] = new URL(request.url()).pathname.split("/").filter(Boolean)
|
|
|
|
|
|
|
|
|
|
const missing = (what: string) => route.fulfill({status: 404, json: {error: `No such ${what}`}})
|
2026-08-06 15:43:57 +00:00
|
|
|
|
|
|
|
|
if (resource === "plans") {
|
2026-08-20 20:33:20 +00:00
|
|
|
return route.fulfill({json: {data: plans}})
|
2026-08-06 15:43:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (resource === "tenants") {
|
2026-08-20 20:33:20 +00:00
|
|
|
// Provisioning is the one tenant route with no pubkey in the path.
|
|
|
|
|
if (id) {
|
|
|
|
|
tenant = {...tenant, pubkey: id}
|
|
|
|
|
} else {
|
|
|
|
|
tenant = {...tenant, ...body}
|
|
|
|
|
|
|
|
|
|
return route.fulfill({json: {data: tenant}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sub === "relays") {
|
|
|
|
|
return route.fulfill({json: {data: Array.from(relays.values())}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sub === "invoices") {
|
|
|
|
|
const data = detail === "draft" ? fixtures.draftInvoice : Array.from(invoices.values())
|
|
|
|
|
|
|
|
|
|
return route.fulfill({json: {data}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sub === "stripe") {
|
|
|
|
|
return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/portal/${id}`}}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A wallet is the only thing the app updates a tenant with, so saving one is what sets it.
|
|
|
|
|
if (method === "PUT") {
|
|
|
|
|
tenant = {...tenant, ...body, nwc_is_set: Boolean(body.nwc_url)}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GET and reconcile both answer with the tenant as it now stands.
|
|
|
|
|
return route.fulfill({json: {data: tenant}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (resource === "relays") {
|
|
|
|
|
if (!id) {
|
|
|
|
|
const relay = {
|
|
|
|
|
id: `relay-${relays.size + 1}`,
|
|
|
|
|
status: "active",
|
|
|
|
|
sync_error: "",
|
|
|
|
|
synced: now(),
|
|
|
|
|
custom_domain: "",
|
|
|
|
|
custom_domain_verified: 0,
|
|
|
|
|
...body,
|
|
|
|
|
// A created space is opened straight away, so its url has to be one the container
|
|
|
|
|
// serves. The client names the domain from VITE_HOSTING_RELAY_DOMAIN.
|
|
|
|
|
zooid_domain: body.zooid_domain || "test",
|
2026-08-06 15:43:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
relays.set(String(relay.id), relay)
|
2026-08-06 15:43:57 +00:00
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
return route.fulfill({json: {data: relay}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const relay = relays.get(id)
|
|
|
|
|
|
|
|
|
|
if (!relay) {
|
|
|
|
|
return missing(`relay ${id}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sub === "members") {
|
|
|
|
|
return route.fulfill({json: {data: {members: relay.members ?? []}}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sub === "activity") {
|
|
|
|
|
return route.fulfill({json: {data: {activity: relay.activity ?? []}}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated = {...relay, ...(method === "PUT" ? body : {})}
|
|
|
|
|
|
|
|
|
|
if (sub === "deactivate") {
|
|
|
|
|
updated.status = "inactive"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sub === "reactivate") {
|
|
|
|
|
updated.status = "active"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
relays.set(id, updated)
|
|
|
|
|
|
|
|
|
|
return route.fulfill({json: {data: updated}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (resource === "invoices") {
|
|
|
|
|
const invoice = invoices.get(id)
|
|
|
|
|
|
|
|
|
|
if (!invoice) {
|
|
|
|
|
return missing(`invoice ${id}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sub === "items") {
|
|
|
|
|
return route.fulfill({json: {data: invoice.items ?? []}})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sub === "bolt11") {
|
|
|
|
|
const bolt11 = invoice.bolt11 ?? {
|
|
|
|
|
id: `bolt11-${id}`,
|
|
|
|
|
invoice_id: id,
|
|
|
|
|
lnbc: `lnbc${id}`,
|
|
|
|
|
msats: 0,
|
|
|
|
|
created_at: now(),
|
|
|
|
|
expires_at: now() + int(1, HOUR),
|
2026-08-06 15:43:57 +00:00
|
|
|
}
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
return route.fulfill({json: {data: bolt11}})
|
2026-08-06 15:43:57 +00:00
|
|
|
}
|
2026-08-20 20:33:20 +00:00
|
|
|
|
|
|
|
|
if (sub === "checkout") {
|
|
|
|
|
return route.fulfill({json: {data: {url: `${CHECKOUT_ORIGIN}/invoices/${id}`}}})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 13:50:57 +00:00
|
|
|
// GET and reconcile both answer with the invoice as it now stands, which is how a spec marks
|
|
|
|
|
// one paid. Flip `paid_at` with the handle and let the dialog's next poll find it.
|
2026-08-20 20:33:20 +00:00
|
|
|
return route.fulfill({json: {data: invoice}})
|
2026-08-06 15:43:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return route.fallback()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-20 20:33:20 +00:00
|
|
|
return handle
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 15:43:57 +00:00
|
|
|
export type LivekitOptions = {
|
2026-09-02 13:50:57 +00:00
|
|
|
// Where the client is told to connect. Point it at something the test owns, since the token this
|
2026-08-06 15:43:57 +00:00
|
|
|
// 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: ""})
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-02 13:50:57 +00:00
|
|
|
* Serves a png for anything the browser is loading as an image, so a scenario's fixtures can
|
|
|
|
|
* reference avatar, banner, blob and poster urls without any of them being fetched.
|
2026-08-06 15:43:57 +00:00
|
|
|
*/
|
|
|
|
|
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()
|
|
|
|
|
},
|
|
|
|
|
)
|