Files
opencode-mobile/src/components/chat/DirectoryBrowserSheet.tsx
Den 2da0fdf109 test: E2E coverage for directory picker, all-sessions, variant picker. Refs #46 #48 #47 #49 #57. (#82)
* test: E2E coverage for directory picker, all-sessions, variant picker

Extend the Maestro suite for the features merged into main today:
DirectoryBrowserSheet's server-folder picker, the directory-less
all-sessions-across-projects list (+ the #46/#48 open-across-project
regression), and VariantPicker's reasoning-effort chip.

- tests/fixtures/mock-opencode-server.ts: GET /file (directory-scoped via
  the x-opencode-directory header) with a small fake tree, GET /project
  for the "Server Projects" section, POST /session honoring the directory
  header, GET /session/:id (needed to open a session from the all-sessions
  list), GET /provider variants for VariantPicker, and an optional
  --seed-sessions mode that pre-populates two sessions across two
  directories. --fail-auth mode is untouched.
- .maestro/flows/directory-picker.yaml, all-sessions.yaml,
  variant-picker.yaml: three new flows, run in the same emulator session
  as the existing activation flows.
- Additive testIDs on DirectoryBrowserSheet, the "Browse Folders" row,
  session list rows, the variant chip, and VariantPicker rows.
- .github/workflows/activation-e2e.yml: two more mock server instances
  (4098 seeded, 4099 fresh) and three more maestro test steps.

Verified: tsc --noEmit clean, all 108 existing unit tests pass, every new
mock endpoint curled against its real shape read from the app code, YAML
validated. No Android emulator available locally to run the Maestro flows
themselves.

* test(mock): enforce per-directory session scoping so #46/#48 coverage can fail

Review finding (HIGH): GET /session/:id and /session/:id/message ignored
x-opencode-directory, so all-sessions.yaml could not fail if the directory
threading fix regressed. The mock now mirrors the real server's per-directory
workspace scoping:

- GET /session/:id and GET /session/:id/message 404 unless the request's
  x-opencode-directory (or DEFAULT_DIRECTORY when absent) matches the stored
  session's directory.
- GET /session without ?roots=true is scoped to the request's directory;
  loadSessions()'s directory-less roots=true call still returns everything.
- Document the port-4099 shared-state coupling between directory-picker and
  variant-picker flows, and why all-sessions.yaml now has teeth (flow comment).

Curl-verified: correct header 200, wrong/no header 404, scoped vs roots
listing, create-then-open paths for all three flows, --fail-auth untouched.
tsc clean, 108/108 unit tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKAQ6HAikWGQK7PGZ5Y4E

* fix(e2e): widen connect-handshake wait past client's own 30s timeout

Run 29546383612 (7cbd3a6, first real emulator execution of these flows)
failed on activation-positive.yaml: "Assert that id: connection-status-dot
is visible" timed out after the flow's 20s extendedWaitUntil, right after
tapOn connect-submit-button.

The mock server itself is fast (verified locally: health + project/current
+ path respond in ~30ms total), so this isn't a mock fidelity gap. But
Quick Connect's testConnection()/addConnection() path chains up to 3
fetches (health, then project.current + path.get in parallel), and each
individual fetch is capped by src/lib/sdk.ts REQUEST_TIMEOUT_MS = 30_000 —
strictly longer than the 20s the flow was willing to wait. A first-attempt
emulator-to-host (10.0.2.2) connection that's merely slow to establish,
rather than outright failing, would blow past the test's wait before the
app's own client-side timeout even fires.

Bump the connect -> connection-status-dot / "Connection Failed" waits from
20000 to 40000 across all 5 flows that share this pattern
(activation-positive, activation-negative-401, all-sessions,
directory-picker, variant-picker) so the wait is never shorter than the
code path it's gating on. Assertions are unchanged — still requires the
real dot / real error text, just with a timeout that isn't racing the
client.

Verified locally: typecheck clean, all 108 unit tests pass, YAML parses,
mock server confirmed fast under direct curl. Emulator behavior itself
(whether 40s consistently clears it) is unverified until the next CI run.

* fix(e2e): use adb reverse + 127.0.0.1 instead of 10.0.2.2; capture logcat/maestro debug

Root cause of the activation-e2e failure (connect step timed out, ~0 requests
reaching the mock): the 10.0.2.2 host alias is unreliable under the headless
emulator-runner — the app's http://10.0.2.2:4096/global/health never completed,
so connection-status-dot never rendered.

- run-e2e-flows.sh: single script (fixes cd-per-line fragility) that adb-reverses
  each mock port (4096-4099) into the emulator's localhost, runs every flow with
  --debug-output, and dumps logcat on exit.
- All flows now connect to 127.0.0.1:<port> (the adb reverse target).
- Upload maestro-debug (UI hierarchy on failure) + logcat as artifacts so future
  failures are diagnosable instead of blind.

* fix(e2e): connect via 127.0.0.1:PORT in IP field, stop typing into port input

