From 91145c38fb3974f4035a47094eda1c34ee5e8e36 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 1 Jun 2026 14:50:13 -0700 Subject: [PATCH] Scaffold playwright --- .fdignore | 1 + .gitignore | 5 ++ e2e/smoke.spec.ts | 12 +++++ e2e/support/fixtures.ts | 29 ++++++++++ e2e/support/fixtures/relay1.json | 29 ++++++++++ e2e/support/relayMocks.ts | 30 +++++++++++ package.json | 3 ++ playwright.config.ts | 27 ++++++++++ pnpm-lock.yaml | 38 +++++++++++++ src/lib/test/relayMocks.ts | 92 ++++++++++++++++++++++++++++++++ src/routes/+layout.svelte | 7 +++ 11 files changed, 273 insertions(+) create mode 100644 e2e/smoke.spec.ts create mode 100644 e2e/support/fixtures.ts create mode 100644 e2e/support/fixtures/relay1.json create mode 100644 e2e/support/relayMocks.ts create mode 100644 playwright.config.ts create mode 100644 src/lib/test/relayMocks.ts diff --git a/.fdignore b/.fdignore index de9ba2b3..ca6d6261 100644 --- a/.fdignore +++ b/.fdignore @@ -15,3 +15,4 @@ android/capacitor-cordova-android-plugins android/app/src/androidTest android/app/src/test node_modules +.svelte-kit diff --git a/.gitignore b/.gitignore index 30994385..9e7642de 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,11 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* +# Playwright +/test-results/ +/playwright-report/ +/playwright/.cache/ + # Generated assets static/favicon.ico static/pwa-64x64.png diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 00000000..38899da3 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,12 @@ +import {expect, test} from "@playwright/test" + +test("boots the SPA on the home page", async ({page}) => { + const response = await page.goto("/") + + expect(response?.ok()).toBeTruthy() + + // adapter-static serves an empty shell that hydrates client-side, so the presence of + // rendered text proves the Svelte app actually mounted (not just that a file was served). + // TODO: tighten this to assert concrete onboarding UI once the markup is settled. + await expect(page.locator("body")).toContainText(/\S/, {timeout: 15_000}) +}) diff --git a/e2e/support/fixtures.ts b/e2e/support/fixtures.ts new file mode 100644 index 00000000..778b2664 --- /dev/null +++ b/e2e/support/fixtures.ts @@ -0,0 +1,29 @@ +import type {SignedEvent} from "@welshman/util" +import type {RelayMockConfig} from "../../src/lib/test/relayMocks" + +import relay1Events from "./fixtures/relay1.json" + +// Fake relay urls used by tests. Each maps to a json fixture under ./fixtures/ and an entry in +// EVENTS_BY_RELAY below. To add a relay: drop a `.json` file in ./fixtures/, import it, add a +// url here, and wire it into EVENTS_BY_RELAY. +export const FIXTURE_RELAYS = { + relay1: "wss://relay1.test/", +} as const + +// The events each fake relay serves. The json files hold static, pre-signed events: schnorr +// signatures are non-deterministic, so events are signed once and committed verbatim (they pass +// verifyEvent, which netContext.isEventValid enforces). Regenerate with @welshman/signer: +// await Nip01Signer.fromSecret(secret).sign(makeEvent(kind, {content, created_at})) +const EVENTS_BY_RELAY: Record = { + [FIXTURE_RELAYS.relay1]: relay1Events as SignedEvent[], +} + +// Build a RelayMockConfig populating the given fixture relays (all of them when none are passed). +// Any relay not included returns nothing, keeping tests offline. +export const relayFixtures = (...urls: string[]): RelayMockConfig => { + const selected = urls.length > 0 ? urls : Object.keys(EVENTS_BY_RELAY) + + return { + relays: Object.fromEntries(selected.map(url => [url, EVENTS_BY_RELAY[url] ?? []])), + } +} diff --git a/e2e/support/fixtures/relay1.json b/e2e/support/fixtures/relay1.json new file mode 100644 index 00000000..671c88f5 --- /dev/null +++ b/e2e/support/fixtures/relay1.json @@ -0,0 +1,29 @@ +[ + { + "kind": 0, + "content": "{\"name\":\"Alice\"}", + "tags": [], + "created_at": 1700000000, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "id": "9b3d138641b38364945b20d800268006c2cb7d974bb4b1d63a9f90f5ab974b90", + "sig": "de6b86274e7bcf6c02aa881ada1feee9e01ba320691711d4975916b3cd231ab43cf469a47c5db99503ed72707d5db85fede1ad3763c4fbd7c998d04f00eda6bc" + }, + { + "kind": 1, + "content": "hello from the fixture relay", + "tags": [], + "created_at": 1700000000, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "id": "b9874875bfa8d830c5c9ef3673104360cf21b94848a311febfaf52f0a652b1a9", + "sig": "85df94a2e9884ac3d280145492d5191cde2948d49a824c443a1f5d2143633eff1e1789fa7e8843b6efc3dd2dc0d7e33322edb628125d8e35de8ddca1d06ca970" + }, + { + "kind": 1, + "content": "reply from bob", + "tags": [], + "created_at": 1700000001, + "pubkey": "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + "id": "171dcbdd63d474ba46da609e8b0104cbcf4801fbb581b6c343d9426280f9e1be", + "sig": "eecf26e616a6b70dc67c7eae16fc4fe159314647ba1d4581332257de7f070410aa9a9e41f571b9403ff145d16c9b32766846fce08516201263e25cf08c1ed8f1" + } +] diff --git a/e2e/support/relayMocks.ts b/e2e/support/relayMocks.ts new file mode 100644 index 00000000..46eaf581 --- /dev/null +++ b/e2e/support/relayMocks.ts @@ -0,0 +1,30 @@ +import type {Page} from "@playwright/test" +import type {RelayMockConfig} from "../../src/lib/test/relayMocks" + +// Must match RELAY_MOCKS_KEY in src/lib/test/relayMocks.ts. +const RELAY_MOCKS_KEY = "__RELAY_MOCKS__" + +// Hard safety net: intercept every real websocket so a test can never reach the network, even if +// some code path opens a socket directly (e.g. relay AUTH) rather than going through the adapter +// layer. We never call route.connectToServer(), so the socket connects to Playwright's in-process +// mock and simply receives nothing. +export const blockWebsockets = (page: Page) => page.routeWebSocket(/^wss?:\/\//, () => {}) + +// Inject the relay-mock config the app reads on startup. addInitScript runs before any page script +// on every navigation, so this must be called before page.goto(). +export const injectRelayConfig = (page: Page, config: RelayMockConfig) => + page.addInitScript( + ([key, value]) => { + Object.assign(window, {[key]: value}) + }, + [RELAY_MOCKS_KEY, config] as const, + ) + +// Full network isolation plus optional fixtures, in one call. With no config, every relay returns +// nothing (requirement 1). Pass {relays: {url: events}} to populate specific relays (requirement 2). +export const setupRelayMocks = async (page: Page, config: RelayMockConfig = {}) => { + await blockWebsockets(page) + await injectRelayConfig(page, config) +} + +export type {RelayMockConfig} diff --git a/package.json b/package.json index 3cb04a98..c00a50f9 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "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", + "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", "format:all": "prettier --write src", "prepare": "husky" @@ -18,6 +20,7 @@ "devDependencies": { "@capacitor/assets": "^3.0.5", "@eslint/js": "^9.39.2", + "@playwright/test": "^1.49.1", "@sveltejs/kit": "^2.61.1", "@sveltejs/vite-plugin-svelte": "^5.1.1", "@tailwindcss/postcss": "^4.2.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..7b3bde87 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,27 @@ +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). +export default defineConfig({ + testDir: "e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: "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. + 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"]}}, + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ecde2ec..3826262c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: '@eslint/js': specifier: ^9.39.2 version: 9.39.4 + '@playwright/test': + specifier: ^1.49.1 + version: 1.60.0 '@sveltejs/kit': specifier: ^2.61.1 version: 2.61.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.9(@typescript-eslint/types@8.60.0))(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)))(svelte@5.55.9(@typescript-eslint/types@8.60.0))(typescript@5.9.3)(vite@6.4.2(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) @@ -1465,6 +1468,11 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -3013,6 +3021,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3975,6 +3988,16 @@ packages: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.1: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} @@ -6549,6 +6572,10 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@polka/url@1.0.0-next.29': {} '@pomade/core@0.2.5(@frostr/bifrost@1.0.7(typescript@5.9.3))(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/signer@0.8.16(@noble/curves@1.9.7)(@noble/hashes@2.2.0)(@welshman/lib@0.8.16)(@welshman/net@0.8.16(@welshman/lib@0.8.16)(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(ws@8.21.0))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-signer-capacitor-plugin@https://codeload.github.com/coracle-social/nostr-signer-capacitor-plugin/tar.gz/436adec9ed1e71569748cd56aa697f361e7a8d47(@capacitor/core@8.3.4))(nostr-tools@2.23.5(typescript@5.9.3)))(@welshman/util@0.8.16(@noble/curves@1.9.7)(@welshman/lib@0.8.16)(nostr-tools@2.23.5(typescript@5.9.3)))(nostr-tools@2.23.5(typescript@5.9.3))': @@ -8240,6 +8267,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9129,6 +9159,14 @@ snapshots: pify@3.0.0: {} + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.1: dependencies: '@xmldom/xmldom': 0.9.10 diff --git a/src/lib/test/relayMocks.ts b/src/lib/test/relayMocks.ts new file mode 100644 index 00000000..a3e659c9 --- /dev/null +++ b/src/lib/test/relayMocks.ts @@ -0,0 +1,92 @@ +import {on} from "@welshman/lib" +import {isRelayUrl, normalizeRelayUrl} from "@welshman/util" +import type {TrustedEvent} from "@welshman/util" +import {AbstractAdapter, AdapterEvent, LocalAdapter, Repository, netContext} from "@welshman/net" +import type {ClientMessage, NetContext, RelayMessage} from "@welshman/net" + +// The window key Playwright writes the mock config to (see e2e/support/relayMocks.ts). Keep it in +// sync with the literal duplicated there. +export const RELAY_MOCKS_KEY = "__RELAY_MOCKS__" + +export type RelayMockConfig = { + // Map of relay url -> events that relay should return. Any relay NOT listed returns nothing (an + // immediate EOSE), which is what keeps tests offline and reproducible by default. + relays?: Record +} + +// Wraps welshman's LocalAdapter so we reuse its REQ/EVENT/CLOSE handling against an in-memory +// Repository, but re-emits its messages under the real relay url instead of LOCAL_RELAY_URL. That +// keeps relay attribution / relay-scoped behaviour (e.g. NIP-29 groups) working as it would over a +// real socket. (Composition rather than inheritance because LocalAdapter emits via a private method +// that hardcodes LOCAL_RELAY_URL.) +class FixtureAdapter extends AbstractAdapter { + readonly local: LocalAdapter + + constructor( + readonly url: string, + repository: Repository, + ) { + super() + + this.local = new LocalAdapter(repository) + + const forward = (message: RelayMessage) => this.emit(AdapterEvent.Receive, message, this.url) + + this._unsubscribers.push( + on(this.local, AdapterEvent.Receive, forward), + () => this.local.cleanup(), + ) + } + + get sockets() { + return this.local.sockets + } + + get urls() { + return [this.url] + } + + send(message: ClientMessage) { + this.local.send(message) + } +} + +// Override netContext.getAdapter so every real relay url is served from memory and no websocket is +// ever created for it. Non-relay urls (e.g. the local:// repository relay) fall through to +// welshman's default handling. +export const installRelayMocks = (config: RelayMockConfig) => { + const reposByUrl = new Map() + + for (const [url, events] of Object.entries(config.relays ?? {})) { + const repository = new Repository() + repository.load(events) + reposByUrl.set(normalizeRelayUrl(url), repository) + } + + const emptyRepository = new Repository() + const fallback = netContext.getAdapter + + netContext.getAdapter = ((url: string, context: NetContext) => { + if (!isRelayUrl(url)) { + return fallback ? fallback(url, context) : undefined + } + + const repository = reposByUrl.get(normalizeRelayUrl(url)) ?? emptyRepository + + return new FixtureAdapter(url, repository) + }) as NetContext["getAdapter"] +} + +// Called once on app startup. Installs the mocks only when Playwright has injected a config, so it +// is a no-op for real users. +export const maybeInstallRelayMocks = () => { + const config = (globalThis as Record)[RELAY_MOCKS_KEY] as + | RelayMockConfig + | undefined + + if (config) { + installRelayMocks(config) + } + + return Boolean(config) +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 6eebbc3f..9b6150f4 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -23,6 +23,7 @@ import * as app from "@welshman/app" import {isMobile} from "@lib/html" import * as implicit from "@lib/implicit" + import {maybeInstallRelayMocks} from "@lib/test/relayMocks" import AppContainer from "@app/components/AppContainer.svelte" import ModalContainer from "@app/components/ModalContainer.svelte" import {setupHistory} from "@app/util/history" @@ -46,6 +47,12 @@ const {children} = $props() + // 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. + if (import.meta.env.DEV) { + maybeInstallRelayMocks() + } + const policies = [authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy] // Add stuff to window for convenience