mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Add desktop Playwright e2e harness (#11)
This commit is contained in:
94
.github/workflows/ci.yml
vendored
94
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
|
||||
16
README.md
16
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=<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`).
|
||||
|
||||
|
||||
2
desktop/.gitignore
vendored
2
desktop/.gitignore
vendored
@@ -11,6 +11,8 @@ node_modules
|
||||
.pnpm-store
|
||||
dist
|
||||
dist-ssr
|
||||
playwright-report
|
||||
test-results
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -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",
|
||||
|
||||
40
desktop/playwright.config.ts
Normal file
40
desktop/playwright.config.ts
Normal file
@@ -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",
|
||||
},
|
||||
});
|
||||
111
desktop/pnpm-lock.yaml
generated
111
desktop/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,17 +27,28 @@ export function ChatHeader({
|
||||
channelType,
|
||||
}: ChatHeaderProps) {
|
||||
return (
|
||||
<header className="flex min-w-0 items-center gap-3 border-b border-border/80 bg-background px-4 py-3 sm:px-6">
|
||||
<header
|
||||
className="flex min-w-0 items-center gap-3 border-b border-border/80 bg-background px-4 py-3 sm:px-6"
|
||||
data-testid="chat-header"
|
||||
>
|
||||
<SidebarTrigger />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ChannelIcon channelType={channelType} />
|
||||
<h1 className="truncate text-lg font-semibold tracking-tight">
|
||||
<h1
|
||||
className="truncate text-lg font-semibold tracking-tight"
|
||||
data-testid="chat-title"
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{description}</p>
|
||||
<p
|
||||
className="truncate text-sm text-muted-foreground"
|
||||
data-testid="chat-description"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ export function MessageComposer({
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
|
||||
<form
|
||||
className="rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
|
||||
data-testid="message-composer"
|
||||
onSubmit={(event) => {
|
||||
void handleSubmit(event);
|
||||
}}
|
||||
@@ -53,6 +54,7 @@ export function MessageComposer({
|
||||
<Input
|
||||
aria-label="Message channel"
|
||||
className="h-auto border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0"
|
||||
data-testid="message-input"
|
||||
disabled={disabled}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder={placeholder ?? `Message #${channelName}`}
|
||||
@@ -71,6 +73,7 @@ export function MessageComposer({
|
||||
|
||||
<Button
|
||||
className="gap-2"
|
||||
data-testid="send-message"
|
||||
disabled={disabled || isSending || content.trim().length === 0}
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -20,7 +20,7 @@ function MessageRow({ message }: { message: TimelineMessage }) {
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<article className="flex gap-3">
|
||||
<article className="flex gap-3" data-testid="message-row">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-xs font-semibold shadow-sm",
|
||||
@@ -83,7 +83,10 @@ export function MessageTimeline({
|
||||
emptyDescription = "Send the first message to start the thread.",
|
||||
}: MessageTimelineProps) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6"
|
||||
data-testid="message-timeline"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Separator className="flex-1" />
|
||||
@@ -96,7 +99,10 @@ export function MessageTimeline({
|
||||
{isLoading ? <TimelineSkeleton /> : null}
|
||||
|
||||
{!isLoading && messages.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-sm">
|
||||
<div
|
||||
className="rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-sm"
|
||||
data-testid="message-empty"
|
||||
>
|
||||
<p className="text-base font-semibold tracking-tight">
|
||||
{emptyTitle}
|
||||
</p>
|
||||
|
||||
@@ -51,11 +51,13 @@ function SidebarSection({
|
||||
items,
|
||||
selectedChannelId,
|
||||
title,
|
||||
testId,
|
||||
onSelectChannel,
|
||||
}: {
|
||||
items: Channel[];
|
||||
selectedChannelId: string | null;
|
||||
title: string;
|
||||
testId: string;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
@@ -66,10 +68,11 @@ function SidebarSection({
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{title}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenu data-testid={testId}>
|
||||
{items.map((channel) => (
|
||||
<SidebarMenuItem key={channel.id}>
|
||||
<SidebarMenuButton
|
||||
data-testid={`channel-${channel.name}`}
|
||||
isActive={selectedChannelId === channel.id}
|
||||
onClick={() => onSelectChannel(channel.id)}
|
||||
tooltip={channel.name}
|
||||
@@ -139,11 +142,13 @@ function StreamsSection({
|
||||
{isCreateOpen ? (
|
||||
<form
|
||||
className="mb-2 space-y-2 rounded-lg border border-sidebar-border/70 bg-sidebar-accent/60 p-2"
|
||||
data-testid="create-stream-form"
|
||||
onSubmit={onCreateChannel}
|
||||
>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className="h-8 bg-background/80"
|
||||
data-testid="create-stream-name"
|
||||
disabled={isCreatingChannel}
|
||||
onChange={(event) => onChangeName(event.target.value)}
|
||||
placeholder="release-notes"
|
||||
@@ -153,6 +158,7 @@ function StreamsSection({
|
||||
<Input
|
||||
autoComplete="off"
|
||||
className="h-8 bg-background/80"
|
||||
data-testid="create-stream-description"
|
||||
disabled={isCreatingChannel}
|
||||
onChange={(event) => onChangeDescription(event.target.value)}
|
||||
placeholder="What this stream is for"
|
||||
@@ -183,10 +189,11 @@ function StreamsSection({
|
||||
) : null}
|
||||
|
||||
{items.length > 0 ? (
|
||||
<SidebarMenu>
|
||||
<SidebarMenu data-testid="stream-list">
|
||||
{items.map((channel) => (
|
||||
<SidebarMenuItem key={channel.id}>
|
||||
<SidebarMenuButton
|
||||
data-testid={`channel-${channel.name}`}
|
||||
isActive={selectedChannelId === channel.id}
|
||||
onClick={() => onSelectChannel(channel.id)}
|
||||
tooltip={channel.name}
|
||||
@@ -280,7 +287,11 @@ export function AppSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="offcanvas" variant="sidebar">
|
||||
<Sidebar
|
||||
collapsible="offcanvas"
|
||||
data-testid="app-sidebar"
|
||||
variant="sidebar"
|
||||
>
|
||||
<SidebarHeader className="gap-3">
|
||||
<div className="flex items-center gap-3 rounded-xl bg-sidebar-accent/80 px-3 py-3">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-xl text-lg">
|
||||
@@ -309,7 +320,7 @@ export function AppSidebar({
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Channels</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenu data-testid="sidebar-loading">
|
||||
{skeletonRows.map((row) => (
|
||||
<SidebarMenuSkeleton key={row} showIcon />
|
||||
))}
|
||||
@@ -356,12 +367,14 @@ export function AppSidebar({
|
||||
items={forumChannels}
|
||||
onSelectChannel={onSelectChannel}
|
||||
selectedChannelId={selectedChannelId}
|
||||
testId="forum-list"
|
||||
title="Forums"
|
||||
/>
|
||||
<SidebarSection
|
||||
items={directMessages}
|
||||
onSelectChannel={onSelectChannel}
|
||||
selectedChannelId={selectedChannelId}
|
||||
testId="dm-list"
|
||||
title="Direct Messages"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { App } from "@/app/App";
|
||||
import "@/shared/styles/globals.css";
|
||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||
import { maybeInstallE2eTauriMocks } from "@/testing/e2eBridge";
|
||||
|
||||
maybeInstallE2eTauriMocks();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
596
desktop/src/testing/e2eBridge.ts
Normal file
596
desktop/src/testing/e2eBridge.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
import { hexToBytes } from "@noble/hashes/utils.js";
|
||||
import { mockIPC, mockWindows } from "@tauri-apps/api/mocks";
|
||||
import { finalizeEvent } from "nostr-tools/pure";
|
||||
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
type TestIdentity = {
|
||||
privateKey: string;
|
||||
pubkey: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
type E2eConfig = {
|
||||
mode?: "mock" | "relay";
|
||||
relayHttpUrl?: string;
|
||||
relayWsUrl?: string;
|
||||
identity?: TestIdentity;
|
||||
};
|
||||
|
||||
type RawChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
channel_type: "stream" | "forum" | "dm";
|
||||
description: string;
|
||||
participants: string[];
|
||||
participant_pubkeys: string[];
|
||||
};
|
||||
|
||||
type WsHandler = (message: unknown) => void;
|
||||
|
||||
type MockSocket = {
|
||||
handler: WsHandler;
|
||||
subscriptions: Map<string, string>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__SPROUT_E2E__?: E2eConfig;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_RELAY_HTTP_URL = "http://localhost:3000";
|
||||
const DEFAULT_RELAY_WS_URL = "ws://localhost:3000";
|
||||
const DEFAULT_MOCK_IDENTITY = {
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
display_name: "npub1mock...",
|
||||
};
|
||||
const DEFAULT_REAL_IDENTITY = {
|
||||
privateKey:
|
||||
"3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03",
|
||||
pubkey: "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
|
||||
username: "tyler",
|
||||
} satisfies TestIdentity;
|
||||
|
||||
const mockChannels: RawChannel[] = [
|
||||
{
|
||||
id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
name: "general",
|
||||
channel_type: "stream",
|
||||
description: "General discussion for everyone",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
id: "9dae0116-799b-5071-a0a8-fdd30a91a35d",
|
||||
name: "random",
|
||||
channel_type: "stream",
|
||||
description: "Off-topic, fun stuff",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
|
||||
name: "engineering",
|
||||
channel_type: "stream",
|
||||
description: "Engineering discussions",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
id: "94a444a4-c0a3-5966-ab05-530c6ddc2301",
|
||||
name: "agents",
|
||||
channel_type: "stream",
|
||||
description: "AI agent testing and collaboration",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
id: "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11",
|
||||
name: "watercooler",
|
||||
channel_type: "forum",
|
||||
description: "Casual forum for async discussions",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
id: "1be1dcdb-4c31-5a8c-81de-ac102552ca10",
|
||||
name: "announcements",
|
||||
channel_type: "forum",
|
||||
description: "Company announcements",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
},
|
||||
{
|
||||
id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
|
||||
name: "alice-tyler",
|
||||
channel_type: "dm",
|
||||
description: "DM between alice and tyler",
|
||||
participants: ["alice", "tyler"],
|
||||
participant_pubkeys: [
|
||||
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
||||
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "7eb9f239-9393-50b0-bd76-d85eef0511c7",
|
||||
name: "bob-tyler",
|
||||
channel_type: "dm",
|
||||
description: "DM between bob and tyler",
|
||||
participants: ["bob", "tyler"],
|
||||
participant_pubkeys: [
|
||||
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
|
||||
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockMessages = new Map<string, RelayEvent[]>();
|
||||
const mockSockets = new Map<number, MockSocket>();
|
||||
const realSockets = new Map<number, WebSocket>();
|
||||
|
||||
let installed = false;
|
||||
let nextSocketId = 1;
|
||||
|
||||
function getConfig(): E2eConfig | undefined {
|
||||
return window.__SPROUT_E2E__;
|
||||
}
|
||||
|
||||
function isRelayMode(config: E2eConfig | undefined): boolean {
|
||||
return config?.mode === "relay";
|
||||
}
|
||||
|
||||
function getRelayHttpUrl(config: E2eConfig | undefined): string {
|
||||
return config?.relayHttpUrl ?? DEFAULT_RELAY_HTTP_URL;
|
||||
}
|
||||
|
||||
function getRelayWsUrl(config: E2eConfig | undefined): string {
|
||||
return config?.relayWsUrl ?? DEFAULT_RELAY_WS_URL;
|
||||
}
|
||||
|
||||
function getIdentity(config: E2eConfig | undefined): TestIdentity | undefined {
|
||||
if (!isRelayMode(config)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return config?.identity ?? DEFAULT_REAL_IDENTITY;
|
||||
}
|
||||
|
||||
function resolveHandler(handler: unknown): WsHandler {
|
||||
if (typeof handler === "function") {
|
||||
return handler as WsHandler;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof handler === "object" &&
|
||||
handler !== null &&
|
||||
"onmessage" in handler &&
|
||||
typeof handler.onmessage === "function"
|
||||
) {
|
||||
return handler.onmessage as WsHandler;
|
||||
}
|
||||
|
||||
throw new Error("Invalid websocket message handler.");
|
||||
}
|
||||
|
||||
function sendWsText(handler: WsHandler, payload: unknown[]) {
|
||||
handler({
|
||||
type: "Text",
|
||||
data: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function sendWsClose(handler: WsHandler) {
|
||||
handler({
|
||||
type: "Close",
|
||||
});
|
||||
}
|
||||
|
||||
function getChannelIdFromTags(tags: string[][]): string | undefined {
|
||||
return tags.find((tag) => tag[0] === "e")?.[1];
|
||||
}
|
||||
|
||||
function getMockMessageStore(channelId: string): RelayEvent[] {
|
||||
const existing = mockMessages.get(channelId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const seeded: RelayEvent[] =
|
||||
channelId === "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"
|
||||
? [
|
||||
{
|
||||
id: "mock-general-welcome",
|
||||
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 40001,
|
||||
tags: [["e", channelId]],
|
||||
content: "Welcome to #general",
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
mockMessages.set(channelId, seeded);
|
||||
return seeded;
|
||||
}
|
||||
|
||||
function emitMockHistory(socket: MockSocket, subId: string, channelId: string) {
|
||||
const events = getMockMessageStore(channelId);
|
||||
for (const event of events) {
|
||||
sendWsText(socket.handler, ["EVENT", subId, event]);
|
||||
}
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
}
|
||||
|
||||
function emitMockLiveEvent(channelId: string, event: RelayEvent) {
|
||||
for (const socket of mockSockets.values()) {
|
||||
for (const [subId, subscribedChannelId] of socket.subscriptions) {
|
||||
if (subscribedChannelId === channelId) {
|
||||
sendWsText(socket.handler, ["EVENT", subId, event]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createMockEvent(
|
||||
kind: number,
|
||||
content: string,
|
||||
tags: string[][],
|
||||
): RelayEvent {
|
||||
return {
|
||||
id: crypto.randomUUID().replace(/-/g, ""),
|
||||
pubkey: DEFAULT_MOCK_IDENTITY.pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind,
|
||||
tags,
|
||||
content,
|
||||
sig: "mocksig".repeat(20).slice(0, 128),
|
||||
};
|
||||
}
|
||||
|
||||
async function signWithIdentity(
|
||||
identity: TestIdentity,
|
||||
template: {
|
||||
kind: number;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
},
|
||||
) {
|
||||
const secretKey = hexToBytes(identity.privateKey);
|
||||
|
||||
return finalizeEvent(
|
||||
{
|
||||
kind: template.kind,
|
||||
content: template.content,
|
||||
tags: template.tags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
secretKey,
|
||||
);
|
||||
}
|
||||
|
||||
async function assertOk(response: Response) {
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
throw new Error(body || `Request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
async function handleGetChannels(config: E2eConfig | undefined) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
return mockChannels;
|
||||
}
|
||||
|
||||
const response = await fetch(`${getRelayHttpUrl(config)}/api/channels`, {
|
||||
headers: {
|
||||
"X-Pubkey": identity.pubkey,
|
||||
},
|
||||
});
|
||||
await assertOk(response);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function handleCreateChannel(
|
||||
args: {
|
||||
name: string;
|
||||
channelType: "stream" | "forum";
|
||||
visibility: "open" | "private";
|
||||
description?: string;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const channel: RawChannel = {
|
||||
id: crypto.randomUUID(),
|
||||
name: args.name,
|
||||
channel_type: args.channelType,
|
||||
description: args.description ?? "",
|
||||
participants: [],
|
||||
participant_pubkeys: [],
|
||||
};
|
||||
mockChannels.push(channel);
|
||||
return channel;
|
||||
}
|
||||
|
||||
const response = await fetch(`${getRelayHttpUrl(config)}/api/channels`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Pubkey": identity.pubkey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: args.name,
|
||||
channel_type: args.channelType,
|
||||
visibility: args.visibility,
|
||||
description: args.description,
|
||||
}),
|
||||
});
|
||||
await assertOk(response);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function connectRealSocket(args: { url?: string; onMessage: unknown }) {
|
||||
const wsId = nextSocketId++;
|
||||
const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL);
|
||||
const handler = resolveHandler(args.onMessage);
|
||||
|
||||
realSockets.set(wsId, ws);
|
||||
ws.addEventListener("message", (event) => {
|
||||
handler({
|
||||
type: "Text",
|
||||
data: event.data,
|
||||
});
|
||||
});
|
||||
ws.addEventListener("close", () => {
|
||||
sendWsClose(handler);
|
||||
realSockets.delete(wsId);
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
handler({
|
||||
type: "Error",
|
||||
});
|
||||
});
|
||||
|
||||
return await new Promise<number>((resolve) => {
|
||||
ws.addEventListener("open", () => resolve(wsId), { once: true });
|
||||
ws.addEventListener("error", () => resolve(wsId), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function connectMockSocket(args: { onMessage: unknown }) {
|
||||
const wsId = nextSocketId++;
|
||||
const handler = resolveHandler(args.onMessage);
|
||||
|
||||
mockSockets.set(wsId, {
|
||||
handler,
|
||||
subscriptions: new Map(),
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
sendWsText(handler, ["AUTH", `mock-challenge-${wsId}`]);
|
||||
}, 0);
|
||||
|
||||
return wsId;
|
||||
}
|
||||
|
||||
async function sendToRealSocket(args: {
|
||||
id: number;
|
||||
message?: {
|
||||
type: "Text" | "Close";
|
||||
data?: string;
|
||||
};
|
||||
}) {
|
||||
const socket = realSockets.get(args.id);
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.message?.type === "Close") {
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.message?.type === "Text") {
|
||||
socket.send(args.message.data ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
function sendToMockSocket(args: {
|
||||
id: number;
|
||||
message?: {
|
||||
type: "Text" | "Close";
|
||||
data?: string;
|
||||
};
|
||||
}) {
|
||||
const socket = mockSockets.get(args.id);
|
||||
if (!socket || !args.message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.message.type === "Close") {
|
||||
mockSockets.delete(args.id);
|
||||
sendWsClose(socket.handler);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.message.type !== "Text" || !args.message.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [type, ...rest] = JSON.parse(args.message.data) as [
|
||||
string,
|
||||
...unknown[],
|
||||
];
|
||||
|
||||
if (type === "AUTH") {
|
||||
const event = rest[0] as RelayEvent;
|
||||
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "REQ") {
|
||||
const subId = rest[0] as string;
|
||||
const filter = rest[1] as { "#e"?: string[] };
|
||||
const channelId = filter["#e"]?.[0];
|
||||
if (!channelId) {
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subId.startsWith("live-")) {
|
||||
socket.subscriptions.set(subId, channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
emitMockHistory(socket, subId, channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "CLOSE") {
|
||||
const subId = rest[0] as string;
|
||||
socket.subscriptions.delete(subId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "EVENT") {
|
||||
const event = rest[0] as RelayEvent;
|
||||
const channelId = getChannelIdFromTags(event.tags);
|
||||
if (!channelId) {
|
||||
sendWsText(socket.handler, [
|
||||
"OK",
|
||||
event.id,
|
||||
false,
|
||||
"Missing channel tag.",
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const history = getMockMessageStore(channelId);
|
||||
history.push(event);
|
||||
emitMockLiveEvent(channelId, event);
|
||||
sendWsText(socket.handler, ["OK", event.id, true, ""]);
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectMockSocket(id: number) {
|
||||
const socket = mockSockets.get(id);
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
mockSockets.delete(id);
|
||||
sendWsClose(socket.handler);
|
||||
}
|
||||
|
||||
export function maybeInstallE2eTauriMocks() {
|
||||
if (installed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
mockWindows("main");
|
||||
mockIPC(async (command, payload) => {
|
||||
const activeConfig = getConfig();
|
||||
const identity = getIdentity(activeConfig);
|
||||
|
||||
switch (command) {
|
||||
case "get_identity":
|
||||
if (identity) {
|
||||
return {
|
||||
pubkey: identity.pubkey,
|
||||
display_name: identity.username,
|
||||
};
|
||||
}
|
||||
|
||||
return DEFAULT_MOCK_IDENTITY;
|
||||
case "get_relay_ws_url":
|
||||
return getRelayWsUrl(activeConfig);
|
||||
case "get_channels":
|
||||
return handleGetChannels(activeConfig);
|
||||
case "create_channel":
|
||||
return handleCreateChannel(
|
||||
payload as Parameters<typeof handleCreateChannel>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "sign_event":
|
||||
if (identity) {
|
||||
return JSON.stringify(
|
||||
await signWithIdentity(identity, {
|
||||
kind: (payload as { kind: number }).kind,
|
||||
content: (payload as { content: string }).content,
|
||||
tags: (payload as { tags: string[][] }).tags,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
createMockEvent(
|
||||
(payload as { kind: number }).kind,
|
||||
(payload as { content: string }).content,
|
||||
(payload as { tags: string[][] }).tags,
|
||||
),
|
||||
);
|
||||
case "create_auth_event":
|
||||
if (identity) {
|
||||
return JSON.stringify(
|
||||
await signWithIdentity(identity, {
|
||||
kind: 22242,
|
||||
content: "",
|
||||
tags: [
|
||||
["relay", (payload as { relayUrl: string }).relayUrl],
|
||||
["challenge", (payload as { challenge: string }).challenge],
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.stringify(
|
||||
createMockEvent(22242, "", [
|
||||
["relay", (payload as { relayUrl: string }).relayUrl],
|
||||
["challenge", (payload as { challenge: string }).challenge],
|
||||
]),
|
||||
);
|
||||
case "plugin:websocket|connect":
|
||||
if (isRelayMode(activeConfig)) {
|
||||
return connectRealSocket(
|
||||
payload as Parameters<typeof connectRealSocket>[0],
|
||||
);
|
||||
}
|
||||
|
||||
return connectMockSocket(
|
||||
payload as Parameters<typeof connectMockSocket>[0],
|
||||
);
|
||||
case "plugin:websocket|send":
|
||||
if (isRelayMode(activeConfig)) {
|
||||
return sendToRealSocket(
|
||||
payload as Parameters<typeof sendToRealSocket>[0],
|
||||
);
|
||||
}
|
||||
|
||||
return sendToMockSocket(
|
||||
payload as Parameters<typeof sendToMockSocket>[0],
|
||||
);
|
||||
case "plugin:websocket|disconnect":
|
||||
if (isRelayMode(activeConfig)) {
|
||||
realSockets.get((payload as { id: number }).id)?.close();
|
||||
realSockets.delete((payload as { id: number }).id);
|
||||
return;
|
||||
}
|
||||
|
||||
return disconnectMockSocket((payload as { id: number }).id);
|
||||
default:
|
||||
throw new Error(`Unsupported mocked Tauri command: ${command}`);
|
||||
}
|
||||
});
|
||||
|
||||
installed = true;
|
||||
}
|
||||
43
desktop/tests/e2e/smoke.spec.ts
Normal file
43
desktop/tests/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
});
|
||||
|
||||
test("loads the app shell with mocked channels", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||
await expect(page.getByTestId("stream-list")).toContainText("general");
|
||||
await expect(page.getByTestId("forum-list")).toContainText("watercooler");
|
||||
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
|
||||
});
|
||||
|
||||
test("creates a new mocked stream", async ({ page }) => {
|
||||
const channelName = `release-notes-${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
await page
|
||||
.getByTestId("create-stream-description")
|
||||
.fill("Release coordination");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
});
|
||||
|
||||
test("sends a mocked channel message", async ({ page }) => {
|
||||
const message = `Smoke message ${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await page.getByTestId("message-input").fill(message);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
});
|
||||
82
desktop/tests/e2e/stream.spec.ts
Normal file
82
desktop/tests/e2e/stream.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { expect, test, type Browser } from "@playwright/test";
|
||||
|
||||
import { installRelayBridge } from "../helpers/bridge";
|
||||
import { assertRelaySeeded } from "../helpers/seed";
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await assertRelaySeeded();
|
||||
});
|
||||
|
||||
test("loads channels from the relay", async ({ page }) => {
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText("general");
|
||||
await expect(page.getByTestId("stream-list")).toContainText("random");
|
||||
await expect(page.getByTestId("forum-list")).toContainText("watercooler");
|
||||
await expect(page.getByTestId("dm-list")).toContainText("alice-tyler");
|
||||
});
|
||||
|
||||
test("creates a relay-backed stream", async ({ page }) => {
|
||||
const channelName = `desktop-e2e-${Date.now()}`;
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Create a stream" }).click();
|
||||
await page.getByTestId("create-stream-name").fill(channelName);
|
||||
await page
|
||||
.getByTestId("create-stream-description")
|
||||
.fill("Created from Playwright");
|
||||
await page.getByRole("button", { name: "Create" }).click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).toContainText(channelName);
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
});
|
||||
|
||||
test("sends a message through the real relay", async ({ page }) => {
|
||||
const message = `Integration message ${Date.now()}`;
|
||||
|
||||
await installRelayBridge(page, "tyler");
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await page.getByTestId("message-input").fill(message);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(message);
|
||||
});
|
||||
|
||||
test("delivers a message to a second browser context in real time", async ({
|
||||
browser,
|
||||
}: {
|
||||
browser: Browser;
|
||||
}) => {
|
||||
const contextOne = await browser.newContext();
|
||||
const contextTwo = await browser.newContext();
|
||||
const pageOne = await contextOne.newPage();
|
||||
const pageTwo = await contextTwo.newPage();
|
||||
const message = `Realtime message ${Date.now()}`;
|
||||
|
||||
try {
|
||||
await installRelayBridge(pageOne, "tyler");
|
||||
await installRelayBridge(pageTwo, "alice");
|
||||
|
||||
await pageOne.goto("/");
|
||||
await pageTwo.goto("/");
|
||||
|
||||
await pageOne.getByTestId("channel-general").click();
|
||||
await pageTwo.getByTestId("channel-general").click();
|
||||
await expect(pageOne.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(pageTwo.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
await pageOne.getByTestId("message-input").fill(message);
|
||||
await pageOne.getByTestId("send-message").click();
|
||||
|
||||
await expect(pageTwo.getByTestId("message-timeline")).toContainText(
|
||||
message,
|
||||
);
|
||||
} finally {
|
||||
await contextOne.close();
|
||||
await contextTwo.close();
|
||||
}
|
||||
});
|
||||
82
desktop/tests/helpers/bridge.ts
Normal file
82
desktop/tests/helpers/bridge.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
export const TEST_IDENTITIES = {
|
||||
tyler: {
|
||||
privateKey:
|
||||
"3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03",
|
||||
pubkey: "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34",
|
||||
username: "tyler",
|
||||
},
|
||||
alice: {
|
||||
privateKey:
|
||||
"3fa69cbac1dcb9b7b6ac83117c74bd23bb1e717fe8fc7cfda67b47bb4323383d",
|
||||
pubkey: "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
|
||||
username: "alice",
|
||||
},
|
||||
bob: {
|
||||
privateKey:
|
||||
"7667ae87cbc50ac0b2251b115c9c51aca7e2da65301b28ecf82f4e4c5260a6bb",
|
||||
pubkey: "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
|
||||
username: "bob",
|
||||
},
|
||||
charlie: {
|
||||
privateKey:
|
||||
"813fc3bb90587a82b2bfee9b833503e7686c7480681850b3d789c6987e997fc8",
|
||||
pubkey: "554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea",
|
||||
username: "charlie",
|
||||
},
|
||||
outsider: {
|
||||
privateKey:
|
||||
"91bd673543195c0c78fc74a881545dcc8cd6ea6d0f9f8efb3225d58c4bc70dad",
|
||||
pubkey: "df8e91b86fda13a9a67896df77232f7bdab2ba9c3e165378e1ba3d24c13a328e",
|
||||
username: "outsider",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type BridgeMode = "mock" | "relay";
|
||||
|
||||
type BridgeOptions = {
|
||||
mode: BridgeMode;
|
||||
relayHttpUrl?: string;
|
||||
relayWsUrl?: string;
|
||||
user?: keyof typeof TEST_IDENTITIES;
|
||||
};
|
||||
|
||||
export async function installBridge(page: Page, options: BridgeOptions) {
|
||||
const identity =
|
||||
options.mode === "relay"
|
||||
? TEST_IDENTITIES[options.user ?? "tyler"]
|
||||
: undefined;
|
||||
|
||||
await page.addInitScript(
|
||||
({ identity: bridgeIdentity, mode, relayHttpUrl, relayWsUrl }) => {
|
||||
(
|
||||
window as Window & {
|
||||
__SPROUT_E2E__?: Record<string, unknown>;
|
||||
}
|
||||
).__SPROUT_E2E__ = {
|
||||
identity: bridgeIdentity,
|
||||
mode,
|
||||
relayHttpUrl,
|
||||
relayWsUrl,
|
||||
};
|
||||
},
|
||||
{
|
||||
identity,
|
||||
mode: options.mode,
|
||||
relayHttpUrl: options.relayHttpUrl,
|
||||
relayWsUrl: options.relayWsUrl,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function installMockBridge(page: Page) {
|
||||
await installBridge(page, { mode: "mock" });
|
||||
}
|
||||
|
||||
export async function installRelayBridge(
|
||||
page: Page,
|
||||
user: keyof typeof TEST_IDENTITIES = "tyler",
|
||||
) {
|
||||
await installBridge(page, { mode: "relay", user });
|
||||
}
|
||||
31
desktop/tests/helpers/seed.ts
Normal file
31
desktop/tests/helpers/seed.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { request } from "@playwright/test";
|
||||
|
||||
const tylerPubkey =
|
||||
"e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34";
|
||||
|
||||
export async function assertRelaySeeded() {
|
||||
const context = await request.newContext();
|
||||
|
||||
try {
|
||||
const response = await context.get("http://localhost:3000/api/channels", {
|
||||
headers: {
|
||||
"X-Pubkey": tylerPubkey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
"Relay test data is unavailable. Start the relay and run scripts/setup-desktop-test-data.sh.",
|
||||
);
|
||||
}
|
||||
|
||||
const channels = (await response.json()) as Array<{ name: string }>;
|
||||
if (!channels.some((channel) => channel.name === "general")) {
|
||||
throw new Error(
|
||||
'Relay test data is incomplete. Expected a "general" channel from scripts/setup-desktop-test-data.sh.',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await context.dispose();
|
||||
}
|
||||
}
|
||||
14
justfile
14
justfile
@@ -84,6 +84,18 @@ desktop-tauri-check:
|
||||
# Run desktop checks suitable for CI / pre-push
|
||||
desktop-ci: desktop-check desktop-build desktop-tauri-check
|
||||
|
||||
# Seed deterministic channel data for desktop Playwright tests
|
||||
desktop-e2e-seed:
|
||||
./scripts/setup-desktop-test-data.sh
|
||||
|
||||
# Run desktop browser smoke tests
|
||||
desktop-e2e-smoke:
|
||||
cd {{desktop_dir}} && pnpm test:e2e:smoke
|
||||
|
||||
# Run desktop relay-backed e2e tests
|
||||
desktop-e2e-integration:
|
||||
cd {{desktop_dir}} && pnpm test:e2e:integration
|
||||
|
||||
# Run all checks suitable for CI / pre-push (no infra needed)
|
||||
ci: check test-unit desktop-build desktop-tauri-check
|
||||
|
||||
@@ -136,7 +148,7 @@ migrate:
|
||||
echo "sqlx CLI not found — applying migrations via docker exec..."
|
||||
for sql_file in migrations/*.sql; do
|
||||
echo " Applying $(basename "$sql_file")..."
|
||||
docker exec -i sprout-mysql mysql -u sprout -psprout_dev sprout < "$sql_file" 2>/dev/null || true
|
||||
docker run --rm -i --network "${SPROUT_DOCKER_NETWORK:-sprout-net}" -e MYSQL_PWD="${SPROUT_DB_PASS:-sprout_dev}" "${SPROUT_DB_CLIENT_IMAGE:-mysql:8.0}" mysql -h"${SPROUT_DOCKER_DB_HOST:-mysql}" -u"${SPROUT_DB_USER:-sprout}" "${SPROUT_DB_NAME:-sprout}" < "$sql_file" 2>/dev/null || true
|
||||
done
|
||||
echo "Migrations applied."
|
||||
fi
|
||||
|
||||
@@ -12,6 +12,12 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
TIMEOUT=120 # seconds to wait for services to become healthy
|
||||
DB_USER="${SPROUT_DB_USER:-sprout}"
|
||||
DB_PASS="${SPROUT_DB_PASS:-sprout_dev}"
|
||||
DB_NAME="${SPROUT_DB_NAME:-sprout}"
|
||||
DOCKER_DB_HOST="${SPROUT_DOCKER_DB_HOST:-mysql}"
|
||||
DOCKER_NETWORK="${SPROUT_DOCKER_NETWORK:-sprout-net}"
|
||||
MYSQL_CLIENT_IMAGE="${SPROUT_DB_CLIENT_IMAGE:-mysql:8.0}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
@@ -39,6 +45,13 @@ fi
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
run_mysql_in_container() {
|
||||
docker run --rm -i --network "${DOCKER_NETWORK}" \
|
||||
-e MYSQL_PWD="${DB_PASS}" \
|
||||
"${MYSQL_CLIENT_IMAGE}" \
|
||||
mysql -h"${DOCKER_DB_HOST}" -u"${DB_USER}" "${DB_NAME}" "$@"
|
||||
}
|
||||
|
||||
# ---- Start services ---------------------------------------------------------
|
||||
|
||||
log "Starting services..."
|
||||
@@ -117,9 +130,7 @@ else
|
||||
for sql_file in "${SQL_FILES[@]}"; do
|
||||
filename="$(basename "${sql_file}")"
|
||||
log " Applying ${filename}..."
|
||||
docker exec -i sprout-mysql \
|
||||
mysql -u sprout -psprout_dev sprout \
|
||||
< "${sql_file}"
|
||||
run_mysql_in_container < "${sql_file}"
|
||||
done
|
||||
success "Migrations applied via mysql"
|
||||
fi
|
||||
|
||||
@@ -14,6 +14,12 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
MODE="${1:-all}"
|
||||
TIMEOUT=60 # seconds to wait for services if starting them
|
||||
DB_USER="${SPROUT_DB_USER:-sprout}"
|
||||
DB_PASS="${SPROUT_DB_PASS:-sprout_dev}"
|
||||
DB_NAME="${SPROUT_DB_NAME:-sprout}"
|
||||
DOCKER_DB_HOST="${SPROUT_DOCKER_DB_HOST:-mysql}"
|
||||
DOCKER_NETWORK="${SPROUT_DOCKER_NETWORK:-sprout-net}"
|
||||
MYSQL_CLIENT_IMAGE="${SPROUT_DB_CLIENT_IMAGE:-mysql:8.0}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
@@ -31,6 +37,13 @@ section(){ echo -e "\n${CYAN}━━━━━━━━━━━━━━━━━
|
||||
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
run_mysql_in_container() {
|
||||
docker run --rm -i --network "${DOCKER_NETWORK}" \
|
||||
-e MYSQL_PWD="${DB_PASS}" \
|
||||
"${MYSQL_CLIENT_IMAGE}" \
|
||||
mysql -h"${DOCKER_DB_HOST}" -u"${DB_USER}" "${DB_NAME}" "$@"
|
||||
}
|
||||
|
||||
# ---- Load .env if present ---------------------------------------------------
|
||||
|
||||
if [[ -f ".env" ]]; then
|
||||
@@ -120,9 +133,7 @@ ensure_migrations() {
|
||||
local sql_files=("${migration_dir}"/*.sql)
|
||||
shopt -u nullglob
|
||||
for sql_file in "${sql_files[@]}"; do
|
||||
docker exec -i sprout-mysql \
|
||||
mysql -u sprout -psprout_dev sprout \
|
||||
< "${sql_file}" &>/dev/null || true
|
||||
run_mysql_in_container < "${sql_file}" &>/dev/null || true
|
||||
done
|
||||
success "Migrations applied (mysql fallback)"
|
||||
fi
|
||||
|
||||
96
scripts/setup-desktop-test-data.sh
Executable file
96
scripts/setup-desktop-test-data.sh
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DB_HOST="${SPROUT_DB_HOST:-127.0.0.1}"
|
||||
DB_PORT="${SPROUT_DB_PORT:-3306}"
|
||||
DB_USER="${SPROUT_DB_USER:-sprout}"
|
||||
DB_PASS="${SPROUT_DB_PASS:-sprout_dev}"
|
||||
DB_NAME="${SPROUT_DB_NAME:-sprout}"
|
||||
DOCKER_DB_HOST="${SPROUT_DOCKER_DB_HOST:-mysql}"
|
||||
DOCKER_NETWORK="${SPROUT_DOCKER_NETWORK:-sprout-net}"
|
||||
MYSQL_CLIENT_IMAGE="${SPROUT_DB_CLIENT_IMAGE:-mysql:8.0}"
|
||||
|
||||
SYSTEM_PUBKEY="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
ALICE_PUBKEY="953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"
|
||||
BOB_PUBKEY="bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"
|
||||
CHARLIE_PUBKEY="554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea"
|
||||
TYLER_PUBKEY="e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"
|
||||
AGENT_PUBKEY="db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36"
|
||||
|
||||
if command -v mysql >/dev/null 2>&1; then
|
||||
run_mysql() { MYSQL_PWD="$DB_PASS" mysql -h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" "$DB_NAME" "$@"; }
|
||||
elif docker exec sprout-mysql mysql --version >/dev/null 2>&1; then
|
||||
run_mysql() {
|
||||
docker run --rm -i --network "$DOCKER_NETWORK" \
|
||||
-e MYSQL_PWD="$DB_PASS" \
|
||||
"$MYSQL_CLIENT_IMAGE" \
|
||||
mysql -h"$DOCKER_DB_HOST" -u"$DB_USER" "$DB_NAME" "$@"
|
||||
}
|
||||
else
|
||||
echo "No mysql client available. Start docker compose or install mysql." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_sql() {
|
||||
run_mysql --silent --skip-column-names -e "$1"
|
||||
}
|
||||
|
||||
uuid5_hex() {
|
||||
local slug="$1"
|
||||
python3 - "$slug" <<'PYEOF'
|
||||
import sys, uuid
|
||||
print(uuid.uuid5(uuid.NAMESPACE_DNS, sys.argv[1]).hex)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
echo "Checking database connection..."
|
||||
run_sql "SELECT 1" >/dev/null
|
||||
|
||||
UUID_GENERAL=$(uuid5_hex "sprout.channel.general")
|
||||
UUID_RANDOM=$(uuid5_hex "sprout.channel.random")
|
||||
UUID_ENGINEERING=$(uuid5_hex "sprout.channel.engineering")
|
||||
UUID_AGENTS=$(uuid5_hex "sprout.channel.agents")
|
||||
UUID_WATERCOOLER=$(uuid5_hex "sprout.channel.watercooler")
|
||||
UUID_ANNOUNCEMENTS=$(uuid5_hex "sprout.channel.announcements")
|
||||
UUID_DM_ALICE_TYLER=$(uuid5_hex "sprout.channel.dm.alice-tyler")
|
||||
UUID_DM_BOB_TYLER=$(uuid5_hex "sprout.channel.dm.bob-tyler")
|
||||
UUID_DM_BOB_CHARLIE_TYLER=$(uuid5_hex "sprout.channel.dm.bob-charlie-tyler")
|
||||
|
||||
SYSTEM_HEX="UNHEX('${SYSTEM_PUBKEY}')"
|
||||
|
||||
run_sql "
|
||||
INSERT IGNORE INTO channels
|
||||
(id, name, channel_type, visibility, description, created_by, topic_required)
|
||||
VALUES
|
||||
(UNHEX('${UUID_GENERAL}'), 'general', 'stream', 'open', 'General discussion for everyone', ${SYSTEM_HEX}, 0),
|
||||
(UNHEX('${UUID_RANDOM}'), 'random', 'stream', 'open', 'Off-topic, fun stuff', ${SYSTEM_HEX}, 0),
|
||||
(UNHEX('${UUID_ENGINEERING}'), 'engineering', 'stream', 'open', 'Engineering discussions', ${SYSTEM_HEX}, 0),
|
||||
(UNHEX('${UUID_AGENTS}'), 'agents', 'stream', 'open', 'AI agent testing and collaboration', ${SYSTEM_HEX}, 0),
|
||||
(UNHEX('${UUID_WATERCOOLER}'), 'watercooler', 'forum', 'open', 'Casual forum for async discussions', ${SYSTEM_HEX}, 1),
|
||||
(UNHEX('${UUID_ANNOUNCEMENTS}'), 'announcements', 'forum', 'open', 'Company announcements', ${SYSTEM_HEX}, 1),
|
||||
(UNHEX('${UUID_DM_ALICE_TYLER}'), 'alice-tyler', 'dm', 'private', 'DM between alice and tyler', ${SYSTEM_HEX}, 0),
|
||||
(UNHEX('${UUID_DM_BOB_TYLER}'), 'bob-tyler', 'dm', 'private', 'DM between bob and tyler', ${SYSTEM_HEX}, 0),
|
||||
(UNHEX('${UUID_DM_BOB_CHARLIE_TYLER}'), 'bob-charlie-tyler', 'dm', 'private', 'Group DM: bob, charlie, tyler', ${SYSTEM_HEX}, 0)
|
||||
;
|
||||
"
|
||||
|
||||
run_sql "
|
||||
INSERT IGNORE INTO channel_members
|
||||
(channel_id, pubkey, role, invited_by)
|
||||
VALUES
|
||||
(UNHEX('${UUID_DM_ALICE_TYLER}'), UNHEX('${ALICE_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_DM_ALICE_TYLER}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_DM_BOB_TYLER}'), UNHEX('${BOB_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_DM_BOB_TYLER}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_DM_BOB_CHARLIE_TYLER}'), UNHEX('${BOB_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_DM_BOB_CHARLIE_TYLER}'), UNHEX('${CHARLIE_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_DM_BOB_CHARLIE_TYLER}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_GENERAL}'), UNHEX('${AGENT_PUBKEY}'), 'bot', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_RANDOM}'), UNHEX('${AGENT_PUBKEY}'), 'bot', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_ENGINEERING}'), UNHEX('${AGENT_PUBKEY}'), 'bot', ${SYSTEM_HEX}),
|
||||
(UNHEX('${UUID_AGENTS}'), UNHEX('${AGENT_PUBKEY}'), 'bot', ${SYSTEM_HEX})
|
||||
;
|
||||
"
|
||||
|
||||
echo "Desktop e2e data ready."
|
||||
Reference in New Issue
Block a user