Add desktop Playwright e2e harness (#11)

This commit is contained in:
Wes
2026-03-09 19:16:43 -07:00
committed by GitHub
parent 998d83962b
commit 6e51cc0b12
22 changed files with 1289 additions and 24 deletions

View File

@@ -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
View File

@@ -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/

View File

@@ -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
View File

@@ -11,6 +11,8 @@ node_modules
.pnpm-store
dist
dist-ssr
playwright-report
test-results
*.local
# Editor directories and files

View File

@@ -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",

View 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
View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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>
);

View File

@@ -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"
>

View File

@@ -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>

View File

@@ -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"
/>
</>

View File

@@ -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: {

View 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;
}

View 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);
});

View 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();
}
});

View 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 });
}

View 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();
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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."