diff --git a/.github/workflows/activation-e2e.yml b/.github/workflows/activation-e2e.yml new file mode 100644 index 0000000..81c32e1 --- /dev/null +++ b/.github/workflows/activation-e2e.yml @@ -0,0 +1,148 @@ +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 + timeout-minutes: 15 + 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: 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 diff --git a/.gitignore b/.gitignore index 0b2ec36..aad47da 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,11 @@ __pycache__/ expo-env.d.ts # @end expo-cli android/app/src/main/assets/index.android.bundle + +# Maestro `takeScreenshot` output (.maestro/flows/activation-*.yaml). Maestro +# writes .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 diff --git a/.maestro/flows/activation-negative-401.yaml b/.maestro/flows/activation-negative-401.yaml new file mode 100644 index 0000000..f50efed --- /dev/null +++ b/.maestro/flows/activation-negative-401.yaml @@ -0,0 +1,68 @@ +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 empty/no-connection state, not a fake +# "connected" screen. +- tapOn: "OK" +- assertVisible: + text: "No Connection" +- takeScreenshot: negative-S5_still_disconnected_after_dismiss diff --git a/.maestro/flows/activation-positive.yaml b/.maestro/flows/activation-positive.yaml new file mode 100644 index 0000000..1937406 --- /dev/null +++ b/.maestro/flows/activation-positive.yaml @@ -0,0 +1,74 @@ +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" +- takeScreenshot: positive-S8_reply_received diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 3b6caec..d18e400 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -290,6 +290,7 @@ export default function SessionsScreen() { router.push("/connection/add")} + testID="add-connection-button" > Add Connection @@ -307,9 +308,10 @@ export default function SessionsScreen() { onPress={() => dirSheetRef.current?.expand()} onLongPress={() => router.push("/(tabs)/connections")} activeOpacity={0.7} + testID="connection-status-bar" > - + {activeConnection.name} @@ -365,6 +367,7 @@ export default function SessionsScreen() { onPress={onFabPress} onLongPress={onFabLongPress} delayLongPress={500} + testID="new-session-fab" > diff --git a/app/connection/add.tsx b/app/connection/add.tsx index 34416f3..f6f76fc 100644 --- a/app/connection/add.tsx +++ b/app/connection/add.tsx @@ -177,6 +177,7 @@ export default function AddConnectionScreen() { autoCapitalize="none" autoCorrect={false} keyboardType="url" + testID="connect-ip-input" /> : @@ -208,6 +210,7 @@ export default function AddConnectionScreen() { value={password} onChangeText={setPassword} secureTextEntry + testID="connect-password-input" /> Username defaults to opencode. Custom username? Use{" "} @@ -222,6 +225,7 @@ export default function AddConnectionScreen() { style={[styles.connectButton, isDark && styles.connectButtonDark]} onPress={handleQuickConnect} disabled={isConnecting} + testID="connect-submit-button" > {isConnecting ? ( diff --git a/app/session/[id].tsx b/app/session/[id].tsx index def808c..71954d5 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -640,6 +640,7 @@ export default function SessionScreen() { editable={!speech.listening} multiline maxLength={10000} + testID="chat-message-input" /> {/* Stop button: only when busy and no input */} {isSending && !input.trim() && attachments.length === 0 && !speech.listening && ( @@ -661,7 +662,7 @@ export default function SessionScreen() { )} {/* Send button: when there's input */} {!speech.listening && (input.trim() || attachments.length > 0) && ( - + )} diff --git a/src/components/TelemetryConsentModal.tsx b/src/components/TelemetryConsentModal.tsx index bf61b3f..cc7fe80 100644 --- a/src/components/TelemetryConsentModal.tsx +++ b/src/components/TelemetryConsentModal.tsx @@ -22,7 +22,7 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) { return ( - + {/* Icon */} @@ -56,6 +56,7 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) { style={[styles.btn, styles.btnDecline, isDark && styles.btnDeclineDark]} onPress={onDecline} accessibilityLabel="No thanks, decline crash reporting" + testID="telemetry-decline-button" > No thanks @@ -63,6 +64,7 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) { style={[styles.btn, styles.btnAllow]} onPress={onAllow} accessibilityLabel="Allow anonymous crash reports" + testID="telemetry-allow-button" > Allow diff --git a/src/components/chat/MessageBubble.tsx b/src/components/chat/MessageBubble.tsx index 249007e..97d7038 100644 --- a/src/components/chat/MessageBubble.tsx +++ b/src/components/chat/MessageBubble.tsx @@ -39,6 +39,7 @@ export const MessageBubble = memo( isUser && isDark && s.userDark, !isUser && isDark && s.assistantDark, ]} + testID={`chat-bubble-${message.role}`} > {/* Role indicator */} diff --git a/tests/fixtures/mock-opencode-server.ts b/tests/fixtures/mock-opencode-server.ts new file mode 100644 index 0000000..21d5892 --- /dev/null +++ b/tests/fixtures/mock-opencode-server.ts @@ -0,0 +1,324 @@ +// 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: \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). +// +// 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() + const messagesBySession = new Map() + const sseClients = new Set() + + function broadcast(type: string, properties: Record) { + 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)", + }) + } + + 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}` }) + } + json(res, 200, { ok: true }) + 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 { + return new Promise((resolve) => server.listen(port, "0.0.0.0", () => resolve())) + }, + close(): Promise { + 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) +}