merge: test/activation-e2e — Maestro activation E2E + mock opencode server (reviewed: APPROVE after fixes)
This commit is contained in:
161
.github/workflows/activation-e2e.yml
vendored
Normal file
161
.github/workflows/activation-e2e.yml
vendored
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
name: Activation E2E (Maestro)
|
||||||
|
|
||||||
|
# Deterministic regression coverage for the activation flow (first open ->
|
||||||
|
# telemetry consent -> server URL entry -> connect -> send first message ->
|
||||||
|
# receive reply), including the connect-time-401 negative case tied to the
|
||||||
|
# 0%-7-day-retention / GitHub issue #76 investigation.
|
||||||
|
#
|
||||||
|
# Runs against tests/fixtures/mock-opencode-server.ts (a small dependency-free
|
||||||
|
# HTTP+SSE stub matching the REAL client protocol read from src/lib/sdk.ts —
|
||||||
|
# NOT a live opencode server, NOT a WebSocket), so the suite is fast and fully
|
||||||
|
# self-contained: no external server, no LLM provider, no network flakiness.
|
||||||
|
#
|
||||||
|
# This is intentionally separate from cua-smoke.yml (the existing
|
||||||
|
# vision-driven CUA harness): that one needs a live opencode server + an Azure
|
||||||
|
# LLM and is exploratory/non-deterministic by design, so it isn't suited to
|
||||||
|
# tight regression assertions like "a 401 must show a visible error."
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "app/**"
|
||||||
|
- "src/**"
|
||||||
|
- "tests/fixtures/**"
|
||||||
|
- ".maestro/**"
|
||||||
|
- ".github/workflows/activation-e2e.yml"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "app/**"
|
||||||
|
- "src/**"
|
||||||
|
- "tests/fixtures/**"
|
||||||
|
- ".maestro/**"
|
||||||
|
- ".github/workflows/activation-e2e.yml"
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
activation-e2e:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Same npm install + expo prebuild + assembleRelease + emulator pipeline as
|
||||||
|
# cua-smoke.yml, which budgets 60 min (emulator-boot-timeout alone is 10 min).
|
||||||
|
# Typical runs finish well under this; the ceiling just avoids flaky kills
|
||||||
|
# on cold Gradle caches.
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v5
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
|
- name: Setup Android SDK
|
||||||
|
uses: android-actions/setup-android@v4
|
||||||
|
|
||||||
|
- name: Add emulator to PATH
|
||||||
|
run: echo "$ANDROID_HOME/emulator" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Enable KVM
|
||||||
|
run: |
|
||||||
|
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
sudo udevadm trigger --name-match=kvm
|
||||||
|
|
||||||
|
- name: Cache Gradle
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
android/.gradle
|
||||||
|
key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle*', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
|
- name: Purge stale generated sources
|
||||||
|
# Same mitigation as cua-smoke.yml (whose Gradle cache entries this job
|
||||||
|
# shares — the cache key/restore-keys are identical): the restore-keys
|
||||||
|
# prefix fallback can restore a generated autolinking tree from a
|
||||||
|
# previous package id, making compileReleaseJavaWithJavac fail against
|
||||||
|
# the old package (ai.opencode.mobile vs cc.agentlabs.opencode). Delete
|
||||||
|
# generated sources so prebuild + Gradle regenerate them.
|
||||||
|
run: rm -rf android/app/build/generated android/build/generated android/app/build/intermediates
|
||||||
|
|
||||||
|
- name: Install Maestro CLI
|
||||||
|
run: |
|
||||||
|
curl -Ls "https://get.maestro.mobile.dev" | bash
|
||||||
|
echo "$HOME/.maestro/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install dependencies & build APK
|
||||||
|
env:
|
||||||
|
SENTRY_DISABLE_AUTO_UPLOAD: "true"
|
||||||
|
run: |
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
npx expo prebuild --platform android --no-install
|
||||||
|
keytool -genkey -v -keystore android/app/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
|
||||||
|
cd android && ./gradlew assembleRelease
|
||||||
|
|
||||||
|
- name: Start mock opencode servers
|
||||||
|
run: |
|
||||||
|
set -x
|
||||||
|
mkdir -p artifacts/screenshots
|
||||||
|
# Normal mode on 4096 (positive flow) and --fail-auth on 4097 (negative flow).
|
||||||
|
# Both bind 0.0.0.0 so the emulator can reach them via 10.0.2.2.
|
||||||
|
nohup node tests/fixtures/mock-opencode-server.ts --port 4096 > /tmp/mock-4096.log 2>&1 &
|
||||||
|
nohup node tests/fixtures/mock-opencode-server.ts --port 4097 --fail-auth > /tmp/mock-4097.log 2>&1 &
|
||||||
|
for port in 4096 4097; do
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sf --connect-timeout 1 -m 3 "http://127.0.0.1:${port}/global/health" > /dev/null 2>&1; then
|
||||||
|
echo "mock server on ${port} responded (may be 401, that's expected on 4097)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
# 4097 always 401s -> curl -sf treats that as failure, so also accept "connection made"
|
||||||
|
if curl -s --connect-timeout 1 -m 3 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/global/health" 2>/dev/null | grep -qE '^[0-9]+$'; then
|
||||||
|
echo "mock server on ${port} is up (got an HTTP response)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" = "30" ]; then
|
||||||
|
echo "::error::mock server on port ${port} did not come up in 30s"
|
||||||
|
cat "/tmp/mock-${port}.log" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Run activation E2E flows on emulator
|
||||||
|
uses: reactivecircus/android-emulator-runner@v2
|
||||||
|
with:
|
||||||
|
api-level: 28
|
||||||
|
arch: x86_64
|
||||||
|
target: default
|
||||||
|
disable-animations: true
|
||||||
|
emulator-boot-timeout: 600
|
||||||
|
emulator-options: -no-window -no-audio -no-boot-anim -gpu swiftshader_indirect -no-snapshot
|
||||||
|
script: |
|
||||||
|
adb install android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
cd artifacts/screenshots
|
||||||
|
echo "--- positive activation flow (connect -> send -> reply) ---"
|
||||||
|
maestro test ../../.maestro/flows/activation-positive.yaml
|
||||||
|
echo "--- negative activation flow (connect-time 401 must show a visible error) ---"
|
||||||
|
maestro test ../../.maestro/flows/activation-negative-401.yaml
|
||||||
|
|
||||||
|
- name: Mock server logs
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "--- 4096 (normal) ---"; cat /tmp/mock-4096.log || true
|
||||||
|
echo "--- 4097 (fail-auth) ---"; cat /tmp/mock-4097.log || true
|
||||||
|
|
||||||
|
- name: Upload screenshots
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: activation-e2e-screenshots-${{ github.run_number }}
|
||||||
|
path: artifacts/screenshots/*.png
|
||||||
|
if-no-files-found: warn
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -20,3 +20,11 @@ __pycache__/
|
|||||||
expo-env.d.ts
|
expo-env.d.ts
|
||||||
# @end expo-cli
|
# @end expo-cli
|
||||||
android/app/src/main/assets/index.android.bundle
|
android/app/src/main/assets/index.android.bundle
|
||||||
|
|
||||||
|
# Maestro `takeScreenshot` output (.maestro/flows/activation-*.yaml). Maestro
|
||||||
|
# writes <name>.png to the CLI's current working directory at invocation time,
|
||||||
|
# not a fixed subdirectory. CI invokes `maestro test` from artifacts/screenshots/
|
||||||
|
# so uploads stay tidy; these are never committed, only attached as CI artifacts.
|
||||||
|
artifacts/screenshots/
|
||||||
|
positive-S*.png
|
||||||
|
negative-S*.png
|
||||||
|
|||||||
70
.maestro/flows/activation-negative-401.yaml
Normal file
70
.maestro/flows/activation-negative-401.yaml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
appId: cc.agentlabs.opencode
|
||||||
|
name: Activation - negative path (connect-time 401 must surface a visible error)
|
||||||
|
---
|
||||||
|
# Negative activation flow — regression test for the GitHub issue #76 failure
|
||||||
|
# class: a connect-time 401 from the server must produce a visible, actionable
|
||||||
|
# error, never a silent failure.
|
||||||
|
#
|
||||||
|
# Quick Connect (app/connection/add.tsx handleQuickConnect) calls
|
||||||
|
# testConnection() -> client.global.health() BEFORE saving the connection.
|
||||||
|
# When that call fails (any thrown error, including a 401 API error), the
|
||||||
|
# screen runs probeConnection() and shows a native Alert titled
|
||||||
|
# "Connection Failed" with the underlying error text — this is the CURRENT,
|
||||||
|
# already-correct behavior we are locking in with this test.
|
||||||
|
#
|
||||||
|
# Known gap (see report): Advanced-mode "Save Connection" (handleAdvancedSave)
|
||||||
|
# does NOT call testConnection() at all — it saves the connection and
|
||||||
|
# navigates back regardless of server reachability, so a 401 there is
|
||||||
|
# currently silent. That gap is not covered by this flow (Quick Connect is the
|
||||||
|
# only entry point that already does the right thing) — flagged in the E2E
|
||||||
|
# report as follow-up work, not silently fixed here.
|
||||||
|
#
|
||||||
|
# The CI job starts `node tests/fixtures/mock-opencode-server.ts --port 4097
|
||||||
|
# --fail-auth` on the runner host BEFORE this flow runs, so every request the
|
||||||
|
# app makes to it (including /global/health) returns HTTP 401.
|
||||||
|
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- assertVisible:
|
||||||
|
id: "telemetry-consent-card"
|
||||||
|
- tapOn:
|
||||||
|
id: "telemetry-decline-button"
|
||||||
|
- takeScreenshot: negative-S1_consent_dismissed
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
text: "No Connection"
|
||||||
|
- tapOn:
|
||||||
|
id: "add-connection-button"
|
||||||
|
- takeScreenshot: negative-S2_add_connection_form
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "connect-ip-input"
|
||||||
|
- inputText: "10.0.2.2"
|
||||||
|
- tapOn:
|
||||||
|
id: "connect-port-input"
|
||||||
|
- eraseText
|
||||||
|
- inputText: "4097"
|
||||||
|
- hideKeyboard
|
||||||
|
- takeScreenshot: negative-S3_401_server_url_entered
|
||||||
|
- tapOn:
|
||||||
|
id: "connect-submit-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Connection Failed"
|
||||||
|
timeout: 20000
|
||||||
|
- assertVisible:
|
||||||
|
text: "Connection Failed"
|
||||||
|
- assertVisible:
|
||||||
|
text: "401"
|
||||||
|
- takeScreenshot: negative-S4_visible_error_alert
|
||||||
|
|
||||||
|
# The connection must NOT have been silently saved: dismiss the alert and
|
||||||
|
# confirm we are still on the add-connection screen, not a fake "connected"
|
||||||
|
# screen. (handleQuickConnect's failure branch never calls router.back(), so
|
||||||
|
# the app stays on this modal — the Sessions-tab "No Connection" empty state
|
||||||
|
# is NOT visible here.)
|
||||||
|
- tapOn: "OK"
|
||||||
|
- assertVisible:
|
||||||
|
id: "connect-submit-button"
|
||||||
|
- takeScreenshot: negative-S5_still_on_add_connection_after_dismiss
|
||||||
82
.maestro/flows/activation-positive.yaml
Normal file
82
.maestro/flows/activation-positive.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
appId: cc.agentlabs.opencode
|
||||||
|
name: Activation - positive path (consent -> connect -> send -> reply)
|
||||||
|
---
|
||||||
|
# Positive activation flow: first launch -> telemetry consent -> quick connect
|
||||||
|
# to the mock opencode server (tests/fixtures/mock-opencode-server.ts, run
|
||||||
|
# normally on the port below) -> connected indicator -> new session -> send a
|
||||||
|
# message -> assert the streamed canned reply renders.
|
||||||
|
#
|
||||||
|
# The CI job starts `node tests/fixtures/mock-opencode-server.ts --port 4096`
|
||||||
|
# on the runner host BEFORE this flow runs. The Android emulator reaches the
|
||||||
|
# runner host via the standard emulator alias 10.0.2.2.
|
||||||
|
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- takeScreenshot: positive-S1_launch_consent_modal
|
||||||
|
- assertVisible:
|
||||||
|
id: "telemetry-consent-card"
|
||||||
|
- tapOn:
|
||||||
|
id: "telemetry-decline-button"
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
text: "No Connection"
|
||||||
|
- takeScreenshot: positive-S2_empty_state
|
||||||
|
- tapOn:
|
||||||
|
id: "add-connection-button"
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
id: "connect-ip-input"
|
||||||
|
- takeScreenshot: positive-S3_add_connection_form
|
||||||
|
- tapOn:
|
||||||
|
id: "connect-ip-input"
|
||||||
|
- inputText: "10.0.2.2"
|
||||||
|
- tapOn:
|
||||||
|
id: "connect-port-input"
|
||||||
|
- eraseText
|
||||||
|
- inputText: "4096"
|
||||||
|
- hideKeyboard
|
||||||
|
- takeScreenshot: positive-S4_server_url_entered
|
||||||
|
- tapOn:
|
||||||
|
id: "connect-submit-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "connection-status-dot"
|
||||||
|
timeout: 20000
|
||||||
|
- assertVisible:
|
||||||
|
id: "connection-status-dot"
|
||||||
|
- takeScreenshot: positive-S5_connected
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "new-session-fab"
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "chat-message-input"
|
||||||
|
timeout: 15000
|
||||||
|
- takeScreenshot: positive-S6_session_opened
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "chat-message-input"
|
||||||
|
- inputText: "Hello from the activation e2e test"
|
||||||
|
- hideKeyboard
|
||||||
|
- takeScreenshot: positive-S7_message_typed
|
||||||
|
- tapOn:
|
||||||
|
id: "chat-send-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Hello from the mock opencode server"
|
||||||
|
timeout: 20000
|
||||||
|
- assertVisible:
|
||||||
|
id: "chat-bubble-assistant"
|
||||||
|
- assertVisible:
|
||||||
|
text: "Hello from the mock opencode server"
|
||||||
|
# The USER's message must still be in the transcript after the reply lands.
|
||||||
|
# Regression guard: any message.updated event strips optimistic temp-
|
||||||
|
# messages (src/stores/sessions.ts handleEvent), so if the server never
|
||||||
|
# persisted/broadcast the user's message, it would vanish here.
|
||||||
|
- assertVisible:
|
||||||
|
id: "chat-bubble-user"
|
||||||
|
- assertVisible:
|
||||||
|
text: "Hello from the activation e2e test"
|
||||||
|
- takeScreenshot: positive-S8_reply_received
|
||||||
@@ -334,6 +334,7 @@ export default function SessionsScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.addButton, isDark && styles.addButtonDark]}
|
style={[styles.addButton, isDark && styles.addButtonDark]}
|
||||||
onPress={() => router.push("/connection/add")}
|
onPress={() => router.push("/connection/add")}
|
||||||
|
testID="add-connection-button"
|
||||||
>
|
>
|
||||||
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Add Connection</Text>
|
<Text style={[styles.addButtonText, isDark && styles.addButtonTextDark]}>Add Connection</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -351,9 +352,10 @@ export default function SessionsScreen() {
|
|||||||
onPress={() => dirSheetRef.current?.expand()}
|
onPress={() => dirSheetRef.current?.expand()}
|
||||||
onLongPress={() => router.push("/(tabs)/connections")}
|
onLongPress={() => router.push("/(tabs)/connections")}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
testID="connection-status-bar"
|
||||||
>
|
>
|
||||||
<View style={styles.connectionInfo}>
|
<View style={styles.connectionInfo}>
|
||||||
<View style={[styles.connectionDot, { backgroundColor: "#22c55e" }]} />
|
<View style={[styles.connectionDot, { backgroundColor: "#22c55e" }]} testID="connection-status-dot" />
|
||||||
<Text style={[styles.connectionName, isDark && styles.textDark]} numberOfLines={1}>
|
<Text style={[styles.connectionName, isDark && styles.textDark]} numberOfLines={1}>
|
||||||
{activeConnection.name}
|
{activeConnection.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -409,6 +411,7 @@ export default function SessionsScreen() {
|
|||||||
onPress={onFabPress}
|
onPress={onFabPress}
|
||||||
onLongPress={onFabLongPress}
|
onLongPress={onFabLongPress}
|
||||||
delayLongPress={500}
|
delayLongPress={500}
|
||||||
|
testID="new-session-fab"
|
||||||
>
|
>
|
||||||
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
<Ionicons name="add" size={28} color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export default function AddConnectionScreen() {
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
keyboardType="url"
|
keyboardType="url"
|
||||||
|
testID="connect-ip-input"
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.ipColon, isDark && styles.textDark]}>:</Text>
|
<Text style={[styles.ipColon, isDark && styles.textDark]}>:</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -194,6 +195,7 @@ export default function AddConnectionScreen() {
|
|||||||
value={port}
|
value={port}
|
||||||
onChangeText={setPort}
|
onChangeText={setPort}
|
||||||
keyboardType="number-pad"
|
keyboardType="number-pad"
|
||||||
|
testID="connect-port-input"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -216,6 +218,7 @@ export default function AddConnectionScreen() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChangeText={setPassword}
|
onChangeText={setPassword}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
|
testID="connect-password-input"
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.usernameHint, isDark && styles.hintDark]}>
|
<Text style={[styles.usernameHint, isDark && styles.hintDark]}>
|
||||||
Username defaults to <Text style={styles.code}>opencode</Text>. Custom username? Use{" "}
|
Username defaults to <Text style={styles.code}>opencode</Text>. Custom username? Use{" "}
|
||||||
@@ -230,6 +233,7 @@ export default function AddConnectionScreen() {
|
|||||||
style={[styles.connectButton, isDark && styles.connectButtonDark]}
|
style={[styles.connectButton, isDark && styles.connectButtonDark]}
|
||||||
onPress={handleQuickConnect}
|
onPress={handleQuickConnect}
|
||||||
disabled={isConnecting}
|
disabled={isConnecting}
|
||||||
|
testID="connect-submit-button"
|
||||||
>
|
>
|
||||||
{isConnecting ? (
|
{isConnecting ? (
|
||||||
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
<ActivityIndicator size="small" color={isDark ? "#0a0a0a" : "#ffffff"} />
|
||||||
|
|||||||
@@ -640,6 +640,7 @@ export default function SessionScreen() {
|
|||||||
editable={!speech.listening}
|
editable={!speech.listening}
|
||||||
multiline
|
multiline
|
||||||
maxLength={10000}
|
maxLength={10000}
|
||||||
|
testID="chat-message-input"
|
||||||
/>
|
/>
|
||||||
{/* Stop button: only when busy and no input */}
|
{/* Stop button: only when busy and no input */}
|
||||||
{isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
|
{isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
|
||||||
@@ -661,7 +662,7 @@ export default function SessionScreen() {
|
|||||||
)}
|
)}
|
||||||
{/* Send button: when there's input */}
|
{/* Send button: when there's input */}
|
||||||
{!speech.listening && (input.trim() || attachments.length > 0) && (
|
{!speech.listening && (input.trim() || attachments.length > 0) && (
|
||||||
<TouchableOpacity style={s.sendBtn} onPress={handleSend}>
|
<TouchableOpacity style={s.sendBtn} onPress={handleSend} testID="chat-send-button">
|
||||||
<Ionicons name="send" size={20} color="#ffffff" />
|
<Ionicons name="send" size={20} color="#ffffff" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent onRequestClose={onDecline}>
|
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent onRequestClose={onDecline}>
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<View style={[styles.card, isDark && styles.cardDark]}>
|
<View style={[styles.card, isDark && styles.cardDark]} testID="telemetry-consent-card">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<View style={[styles.iconWrap, isDark && styles.iconWrapDark]}>
|
<View style={[styles.iconWrap, isDark && styles.iconWrapDark]}>
|
||||||
<Ionicons name="bug-outline" size={40} color="#3b82f6" />
|
<Ionicons name="bug-outline" size={40} color="#3b82f6" />
|
||||||
@@ -56,6 +56,7 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) {
|
|||||||
style={[styles.btn, styles.btnDecline, isDark && styles.btnDeclineDark]}
|
style={[styles.btn, styles.btnDecline, isDark && styles.btnDeclineDark]}
|
||||||
onPress={onDecline}
|
onPress={onDecline}
|
||||||
accessibilityLabel="No thanks, decline crash reporting"
|
accessibilityLabel="No thanks, decline crash reporting"
|
||||||
|
testID="telemetry-decline-button"
|
||||||
>
|
>
|
||||||
<Text style={[styles.btnDeclineText, isDark && styles.btnDeclineTextDark]}>No thanks</Text>
|
<Text style={[styles.btnDeclineText, isDark && styles.btnDeclineTextDark]}>No thanks</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -63,6 +64,7 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) {
|
|||||||
style={[styles.btn, styles.btnAllow]}
|
style={[styles.btn, styles.btnAllow]}
|
||||||
onPress={onAllow}
|
onPress={onAllow}
|
||||||
accessibilityLabel="Allow anonymous crash reports"
|
accessibilityLabel="Allow anonymous crash reports"
|
||||||
|
testID="telemetry-allow-button"
|
||||||
>
|
>
|
||||||
<Text style={styles.btnAllowText}>Allow</Text>
|
<Text style={styles.btnAllowText}>Allow</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const MessageBubble = memo(
|
|||||||
isUser && isDark && s.userDark,
|
isUser && isDark && s.userDark,
|
||||||
!isUser && isDark && s.assistantDark,
|
!isUser && isDark && s.assistantDark,
|
||||||
]}
|
]}
|
||||||
|
testID={`chat-bubble-${message.role}`}
|
||||||
>
|
>
|
||||||
{/* Role indicator */}
|
{/* Role indicator */}
|
||||||
<View style={s.header}>
|
<View style={s.header}>
|
||||||
|
|||||||
373
tests/fixtures/mock-opencode-server.ts
vendored
Normal file
373
tests/fixtures/mock-opencode-server.ts
vendored
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
// Minimal opencode-server protocol stub used by the Maestro activation E2E flows
|
||||||
|
// (.maestro/flows/activation-*.yaml). It is NOT a real opencode server — it
|
||||||
|
// implements just enough of the REST + Server-Sent-Events surface that the
|
||||||
|
// mobile client (src/lib/sdk.ts) actually talks to, so the app can genuinely
|
||||||
|
// go through connect -> create session -> send message -> receive a streamed
|
||||||
|
// reply against something real instead of a live server.
|
||||||
|
//
|
||||||
|
// Protocol notes (read from src/lib/sdk.ts / src/stores/connections.ts /
|
||||||
|
// src/stores/events.ts — NOT guessed):
|
||||||
|
// - There is no WebSocket anywhere in the client. "Connect" = one GET
|
||||||
|
// /global/health call (src/stores/connections.ts testConnection()).
|
||||||
|
// - Real-time updates (including the assistant's streamed reply) arrive via
|
||||||
|
// a single long-lived SSE connection: GET /global/event, framed as
|
||||||
|
// `data: <json>\n\n` lines (see src/lib/sse.ts SSEParser).
|
||||||
|
// - Sending a message is fire-and-forget: POST /session/:id/prompt_async
|
||||||
|
// returns immediately; the actual reply is delivered as
|
||||||
|
// `message.updated` + `message.part.updated` + `session.status` (idle)
|
||||||
|
// events on the SSE stream (src/stores/events.ts).
|
||||||
|
// - The real server ALSO persists and broadcasts the USER's message. The
|
||||||
|
// app relies on this: any `message.updated` event strips optimistic
|
||||||
|
// `temp-` messages (src/stores/sessions.ts handleEvent), so if the mock
|
||||||
|
// only broadcast the assistant reply, the user's sent message would
|
||||||
|
// vanish from the transcript. The mock therefore stores the user message
|
||||||
|
// from the prompt_async body and broadcasts it (message.updated +
|
||||||
|
// message.part.updated) before the canned assistant reply, and returns
|
||||||
|
// it from GET /session/:id/message.
|
||||||
|
//
|
||||||
|
// Two modes:
|
||||||
|
// - Normal mode: implements the endpoints above so the app can connect,
|
||||||
|
// open a session, send a message, and render a canned assistant reply.
|
||||||
|
// - `--fail-auth` mode: every request returns 401, simulating a
|
||||||
|
// connect-time auth failure (GitHub issue #76's failure class). Used by
|
||||||
|
// .maestro/flows/activation-negative-401.yaml to assert the app surfaces
|
||||||
|
// a visible, actionable error instead of failing silently.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node tests/fixtures/mock-opencode-server.ts --port 4096
|
||||||
|
// node tests/fixtures/mock-opencode-server.ts --port 4097 --fail-auth
|
||||||
|
|
||||||
|
import http from "node:http"
|
||||||
|
import { randomUUID } from "node:crypto"
|
||||||
|
|
||||||
|
export interface MockServerOptions {
|
||||||
|
port: number
|
||||||
|
/** When true, ALL requests return 401 (simulates issue #76's connect-time auth failure). */
|
||||||
|
failAuth?: boolean
|
||||||
|
/** Canned assistant reply text streamed back after a prompt is submitted. */
|
||||||
|
replyText?: string
|
||||||
|
/** Delay before the canned reply is pushed over SSE, in ms. */
|
||||||
|
replyDelayMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredSession {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
projectID: string
|
||||||
|
directory: string
|
||||||
|
title: string
|
||||||
|
version: string
|
||||||
|
time: { created: number; updated: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredPart {
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
messageID: string
|
||||||
|
type: string
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredMessageInfo {
|
||||||
|
id: string
|
||||||
|
sessionID: string
|
||||||
|
role: "user" | "assistant"
|
||||||
|
time: { created: number; completed?: number }
|
||||||
|
modelID?: string
|
||||||
|
providerID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredMessage {
|
||||||
|
info: StoredMessageInfo
|
||||||
|
parts: StoredPart[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_REPLY_TEXT = "Hello from the mock opencode server — activation e2e canned reply."
|
||||||
|
|
||||||
|
export function createMockOpencodeServer(opts: MockServerOptions) {
|
||||||
|
const { port, failAuth = false, replyText = DEFAULT_REPLY_TEXT, replyDelayMs = 300 } = opts
|
||||||
|
|
||||||
|
const sessions = new Map<string, StoredSession>()
|
||||||
|
const messagesBySession = new Map<string, StoredMessage[]>()
|
||||||
|
const sseClients = new Set<http.ServerResponse>()
|
||||||
|
|
||||||
|
function broadcast(type: string, properties: Record<string, unknown>) {
|
||||||
|
const line = `data: ${JSON.stringify({ type, properties })}\n\n`
|
||||||
|
for (const res of sseClients) {
|
||||||
|
try {
|
||||||
|
res.write(line)
|
||||||
|
} catch {
|
||||||
|
sseClients.delete(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(res: http.ServerResponse, status: number, body: unknown) {
|
||||||
|
const data = JSON.stringify(body)
|
||||||
|
res.writeHead(status, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(data),
|
||||||
|
})
|
||||||
|
res.end(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unauthorized(res: http.ServerResponse) {
|
||||||
|
json(res, 401, {
|
||||||
|
error: "Unauthorized",
|
||||||
|
message: "mock-opencode-server: running in --fail-auth mode (simulates GitHub issue #76)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the user's message (parsed from the prompt_async body) and
|
||||||
|
// broadcast it over SSE, mirroring the real server. This is what lets the
|
||||||
|
// app replace its optimistic `temp-` user message with the real one instead
|
||||||
|
// of losing it when the assistant's message.updated arrives.
|
||||||
|
function storeUserMessage(sessionID: string, promptParts: Array<{ type?: string; text?: string }>) {
|
||||||
|
const list = messagesBySession.get(sessionID)
|
||||||
|
if (!list) return
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const messageID = randomUUID()
|
||||||
|
const info: StoredMessageInfo = {
|
||||||
|
id: messageID,
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: now, completed: now },
|
||||||
|
}
|
||||||
|
const parts: StoredPart[] = promptParts
|
||||||
|
.filter((p) => p.type === "text" && typeof p.text === "string")
|
||||||
|
.map((p) => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
type: "text",
|
||||||
|
text: p.text,
|
||||||
|
}))
|
||||||
|
|
||||||
|
list.push({ info, parts })
|
||||||
|
broadcast("message.updated", { info })
|
||||||
|
for (const part of parts) {
|
||||||
|
broadcast("message.part.updated", { part })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReply(sessionID: string) {
|
||||||
|
const list = messagesBySession.get(sessionID)
|
||||||
|
if (!list) return
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
broadcast("session.status", { sessionID, status: { type: "busy" } })
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const messageID = randomUUID()
|
||||||
|
const info: StoredMessageInfo = {
|
||||||
|
id: messageID,
|
||||||
|
sessionID,
|
||||||
|
role: "assistant",
|
||||||
|
time: { created: now },
|
||||||
|
modelID: "mock-model",
|
||||||
|
providerID: "mock",
|
||||||
|
}
|
||||||
|
broadcast("message.updated", { info })
|
||||||
|
|
||||||
|
const part: StoredPart = {
|
||||||
|
id: randomUUID(),
|
||||||
|
sessionID,
|
||||||
|
messageID,
|
||||||
|
type: "text",
|
||||||
|
text: replyText,
|
||||||
|
}
|
||||||
|
broadcast("message.part.updated", { part })
|
||||||
|
|
||||||
|
list.push({ info: { ...info, time: { created: now, completed: Date.now() } }, parts: [part] })
|
||||||
|
|
||||||
|
broadcast("session.status", { sessionID, status: { type: "idle" } })
|
||||||
|
}, replyDelayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url || "/", `http://localhost:${port}`)
|
||||||
|
const path = url.pathname
|
||||||
|
const method = req.method || "GET"
|
||||||
|
|
||||||
|
if (failAuth) {
|
||||||
|
unauthorized(res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && path === "/global/health") {
|
||||||
|
return json(res, 200, { healthy: true, version: "0.0.0-mock" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && path === "/project/current") {
|
||||||
|
return json(res, 200, {
|
||||||
|
id: "mock-project",
|
||||||
|
name: "mock-project",
|
||||||
|
path: { cwd: "/mock/project", root: "/mock/project", absolute: "/mock/project" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/project") {
|
||||||
|
return json(res, 200, [])
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/path") {
|
||||||
|
return json(res, 200, {
|
||||||
|
home: "/mock/home",
|
||||||
|
state: "/mock/home/.local/state/opencode",
|
||||||
|
config: "/mock/home/.config/opencode",
|
||||||
|
worktree: "/mock/project",
|
||||||
|
directory: "/mock/project",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "GET" && path === "/agent") {
|
||||||
|
return json(res, 200, [{ name: "build", mode: "primary", options: {} }])
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/command") {
|
||||||
|
return json(res, 200, [])
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/provider") {
|
||||||
|
return json(res, 200, {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "mock",
|
||||||
|
name: "Mock Provider",
|
||||||
|
models: {
|
||||||
|
"mock-model": {
|
||||||
|
id: "mock-model",
|
||||||
|
name: "Mock Model",
|
||||||
|
attachment: false,
|
||||||
|
reasoning: false,
|
||||||
|
tool_call: false,
|
||||||
|
limit: { context: 8000, output: 2000 },
|
||||||
|
status: "active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
default: { mock: "mock-model" },
|
||||||
|
connected: ["mock"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/permission") {
|
||||||
|
return json(res, 200, [])
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/question") {
|
||||||
|
return json(res, 200, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSE event stream — kept open for the lifetime of the connection.
|
||||||
|
if (method === "GET" && path === "/global/event") {
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
})
|
||||||
|
res.write(": connected\n\n")
|
||||||
|
sseClients.add(res)
|
||||||
|
req.on("close", () => sseClients.delete(res))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === "POST" && path === "/session") {
|
||||||
|
const id = randomUUID()
|
||||||
|
const now = Date.now()
|
||||||
|
const session: StoredSession = {
|
||||||
|
id,
|
||||||
|
slug: id.slice(0, 8),
|
||||||
|
projectID: "mock-project",
|
||||||
|
directory: "/mock/project",
|
||||||
|
title: "Mock Session",
|
||||||
|
version: "0.0.0-mock",
|
||||||
|
time: { created: now, updated: now },
|
||||||
|
}
|
||||||
|
sessions.set(id, session)
|
||||||
|
messagesBySession.set(id, [])
|
||||||
|
return json(res, 200, session)
|
||||||
|
}
|
||||||
|
if (method === "GET" && path === "/session") {
|
||||||
|
return json(res, 200, Array.from(sessions.values()))
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionMessageMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||||
|
if (method === "GET" && sessionMessageMatch) {
|
||||||
|
const sid = sessionMessageMatch[1]
|
||||||
|
return json(res, 200, messagesBySession.get(sid) || [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptMatch = path.match(/^\/session\/([^/]+)\/prompt_async$/)
|
||||||
|
if (method === "POST" && promptMatch) {
|
||||||
|
const sid = promptMatch[1]
|
||||||
|
let body = ""
|
||||||
|
req.on("data", (chunk) => (body += chunk))
|
||||||
|
req.on("end", () => {
|
||||||
|
if (!sessions.has(sid)) {
|
||||||
|
return json(res, 404, { error: `unknown session ${sid}` })
|
||||||
|
}
|
||||||
|
let promptParts: Array<{ type?: string; text?: string }> = []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body || "{}")
|
||||||
|
if (Array.isArray(parsed.parts)) promptParts = parsed.parts
|
||||||
|
} catch {
|
||||||
|
// malformed body — still ack like a fire-and-forget endpoint would
|
||||||
|
}
|
||||||
|
json(res, 200, { ok: true })
|
||||||
|
storeUserMessage(sid, promptParts)
|
||||||
|
scheduleReply(sid)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortMatch = path.match(/^\/session\/([^/]+)\/abort$/)
|
||||||
|
if (method === "POST" && abortMatch) {
|
||||||
|
return json(res, 200, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
json(res, 404, { error: `mock-opencode-server: no handler for ${method} ${path}` })
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
url: `http://localhost:${port}`,
|
||||||
|
listen(): Promise<void> {
|
||||||
|
return new Promise((resolve) => server.listen(port, "0.0.0.0", () => resolve()))
|
||||||
|
},
|
||||||
|
close(): Promise<void> {
|
||||||
|
for (const res of sseClients) {
|
||||||
|
try {
|
||||||
|
res.end()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sseClients.clear()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.close((err) => (err ? reject(err) : resolve()))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): { port: number; failAuth: boolean } {
|
||||||
|
const opts = { port: 4096, failAuth: false }
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
if (argv[i] === "--port") opts.port = Number(argv[++i])
|
||||||
|
else if (argv[i] === "--fail-auth") opts.failAuth = true
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
const invokedDirectly =
|
||||||
|
typeof process !== "undefined" && process.argv[1] && import.meta.url === `file://${process.argv[1]}`
|
||||||
|
|
||||||
|
if (invokedDirectly) {
|
||||||
|
const opts = parseArgs(process.argv.slice(2))
|
||||||
|
const mock = createMockOpencodeServer(opts)
|
||||||
|
mock.listen().then(() => {
|
||||||
|
console.log(`[mock-opencode-server] listening on ${mock.url} (failAuth=${opts.failAuth})`)
|
||||||
|
})
|
||||||
|
const shutdown = () => {
|
||||||
|
mock.close().then(() => process.exit(0))
|
||||||
|
}
|
||||||
|
process.on("SIGINT", shutdown)
|
||||||
|
process.on("SIGTERM", shutdown)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user