From ac8cb4823d44c939335c5ae933397af79ff0874c Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 9 Mar 2026 18:29:41 -0700 Subject: [PATCH] Add desktop Playwright e2e harness --- .github/workflows/ci.yml | 94 +++ .gitignore | 5 + README.md | 16 +- desktop/.gitignore | 2 + desktop/package.json | 9 +- desktop/playwright.config.ts | 40 ++ desktop/pnpm-lock.yaml | 111 ++++ desktop/src/features/channels/hooks.ts | 2 +- desktop/src/features/chat/ui/ChatHeader.tsx | 17 +- .../features/messages/ui/MessageComposer.tsx | 3 + .../features/messages/ui/MessageTimeline.tsx | 12 +- .../src/features/sidebar/ui/AppSidebar.tsx | 21 +- desktop/src/main.tsx | 3 + desktop/src/testing/e2eBridge.ts | 596 ++++++++++++++++++ desktop/tests/e2e/smoke.spec.ts | 43 ++ desktop/tests/e2e/stream.spec.ts | 82 +++ desktop/tests/helpers/bridge.ts | 82 +++ desktop/tests/helpers/seed.ts | 31 + justfile | 14 +- scripts/dev-setup.sh | 17 +- scripts/run-tests.sh | 17 +- scripts/setup-desktop-test-data.sh | 96 +++ 22 files changed, 1289 insertions(+), 24 deletions(-) create mode 100644 desktop/playwright.config.ts create mode 100644 desktop/src/testing/e2eBridge.ts create mode 100644 desktop/tests/e2e/smoke.spec.ts create mode 100644 desktop/tests/e2e/stream.spec.ts create mode 100644 desktop/tests/helpers/bridge.ts create mode 100644 desktop/tests/helpers/seed.ts create mode 100755 scripts/setup-desktop-test-data.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f421bc59..a8dc0ba39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,12 +74,106 @@ jobs: wget - name: Install desktop dependencies run: just desktop-install-ci + - name: Install Playwright Chromium + run: cd desktop && pnpm exec playwright install --with-deps chromium - name: Desktop lint and format run: just desktop-check - name: Desktop build run: just desktop-build + - name: Desktop smoke e2e + run: cd desktop && pnpm exec playwright test --project=smoke - name: Desktop Tauri check run: just desktop-tauri-check + - name: Upload desktop e2e artifacts + if: failure() + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4 + with: + name: desktop-e2e-artifacts + path: | + desktop/playwright-report + desktop/test-results + if-no-files-found: ignore + + desktop-e2e-integration: + name: Desktop E2E Integration + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - uses: Swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db # v2 + with: + workspaces: desktop/src-tauri + - name: Install desktop dependencies + run: just desktop-install-ci + - name: Install Playwright Chromium + run: cd desktop && pnpm exec playwright install --with-deps chromium + - name: Desktop build + run: just desktop-build + - name: Start integration services + run: docker compose up -d + - name: Wait for integration services + run: | + wait_healthy() { + local service="$1" + local container="$2" + for attempt in $(seq 1 60); do + status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") + if [ "${status}" = "healthy" ]; then + echo "${service} is healthy" + return 0 + fi + sleep 2 + done + docker logs "${container}" || true + return 1 + } + wait_healthy "MySQL" "sprout-mysql" + wait_healthy "Redis" "sprout-redis" + wait_healthy "Typesense" "sprout-typesense" + - name: Build relay + run: cargo build -p sprout-relay + - name: Start relay + run: | + nohup env \ + DATABASE_URL=mysql://sprout:sprout_dev@localhost:3306/sprout \ + REDIS_URL=redis://localhost:6379 \ + TYPESENSE_URL=http://localhost:8108 \ + TYPESENSE_API_KEY=sprout_dev_key \ + RELAY_URL=ws://localhost:3000 \ + SPROUT_BIND_ADDR=0.0.0.0:3000 \ + SPROUT_REQUIRE_AUTH_TOKEN=false \ + ./target/debug/sprout-relay > /tmp/sprout-relay.log 2>&1 & + echo $! > /tmp/sprout-relay.pid + for attempt in $(seq 1 60); do + if ! kill -0 "$(cat /tmp/sprout-relay.pid)" 2>/dev/null; then + cat /tmp/sprout-relay.log + exit 1 + fi + status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/api/channels || true) + if [ "${status_code}" != "000" ]; then + exit 0 + fi + sleep 1 + done + cat /tmp/sprout-relay.log + exit 1 + - name: Seed desktop e2e data + run: bash scripts/setup-desktop-test-data.sh + - name: Desktop relay-backed e2e + run: cd desktop && pnpm exec playwright test --project=integration + - name: Upload desktop integration artifacts + if: failure() + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4 + with: + name: desktop-e2e-integration-artifacts + path: | + desktop/playwright-report + desktop/test-results + /tmp/sprout-relay.log + if-no-files-found: ignore security: name: Security diff --git a/.gitignore b/.gitignore index dc8c8a94c..f04f168ed 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,11 @@ Thumbs.db # Scratch / working files (AI reviews, notes, drafts) .scratch/ +# Playwright artifacts +playwright-report/ +test-results/ +blob-report/ + # sqlx offline query data (generated, not portable) .sqlx/ diff --git a/README.md b/README.md index 02ff2f67f..0ff0cb749 100644 --- a/README.md +++ b/README.md @@ -147,17 +147,21 @@ cargo run -p sprout-admin -- mint-token \ --scopes "messages:read,messages:write,channels:read" ``` -Outputs a bearer token. Set it as `SPROUT_API_TOKEN` for the MCP server. +Save the `nsec...` private key and API token from the output. They are shown only once. -**6. Connect an agent via MCP** +**6. Launch an agent with the MCP extension** ```bash SPROUT_RELAY_URL=ws://localhost:3000 \ SPROUT_API_TOKEN= \ -cargo run -p sprout-mcp +SPROUT_PRIVATE_KEY=nsec1... \ +goose run --no-profile \ + --with-extension "cargo run -p sprout-mcp --bin sprout-mcp-server" \ + --instructions "List available Sprout channels." ``` -The MCP server speaks stdio JSON-RPC. Wire it into any MCP-compatible agent host. +`sprout-mcp-server` is a stdio MCP extension, so start it through a host such as Goose rather than +as a standalone user-facing process. See [TESTING.md](TESTING.md) for the full multi-agent flow. **7. Run the desktop app (optional)** @@ -262,9 +266,11 @@ just reset # ⚠️ Wipe all data and recreate environment ```bash cargo run -p sprout-relay cargo run -p sprout-admin -- --help -cargo run -p sprout-mcp +cargo run -p sprout-mcp --bin sprout-mcp-server ``` +`sprout-mcp-server` is normally launched by Goose or another MCP host. + **Database migrations** live in `migrations/`. The relay applies them automatically on startup. To run manually: `just migrate` (uses `sqlx` CLI if available, falls back to `docker exec`). diff --git a/desktop/.gitignore b/desktop/.gitignore index 6657d217b..ce03b6e00 100644 --- a/desktop/.gitignore +++ b/desktop/.gitignore @@ -11,6 +11,8 @@ node_modules .pnpm-store dist dist-ssr +playwright-report +test-results *.local # Editor directories and files diff --git a/desktop/package.json b/desktop/package.json index fe2940ff7..394b56dc7 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -12,7 +12,11 @@ "check": "biome check .", "format": "biome format --write .", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "test:e2e": "pnpm build && playwright test", + "test:e2e:smoke": "pnpm build && playwright test --project=smoke", + "test:e2e:integration": "pnpm build && playwright test --project=integration", + "test:e2e:report": "playwright show-report" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", @@ -35,11 +39,14 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.6", + "@noble/hashes": "^2.0.1", + "@playwright/test": "^1.58.2", "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", "autoprefixer": "^10.4.27", + "nostr-tools": "^2.23.3", "postcss": "^8.5.8", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts new file mode 100644 index 000000000..94ab87282 --- /dev/null +++ b/desktop/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 30_000, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: [ + ["list"], + ["html", { open: "never", outputFolder: "playwright-report" }], + ], + use: { + baseURL: "http://127.0.0.1:4173", + screenshot: "only-on-failure", + trace: "on-first-retry", + video: "retain-on-failure", + }, + projects: [ + { + name: "smoke", + testMatch: "**/smoke.spec.ts", + use: { + ...devices["Desktop Chrome"], + }, + }, + { + name: "integration", + testMatch: "**/stream.spec.ts", + use: { + ...devices["Desktop Chrome"], + }, + }, + ], + webServer: { + command: "python3 -m http.server 4173 -d dist", + cwd: ".", + reuseExistingServer: !process.env.CI, + url: "http://127.0.0.1:4173", + }, +}); diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 09b22f70b..0c10c10f5 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -63,6 +63,12 @@ importers: '@biomejs/biome': specifier: ^2.4.6 version: 2.4.6 + '@noble/hashes': + specifier: ^2.0.1 + version: 2.0.1 + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@tauri-apps/cli': specifier: ^2 version: 2.10.1 @@ -78,6 +84,9 @@ importers: autoprefixer: specifier: ^10.4.27 version: 10.4.27(postcss@8.5.8) + nostr-tools: + specifier: ^2.23.3 + version: 2.23.3(typescript@5.8.3) postcss: specifier: ^8.5.8 version: 8.5.8 @@ -423,6 +432,18 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -435,6 +456,11 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -912,6 +938,15 @@ packages: cpu: [x64] os: [win32] + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + + '@scure/bip32@2.0.1': + resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} + + '@scure/bip39@2.0.1': + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + '@tanstack/query-core@5.90.20': resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} @@ -1216,6 +1251,11 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + 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} @@ -1488,6 +1528,17 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nostr-tools@2.23.3: + resolution: {integrity: sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-wasm@0.1.0: + resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1521,6 +1572,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -2102,6 +2163,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@noble/ciphers@2.1.1': {} + + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2114,6 +2183,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -2507,6 +2580,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@scure/base@2.0.0': {} + + '@scure/bip32@2.0.1': + dependencies: + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + + '@scure/bip39@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@tanstack/query-core@5.90.20': {} '@tanstack/react-query@5.90.21(react@19.2.4)': @@ -2797,6 +2883,9 @@ snapshots: fraction.js@5.3.4: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3269,6 +3358,20 @@ snapshots: normalize-path@3.0.0: {} + nostr-tools@2.23.3(typescript@5.8.3): + dependencies: + '@noble/ciphers': 2.1.1 + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 5.8.3 + + nostr-wasm@0.1.0: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -3295,6 +3398,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + postcss-import@15.1.0(postcss@8.5.8): dependencies: postcss: 8.5.8 diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 39de01f14..e69774928 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -27,7 +27,7 @@ function sortChannels(channels: Channel[]) { export function useChannelsQuery() { return useQuery({ queryKey: channelsQueryKey, - queryFn: getChannels, + queryFn: async () => sortChannels(await getChannels()), staleTime: 30_000, }); } diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 4a0b099d5..cf5c496c0 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -27,17 +27,28 @@ export function ChatHeader({ channelType, }: ChatHeaderProps) { return ( -
+
-

+

{title}

-

{description}

+

+ {description} +

); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 34c59bb5b..da0866096 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -46,6 +46,7 @@ export function MessageComposer({
{ void handleSubmit(event); }} @@ -53,6 +54,7 @@ export function MessageComposer({ setContent(event.target.value)} placeholder={placeholder ?? `Message #${channelName}`} @@ -71,6 +73,7 @@ export function MessageComposer({