Root cause of every activation-e2e connect failure (proven by the app's own
logcat diagnostic: '[diag] probe start http://127.0.0.1:40966 ... server
unreachable'): the port field defaults to useState("4096"), and the flow's
eraseText + inputText "4096" raced the controlled number-pad input, leaving
"40966" — nothing listens there, so connect always failed. This was never a
10.0.2.2 / adb reverse issue.

Fix: buildUrl already extracts host:port from the IP field, so enter
127.0.0.1:<port> there and remove the flaky port-field steps entirely.
pastedPort overrides the default port state, so each flow's port is
deterministic (4096 positive / 4097 negative / 4098 all-sessions / 4099
directory+variant).

---------

Co-authored-by: engineer <engineer@gray-knight-m1.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:55:25 -07:00

342 lines
10 KiB
TypeScript

import { useCallback, useRef, useState } from "react"
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from "react-native"
import { Ionicons } from "@expo/vector-icons"
import BottomSheet, { BottomSheetBackdrop, BottomSheetFlatList, BottomSheetTextInput } from "@gorhom/bottom-sheet"
import type { Client, FileEntry } from "../../lib/sdk"
import { parentOf, nameOf } from "../../lib/path-utils"
interface Props {
sheetRef: React.RefObject<BottomSheet | null>
// Directory to start browsing from whenever the sheet opens (project root, server home, etc).
startDirectory: string | null
// Builds a client rooted at an arbitrary absolute directory (see connections store).
clientForDirectory: (directory: string) => Client | null
isDark: boolean
// Called with the chosen absolute directory when the user taps "Use this folder".
onSelect: (directory: string) => void
// Called whenever the sheet fully closes (selection or cancel).
onDismiss?: () => void
}
export function DirectoryBrowserSheet({
sheetRef,
startDirectory,
clientForDirectory,
isDark,
onSelect,
onDismiss,
}: Props) {
const [browseDir, setBrowseDir] = useState<string | null>(null)
const [entries, setEntries] = useState<FileEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [jumpPath, setJumpPath] = useState("")
const loadToken = useRef(0)
const load = useCallback(
(dir: string) => {
const client = clientForDirectory(dir)
const token = ++loadToken.current
setLoading(true)
setError(null)
if (!client) {
setEntries([])
setLoading(false)
setError("No active connection")
return
}
client.file
.list({ path: "." })
.then((items) => {
if (loadToken.current !== token) return
setEntries(items.filter((item) => item.type === "directory"))
})
.catch((err) => {
if (loadToken.current !== token) return
setEntries([])
setError(err instanceof Error ? err.message : "Failed to list directory")
})
.finally(() => {
if (loadToken.current === token) setLoading(false)
})
},
[clientForDirectory],
)
const enter = useCallback(
(dir: string) => {
setBrowseDir(dir)
load(dir)
},
[load],
)
// Reset to the starting directory when the sheet transitions from closed
// to open (not on drags between snap points), and notify on full close.
const wasOpen = useRef(false)
const handleSheetChange = useCallback(
(index: number) => {
if (index < 0) {
wasOpen.current = false
onDismiss?.()
return
}
if (wasOpen.current) return // snap-point change while already open
wasOpen.current = true
setJumpPath("")
if (startDirectory) {
enter(startDirectory)
} else {
// No starting directory known (e.g. server home not loaded yet):
// show an explicit empty state instead of a previous open's entries.
loadToken.current++
setBrowseDir(null)
setEntries([])
setError(null)
setLoading(false)
}
},
[startDirectory, enter, onDismiss],
)
const goUp = useCallback(() => {
if (!browseDir) return
const parent = parentOf(browseDir)
if (!parent) return
enter(parent)
}, [browseDir, enter])
const goJump = useCallback(() => {
const dir = jumpPath.trim()
if (!dir) return
setJumpPath("")
enter(dir)
}, [jumpPath, enter])
const handleUseFolder = useCallback(() => {
if (!browseDir) return
onSelect(browseDir)
sheetRef.current?.close()
}, [browseDir, onSelect, sheetRef])
const canGoUp = !!browseDir && !!parentOf(browseDir)
return (
<BottomSheet
ref={sheetRef}
index={-1}
snapPoints={["65%", "92%"]}
enablePanDownToClose
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
android_keyboardInputMode="adjustResize"
backgroundStyle={isDark ? s.sheetDark : s.sheet}
handleIndicatorStyle={{ backgroundColor: isDark ? "#666666" : "#cccccc" }}
backdropComponent={(props) => (
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.5} />
)}
onChange={handleSheetChange}
>
<View style={s.header}>
<Text style={[s.title, isDark && s.white]}>Browse Folders</Text>
<View style={s.pathRow}>
<TouchableOpacity onPress={goUp} disabled={!canGoUp} hitSlop={8} testID="directory-up-button">
<Ionicons
name="arrow-up-circle-outline"
size={22}
color={canGoUp ? (isDark ? "#8b5cf6" : "#6d28d9") : isDark ? "#3a3a3a" : "#dddddd"}
/>
</TouchableOpacity>
<Text style={[s.path, isDark && s.dimDark]} numberOfLines={1} ellipsizeMode="head">
{browseDir || "…"}
</Text>
</View>
</View>
<View style={s.inputWrap}>
<BottomSheetTextInput
style={[s.input, isDark && s.inputDark]}
placeholder="Jump to path..."
placeholderTextColor={isDark ? "#666666" : "#999999"}
value={jumpPath}
onChangeText={setJumpPath}
onSubmitEditing={goJump}
returnKeyType="go"
autoCapitalize="none"
autoCorrect={false}
testID="directory-jump-input"
/>
{jumpPath.trim() && (
<TouchableOpacity style={[s.goBtn, isDark && s.goBtnDark]} onPress={goJump}>
<Ionicons name="arrow-forward" size={18} color={isDark ? "#0a0a0a" : "#ffffff"} />
</TouchableOpacity>
)}
</View>
<BottomSheetFlatList
data={entries}
keyExtractor={(item: FileEntry) => item.absolute}
renderItem={({ item }: { item: FileEntry }) => (
<TouchableOpacity
style={[s.row, isDark && s.rowDark]}
onPress={() => enter(item.absolute)}
testID={`directory-row-${item.name}`}
>
<Ionicons
name="folder-outline"
size={20}
color={item.ignored ? (isDark ? "#555555" : "#bbbbbb") : isDark ? "#888888" : "#666666"}
/>
<Text style={[s.rowLabel, isDark && s.white, item.ignored && s.rowLabelDim]} numberOfLines={1}>
{item.name}
</Text>
<Ionicons name="chevron-forward" size={16} color={isDark ? "#555555" : "#cccccc"} />
</TouchableOpacity>
)}
contentContainerStyle={s.list}
ListHeaderComponent={
loading ? (
<View style={s.centerBox}>
<ActivityIndicator color={isDark ? "#ffffff" : "#0a0a0a"} />
</View>
) : error ? (
<View style={s.centerBox}>
<Text style={s.errorText}>{error}</Text>
</View>
) : null
}
ListEmptyComponent={
!loading && !error ? (
<Text style={[s.emptyText, isDark && s.dimDark]}>
{browseDir ? "No subfolders here" : "Enter a path above to start browsing"}
</Text>
) : null
}
/>
<View style={s.footer}>
<TouchableOpacity
style={[s.selectBtn, isDark && s.selectBtnDark, !browseDir && s.selectBtnDisabled]}
onPress={handleUseFolder}
disabled={!browseDir}
testID="directory-select-button"
>
<Ionicons name="checkmark-circle" size={18} color={isDark ? "#0a0a0a" : "#ffffff"} />
<Text style={[s.selectBtnText, isDark && s.selectBtnTextDark]} numberOfLines={1}>
Use {browseDir ? nameOf(browseDir) : "this folder"}
</Text>
</TouchableOpacity>
</View>
</BottomSheet>
)
}
const s = StyleSheet.create({
sheet: { backgroundColor: "#ffffff" },
sheetDark: { backgroundColor: "#1a1a1a" },
header: { paddingHorizontal: 16, paddingBottom: 8, gap: 8 },
title: { fontSize: 18, fontWeight: "700", color: "#0a0a0a" },
white: { color: "#ffffff" },
pathRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
path: {
flex: 1,
fontSize: 12,
color: "#666666",
},
dimDark: { color: "#888888" },
inputWrap: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingBottom: 8,
gap: 8,
},
input: {
flex: 1,
height: 40,
borderRadius: 10,
paddingHorizontal: 12,
backgroundColor: "#f5f5f5",
color: "#0a0a0a",
fontSize: 14,
},
inputDark: {
backgroundColor: "#2a2a2a",
color: "#ffffff",
},
goBtn: {
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: "#0a0a0a",
alignItems: "center",
justifyContent: "center",
},
goBtnDark: { backgroundColor: "#ffffff" },
list: {
paddingHorizontal: 16,
paddingBottom: 8,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingHorizontal: 14,
paddingVertical: 11,
borderRadius: 10,
backgroundColor: "#f5f5f5",
marginBottom: 6,
},
rowDark: { backgroundColor: "#2a2a2a" },
rowLabel: {
flex: 1,
fontSize: 14,
fontWeight: "600",
color: "#0a0a0a",
},
rowLabelDim: { color: "#999999" },
centerBox: {
paddingVertical: 24,
alignItems: "center",
},
errorText: {
fontSize: 13,
color: "#ef4444",
textAlign: "center",
paddingHorizontal: 16,
},
emptyText: {
fontSize: 13,
color: "#999999",
textAlign: "center",
paddingVertical: 24,
},
footer: {
paddingHorizontal: 16,
paddingVertical: 12,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#e5e5e5",
},
selectBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
height: 46,
borderRadius: 12,
backgroundColor: "#0a0a0a",
},
selectBtnDark: { backgroundColor: "#ffffff" },
selectBtnDisabled: { opacity: 0.5 },
selectBtnText: {
fontSize: 15,
fontWeight: "600",
color: "#ffffff",
},
selectBtnTextDark: { color: "#0a0a0a" },
})