Scaffold playwright
This commit is contained in:
parent
1dd0270f4f
commit
91145c38fb
11 changed files with 235 additions and 0 deletions
|
|
@ -15,3 +15,4 @@ android/capacitor-cordova-android-plugins
|
||||||
android/app/src/androidTest
|
android/app/src/androidTest
|
||||||
android/app/src/test
|
android/app/src/test
|
||||||
node_modules
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -6,6 +6,11 @@
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
|
||||||
# Generated assets
|
# Generated assets
|
||||||
static/favicon.ico
|
static/favicon.ico
|
||||||
static/pwa-64x64.png
|
static/pwa-64x64.png
|
||||||
|
|
|
||||||
12
e2e/smoke.spec.ts
Normal file
12
e2e/smoke.spec.ts
Normal file
|
|
@ -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})
|
||||||
|
})
|
||||||
29
e2e/support/fixtures.ts
Normal file
29
e2e/support/fixtures.ts
Normal file
|
|
@ -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 `<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] ?? []])),
|
||||||
|
}
|
||||||
|
}
|
||||||
29
e2e/support/fixtures/relay1.json
Normal file
29
e2e/support/fixtures/relay1.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
30
e2e/support/relayMocks.ts
Normal file
30
e2e/support/relayMocks.ts
Normal file
|
|
@ -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}
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check src && eslint src",
|
"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": "git diff head --name-only --diff-filter d | grep -E '(js|ts|svelte|css)$' | xargs -r prettier --write",
|
||||||
"format:all": "prettier --write src",
|
"format:all": "prettier --write src",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
|
|
@ -18,6 +20,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@capacitor/assets": "^3.0.5",
|
"@capacitor/assets": "^3.0.5",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
|
"@playwright/test": "^1.49.1",
|
||||||
"@sveltejs/kit": "^2.61.1",
|
"@sveltejs/kit": "^2.61.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
|
|
|
||||||
27
playwright.config.ts
Normal file
27
playwright.config.ts
Normal file
|
|
@ -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"]}},
|
||||||
|
],
|
||||||
|
})
|
||||||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
92
src/lib/test/relayMocks.ts
Normal file
92
src/lib/test/relayMocks.ts
Normal file
|
|
@ -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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<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()
|
||||||
|
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<string, unknown>)[RELAY_MOCKS_KEY] as
|
||||||
|
| RelayMockConfig
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
installRelayMocks(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(config)
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
import * as app from "@welshman/app"
|
import * as app from "@welshman/app"
|
||||||
import {isMobile} from "@lib/html"
|
import {isMobile} from "@lib/html"
|
||||||
import * as implicit from "@lib/implicit"
|
import * as implicit from "@lib/implicit"
|
||||||
|
import {maybeInstallRelayMocks} from "@lib/test/relayMocks"
|
||||||
import AppContainer from "@app/components/AppContainer.svelte"
|
import AppContainer from "@app/components/AppContainer.svelte"
|
||||||
import ModalContainer from "@app/components/ModalContainer.svelte"
|
import ModalContainer from "@app/components/ModalContainer.svelte"
|
||||||
import {setupHistory} from "@app/util/history"
|
import {setupHistory} from "@app/util/history"
|
||||||
|
|
@ -46,6 +47,12 @@
|
||||||
|
|
||||||
const {children} = $props()
|
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]
|
const policies = [authPolicy, blockPolicy, trustPolicy, mostlyRestrictedPolicy]
|
||||||
|
|
||||||
// Add stuff to window for convenience
|
// Add stuff to window for convenience
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue