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>
This commit is contained in:
6
.github/workflows/activation-e2e.yml
vendored
6
.github/workflows/activation-e2e.yml
vendored
@@ -5,6 +5,12 @@ name: Activation E2E (Maestro)
|
||||
# receive reply), including the connect-time-401 negative case tied to the
|
||||
# 0%-7-day-retention / GitHub issue #76 investigation.
|
||||
#
|
||||
# Also covers newer surfaces merged after the initial activation suite:
|
||||
# DirectoryBrowserSheet's server-folder picker, the directory-less
|
||||
# "all sessions across all projects" list (+ the #46/#48 open-across-project
|
||||
# regression), and VariantPicker's reasoning-effort chip. See
|
||||
# .maestro/flows/directory-picker.yaml, all-sessions.yaml, variant-picker.yaml.
|
||||
#
|
||||
# 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
|
||||
|
||||
84
.maestro/flows/all-sessions.yaml
Normal file
84
.maestro/flows/all-sessions.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
appId: cc.agentlabs.opencode
|
||||
name: All sessions across directories - list + open-across-project (regression #46/#48)
|
||||
---
|
||||
# New-feature regression coverage for the "all sessions across all projects"
|
||||
# list: src/stores/sessions.ts loadSessions() intentionally uses
|
||||
# clientForDirectory(undefined) (a directory-less client) so GET /session
|
||||
# returns sessions from every directory, not just the active connection's.
|
||||
#
|
||||
# Also locks in the fix for GitHub issues #46/#48: tapping a session that
|
||||
# belongs to a directory OTHER than the active connection's must actually
|
||||
# open it (src/stores/sessions.ts selectSession uses clientForDirectory(dir)
|
||||
# when the session's directory differs from the active one), not silently
|
||||
# fail or open the wrong session.
|
||||
#
|
||||
# The CI job starts `node tests/fixtures/mock-opencode-server.ts --port 4098
|
||||
# --seed-sessions` on the runner host BEFORE this flow runs. --seed-sessions
|
||||
# pre-populates two fixed-ID sessions before any connection is made:
|
||||
# seed-default -> directory /mock/project (matches the active
|
||||
# connection's default project — GET /project/current)
|
||||
# seed-other -> directory /mock/project/other-dir (a DIFFERENT directory)
|
||||
#
|
||||
# Why this can actually FAIL if #46/#48 regresses: the mock ENFORCES
|
||||
# per-directory scoping — GET /session/seed-other and
|
||||
# GET /session/seed-other/message return 404 unless the request carries
|
||||
# x-opencode-directory: /mock/project/other-dir. With the fix present, the
|
||||
# app threads the session's directory through selectSession ->
|
||||
# clientForDirectory, sends the right header, and the session screen renders.
|
||||
# Without it, the request goes out with the wrong (or no) directory header,
|
||||
# the mock 404s, and the session-screen assertions below (title, directory
|
||||
# badge, empty state) fail.
|
||||
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- assertVisible:
|
||||
id: "telemetry-consent-card"
|
||||
- tapOn:
|
||||
id: "telemetry-decline-button"
|
||||
|
||||
- assertVisible:
|
||||
text: "No Connection"
|
||||
- tapOn:
|
||||
id: "add-connection-button"
|
||||
- tapOn:
|
||||
id: "connect-ip-input"
|
||||
- inputText: "127.0.0.1:4098"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "connect-submit-button"
|
||||
|
||||
# 40s margin: see activation-positive.yaml — the connect handshake's own
|
||||
# fetches are individually capped at 30s (src/lib/sdk.ts REQUEST_TIMEOUT_MS).
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "connection-status-dot"
|
||||
timeout: 40000
|
||||
- takeScreenshot: allsessions-S1_connected
|
||||
|
||||
# Both seeded sessions must appear, even though only one belongs to the
|
||||
# active connection's own directory.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Default Project Session"
|
||||
timeout: 15000
|
||||
- assertVisible:
|
||||
text: "Cross-Project Session"
|
||||
- takeScreenshot: allsessions-S2_both_directories_listed
|
||||
|
||||
# Open the session from the OTHER directory (#46/#48 regression).
|
||||
- tapOn:
|
||||
id: "session-item-seed-other"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "chat-message-input"
|
||||
timeout: 15000
|
||||
- assertVisible:
|
||||
text: "Cross-Project Session"
|
||||
- assertVisible:
|
||||
text: "other-dir"
|
||||
# Confirms GET /session/seed-other/message actually resolved (empty state,
|
||||
# not stuck loading / errored) — i.e. the cross-directory session genuinely
|
||||
# opened, not just navigated to a dead screen.
|
||||
- assertVisible:
|
||||
text: "Start a conversation"
|
||||
- takeScreenshot: allsessions-S3_cross_directory_session_opened
|
||||
117
.maestro/flows/directory-picker.yaml
Normal file
117
.maestro/flows/directory-picker.yaml
Normal file
@@ -0,0 +1,117 @@
|
||||
appId: cc.agentlabs.opencode
|
||||
name: Directory picker - browse server folders and create a session in one
|
||||
---
|
||||
# New-feature regression coverage for DirectoryBrowserSheet
|
||||
# (src/components/chat/DirectoryBrowserSheet.tsx): New Session -> Browse
|
||||
# Folders -> the sheet lists the mock server's fake directory tree -> tapping
|
||||
# a folder navigates into it -> Up navigation returns to the parent -> tapping
|
||||
# "Use this folder" creates a session scoped to the chosen directory.
|
||||
#
|
||||
# The CI job starts `node tests/fixtures/mock-opencode-server.ts --port 4099`
|
||||
# (normal mode, no --fail-auth / --seed-sessions) on the runner host BEFORE
|
||||
# this flow runs. Its fake filesystem (FAKE_FILE_TREE in
|
||||
# tests/fixtures/mock-opencode-server.ts) is:
|
||||
# /mock/project -> frontend/ (dir), backend/ (dir), README.md (file)
|
||||
# /mock/project/frontend -> (empty)
|
||||
# /mock/project/backend -> (empty)
|
||||
# and GET /project (FAKE_SERVER_PROJECTS) additionally lists a "docs-project"
|
||||
# server-known project, exercised as a secondary assertion below.
|
||||
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- assertVisible:
|
||||
id: "telemetry-consent-card"
|
||||
- tapOn:
|
||||
id: "telemetry-decline-button"
|
||||
|
||||
- assertVisible:
|
||||
text: "No Connection"
|
||||
- tapOn:
|
||||
id: "add-connection-button"
|
||||
- tapOn:
|
||||
id: "connect-ip-input"
|
||||
- inputText: "127.0.0.1:4099"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "connect-submit-button"
|
||||
|
||||
# 40s margin: see activation-positive.yaml — the connect handshake's own
|
||||
# fetches are individually capped at 30s (src/lib/sdk.ts REQUEST_TIMEOUT_MS).
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "connection-status-dot"
|
||||
timeout: 40000
|
||||
- takeScreenshot: dirpicker-S1_connected
|
||||
|
||||
# Open the New Session modal (long-press the FAB for the full options sheet)
|
||||
- longPressOn:
|
||||
id: "new-session-fab"
|
||||
- assertVisible:
|
||||
text: "New Session"
|
||||
# GET /project surfaces "docs-project" under "Server Projects" (excludes the
|
||||
# current project, mock-project) — confirms the project-list endpoint wired up.
|
||||
# The modal fetches it async on open, so wait rather than assert immediately.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "docs-project"
|
||||
timeout: 10000
|
||||
- takeScreenshot: dirpicker-S2_new_session_modal
|
||||
|
||||
# Open the folder browser
|
||||
- tapOn:
|
||||
id: "browse-folders-button"
|
||||
- assertVisible:
|
||||
text: "Browse Folders"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "directory-row-frontend"
|
||||
timeout: 10000
|
||||
- assertVisible:
|
||||
id: "directory-row-backend"
|
||||
# README.md is a FILE, not a directory — DirectoryBrowserSheet filters entries
|
||||
# to type === "directory", so it must never appear as a row.
|
||||
- assertNotVisible:
|
||||
text: "README.md"
|
||||
- takeScreenshot: dirpicker-S3_root_listing
|
||||
|
||||
# Navigate into "frontend"
|
||||
- tapOn:
|
||||
id: "directory-row-frontend"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "No subfolders here"
|
||||
timeout: 10000
|
||||
- takeScreenshot: dirpicker-S4_inside_frontend
|
||||
|
||||
# Up navigation must return to the root listing
|
||||
- tapOn:
|
||||
id: "directory-up-button"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "directory-row-frontend"
|
||||
timeout: 10000
|
||||
- assertVisible:
|
||||
id: "directory-row-backend"
|
||||
- takeScreenshot: dirpicker-S5_up_navigation_back_to_root
|
||||
|
||||
# Navigate into "backend" and select it
|
||||
- tapOn:
|
||||
id: "directory-row-backend"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "No subfolders here"
|
||||
timeout: 10000
|
||||
- takeScreenshot: dirpicker-S6_inside_backend
|
||||
- tapOn:
|
||||
id: "directory-select-button"
|
||||
|
||||
# The app creates a session scoped to /mock/project/backend (mock POST
|
||||
# /session reads x-opencode-directory) and navigates straight to it — the
|
||||
# native header's directory badge must show "backend".
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "chat-message-input"
|
||||
timeout: 15000
|
||||
- assertVisible:
|
||||
text: "backend"
|
||||
- takeScreenshot: dirpicker-S7_session_created_in_backend
|
||||
102
.maestro/flows/variant-picker.yaml
Normal file
102
.maestro/flows/variant-picker.yaml
Normal file
@@ -0,0 +1,102 @@
|
||||
appId: cc.agentlabs.opencode
|
||||
name: Variant picker - reasoning-effort chip renders, selects, and still sends
|
||||
---
|
||||
# New-feature regression coverage for VariantPicker
|
||||
# (src/components/chat/VariantPicker.tsx): the reasoning-effort chip only
|
||||
# renders when the selected model's provider entry carries `variants`
|
||||
# (src/stores/catalog.ts / GET /provider), selecting an option updates the
|
||||
# chip label, and sending a message afterwards still works end-to-end
|
||||
# (variant flows into sendMessage -> client.session.prompt's `variant` field
|
||||
# — see src/stores/sessions.ts sendMessage).
|
||||
#
|
||||
# The CI job starts `node tests/fixtures/mock-opencode-server.ts --port 4099`
|
||||
# on the runner host BEFORE this flow runs (same fresh, non-seeded normal-mode
|
||||
# server used by directory-picker.yaml — GET /provider's mock-model carries
|
||||
# low/medium/high variants; see tests/fixtures/mock-opencode-server.ts).
|
||||
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- assertVisible:
|
||||
id: "telemetry-consent-card"
|
||||
- tapOn:
|
||||
id: "telemetry-decline-button"
|
||||
|
||||
- assertVisible:
|
||||
text: "No Connection"
|
||||
- tapOn:
|
||||
id: "add-connection-button"
|
||||
- tapOn:
|
||||
id: "connect-ip-input"
|
||||
- inputText: "127.0.0.1:4099"
|
||||
- hideKeyboard
|
||||
- tapOn:
|
||||
id: "connect-submit-button"
|
||||
|
||||
# 40s margin: see activation-positive.yaml — the connect handshake's own
|
||||
# fetches are individually capped at 30s (src/lib/sdk.ts REQUEST_TIMEOUT_MS).
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "connection-status-dot"
|
||||
timeout: 40000
|
||||
- takeScreenshot: variant-S1_connected
|
||||
|
||||
- tapOn:
|
||||
id: "new-session-fab"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "chat-message-input"
|
||||
timeout: 15000
|
||||
|
||||
# The chip only appears once catalog.load() (triggered on connect, see
|
||||
# app/_layout.tsx) has resolved GET /provider and found variants for the
|
||||
# selected model — wait rather than assert immediately.
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "variant-chip"
|
||||
timeout: 15000
|
||||
- assertVisible:
|
||||
text: "Auto"
|
||||
- takeScreenshot: variant-S2_chip_default_auto
|
||||
|
||||
- tapOn:
|
||||
id: "variant-chip"
|
||||
- assertVisible:
|
||||
text: "Reasoning Effort"
|
||||
- assertVisible:
|
||||
id: "variant-option-low"
|
||||
- assertVisible:
|
||||
id: "variant-option-medium"
|
||||
- assertVisible:
|
||||
id: "variant-option-high"
|
||||
- takeScreenshot: variant-S3_picker_options
|
||||
|
||||
- tapOn:
|
||||
id: "variant-option-high"
|
||||
# Sheet closes and the chip label reflects the new selection.
|
||||
- assertVisible:
|
||||
text: "High"
|
||||
- takeScreenshot: variant-S4_chip_shows_high
|
||||
|
||||
# Sending a message must still work with a variant selected (regression: the
|
||||
# variant chip must not break the send path).
|
||||
- tapOn:
|
||||
id: "chat-message-input"
|
||||
- inputText: "Message with reasoning effort set to high"
|
||||
- hideKeyboard
|
||||
- takeScreenshot: variant-S5_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: "Message with reasoning effort set to high"
|
||||
# The chip must still read "High" after the round trip (selection persists
|
||||
# across a send, it isn't reset by the reply landing).
|
||||
- assertVisible:
|
||||
text: "High"
|
||||
- takeScreenshot: variant-S6_reply_received_variant_still_high
|
||||
@@ -74,6 +74,7 @@ function SessionItem({
|
||||
style={[styles.sessionItem, isDark && styles.sessionItemDark]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
testID={`session-item-${session.id}`}
|
||||
>
|
||||
<View style={styles.sessionContent}>
|
||||
<View style={styles.sessionHeader}>
|
||||
@@ -656,6 +657,7 @@ export default function SessionsScreen() {
|
||||
openBrowser(currentProject?.path?.absolute || activeConnection?.directory || null, "create")
|
||||
}
|
||||
disabled={isCreating}
|
||||
testID="browse-folders-button"
|
||||
>
|
||||
<Ionicons name="folder-open-outline" size={18} color={isDark ? "#8b5cf6" : "#6d28d9"} />
|
||||
<View style={styles.projectRowContent}>
|
||||
|
||||
@@ -722,6 +722,7 @@ export default function SessionScreen() {
|
||||
<TouchableOpacity
|
||||
style={[s.variantChip, isDark && s.variantChipDark, variant && s.variantChipActive]}
|
||||
onPress={() => variantSheetRef.current?.expand()}
|
||||
testID="variant-chip"
|
||||
>
|
||||
<Ionicons name="flash-outline" size={14} color={variant ? "#8b5cf6" : isDark ? "#888888" : "#666666"} />
|
||||
<Text style={[s.variantLabel, isDark && s.metaDark, variant && s.variantLabelActive]} numberOfLines={1}>
|
||||
|
||||
@@ -11,9 +11,7 @@ set -uo pipefail
|
||||
|
||||
ROOT="$(pwd)" # capture BEFORE any cd, so diag paths are absolute
|
||||
APK="android/app/build/outputs/apk/release/app-release.apk"
|
||||
# Only the flows that exist on main. directory-picker/all-sessions/variant-picker
|
||||
# land with the test/e2e-new-features PR.
|
||||
FLOWS=(activation-positive activation-negative-401)
|
||||
FLOWS=(activation-positive activation-negative-401 directory-picker all-sessions variant-picker)
|
||||
mkdir -p "$ROOT/artifacts/screenshots" "$ROOT/artifacts/diag"
|
||||
|
||||
echo "== installing APK =="
|
||||
|
||||
@@ -140,7 +140,7 @@ export function DirectoryBrowserSheet({
|
||||
<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}>
|
||||
<TouchableOpacity onPress={goUp} disabled={!canGoUp} hitSlop={8} testID="directory-up-button">
|
||||
<Ionicons
|
||||
name="arrow-up-circle-outline"
|
||||
size={22}
|
||||
@@ -164,6 +164,7 @@ export function DirectoryBrowserSheet({
|
||||
returnKeyType="go"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
testID="directory-jump-input"
|
||||
/>
|
||||
{jumpPath.trim() && (
|
||||
<TouchableOpacity style={[s.goBtn, isDark && s.goBtnDark]} onPress={goJump}>
|
||||
@@ -176,7 +177,11 @@ export function DirectoryBrowserSheet({
|
||||
data={entries}
|
||||
keyExtractor={(item: FileEntry) => item.absolute}
|
||||
renderItem={({ item }: { item: FileEntry }) => (
|
||||
<TouchableOpacity style={[s.row, isDark && s.rowDark]} onPress={() => enter(item.absolute)}>
|
||||
<TouchableOpacity
|
||||
style={[s.row, isDark && s.rowDark]}
|
||||
onPress={() => enter(item.absolute)}
|
||||
testID={`directory-row-${item.name}`}
|
||||
>
|
||||
<Ionicons
|
||||
name="folder-outline"
|
||||
size={20}
|
||||
@@ -214,6 +219,7 @@ export function DirectoryBrowserSheet({
|
||||
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}>
|
||||
|
||||
@@ -67,6 +67,7 @@ export function VariantPicker({ variants, selected, isDark, onSelect, sheetRef }
|
||||
<TouchableOpacity
|
||||
style={[s.row, isDark && s.rowDark, active && (isDark ? s.rowSelectedDark : s.rowSelected)]}
|
||||
onPress={() => handleSelect(item.id)}
|
||||
testID={`variant-option-${item.id ?? "auto"}`}
|
||||
>
|
||||
<View style={s.rowText}>
|
||||
<Text style={[s.rowName, isDark && s.textWhite]}>{item.label}</Text>
|
||||
|
||||
185
tests/fixtures/mock-opencode-server.ts
vendored
185
tests/fixtures/mock-opencode-server.ts
vendored
@@ -25,6 +25,37 @@
|
||||
// message.part.updated) before the canned assistant reply, and returns
|
||||
// it from GET /session/:id/message.
|
||||
//
|
||||
// Also implements the surface the newer flows need (DirectoryBrowserSheet,
|
||||
// all-sessions across directories, VariantPicker — see
|
||||
// .maestro/flows/directory-picker.yaml / all-sessions.yaml / variant-picker.yaml):
|
||||
// - GET /file (directory-scoped via the x-opencode-directory header, NOT the
|
||||
// literal ?path= query — see src/lib/headers.ts) -> FAKE_FILE_TREE below.
|
||||
// - GET /project -> FAKE_SERVER_PROJECTS, for the "Server Projects" section.
|
||||
// - POST /session honors x-opencode-directory so sessions can be created in
|
||||
// a browsed/picked folder.
|
||||
// - GET /session/:id, needed to open a session from the directory-less
|
||||
// all-sessions list (src/stores/sessions.ts loadSessions/selectSession),
|
||||
// including sessions the client never itself created.
|
||||
// - Per-directory workspace scoping is ENFORCED (like the real server):
|
||||
// GET /session/:id and GET /session/:id/message 404 unless the request's
|
||||
// x-opencode-directory (or DEFAULT_DIRECTORY when absent) matches the
|
||||
// session's own directory, and GET /session without ?roots=true only
|
||||
// lists the request directory's sessions. This is what gives
|
||||
// all-sessions.yaml teeth as a #46/#48 regression test — an app that
|
||||
// stops threading the session's directory gets 404s, not silent passes.
|
||||
// - GET /provider's mock-model carries `variants` (low/medium/high) so
|
||||
// VariantPicker has options to render.
|
||||
//
|
||||
// Shared-state note: in CI (.github/workflows/activation-e2e.yml) the
|
||||
// instance on port 4099 is shared by directory-picker.yaml and then
|
||||
// variant-picker.yaml (run sequentially in the same emulator session).
|
||||
// State persists across flows — e.g. the session directory-picker creates in
|
||||
// /mock/project/backend still exists when variant-picker runs. That is
|
||||
// harmless today (variant-picker creates its own quick session and never
|
||||
// asserts on list contents), but keep it in mind when adding assertions
|
||||
// about "how many sessions exist" to either flow — or give a new flow its
|
||||
// own port instead.
|
||||
//
|
||||
// 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.
|
||||
@@ -36,6 +67,7 @@
|
||||
// Usage:
|
||||
// node tests/fixtures/mock-opencode-server.ts --port 4096
|
||||
// node tests/fixtures/mock-opencode-server.ts --port 4097 --fail-auth
|
||||
// node tests/fixtures/mock-opencode-server.ts --port 4098 --seed-sessions
|
||||
|
||||
import http from "node:http"
|
||||
import { randomUUID } from "node:crypto"
|
||||
@@ -48,6 +80,14 @@ export interface MockServerOptions {
|
||||
replyText?: string
|
||||
/** Delay before the canned reply is pushed over SSE, in ms. */
|
||||
replyDelayMs?: number
|
||||
/**
|
||||
* Pre-populate two sessions in two different directories at startup
|
||||
* (used by .maestro/flows/all-sessions.yaml to test the directory-less
|
||||
* "all sessions across all projects" list — see src/stores/sessions.ts
|
||||
* loadSessions()'s clientForDirectory(undefined) — and the cross-project
|
||||
* open regression for GitHub issues #46/#48).
|
||||
*/
|
||||
seedSessions?: boolean
|
||||
}
|
||||
|
||||
interface StoredSession {
|
||||
@@ -84,13 +124,81 @@ interface StoredMessage {
|
||||
|
||||
export const DEFAULT_REPLY_TEXT = "Hello from the mock opencode server — activation e2e canned reply."
|
||||
|
||||
// The directory a request is scoped to when the client sends no
|
||||
// x-opencode-directory header (i.e. a connection added without an explicit
|
||||
// directory). Mirrors the real server's notion of a default/current workspace.
|
||||
export const DEFAULT_DIRECTORY = "/mock/project"
|
||||
|
||||
// Resolve the workspace directory a request is scoped to. Directory-scoped
|
||||
// clients (src/stores/connections.ts clientForDirectory(dir)) send the
|
||||
// x-opencode-directory header (src/lib/headers.ts); directory-less clients
|
||||
// send none and fall back to DEFAULT_DIRECTORY.
|
||||
function requestDirectory(req: http.IncomingMessage): string {
|
||||
return (req.headers["x-opencode-directory"] as string | undefined) || DEFAULT_DIRECTORY
|
||||
}
|
||||
|
||||
// Fake server-side filesystem tree for DirectoryBrowserSheet
|
||||
// (src/components/chat/DirectoryBrowserSheet.tsx -> client.file.list({path: "."})
|
||||
// -> GET /file). The client always requests path=".", scoping to a directory
|
||||
// entirely via the x-opencode-directory header (src/lib/headers.ts) — so this
|
||||
// map is keyed by absolute directory, not by the literal query string.
|
||||
// Root has two subdirectories (for the picker + Up-navigation flow) plus one
|
||||
// regular file (to exercise DirectoryBrowserSheet's type === "directory" filter).
|
||||
export const FAKE_FILE_TREE: Record<string, Array<{ name: string; path: string; absolute: string; type: "file" | "directory"; ignored: boolean }>> = {
|
||||
"/mock/project": [
|
||||
{ name: "frontend", path: "frontend", absolute: "/mock/project/frontend", type: "directory", ignored: false },
|
||||
{ name: "backend", path: "backend", absolute: "/mock/project/backend", type: "directory", ignored: false },
|
||||
{ name: "README.md", path: "README.md", absolute: "/mock/project/README.md", type: "file", ignored: false },
|
||||
],
|
||||
"/mock/project/frontend": [],
|
||||
"/mock/project/backend": [],
|
||||
}
|
||||
|
||||
// Fake server-known projects (GET /project), consumed by the "Server Projects"
|
||||
// section of the New Session modal (app/(tabs)/index.tsx). "mock-project"
|
||||
// matches GET /project/current so the UI filters it out of this list.
|
||||
export const FAKE_SERVER_PROJECTS = [
|
||||
{ id: "mock-project", name: "mock-project", path: { cwd: "/mock/project", root: "/mock/project", absolute: "/mock/project" } },
|
||||
{
|
||||
id: "mock-project-docs",
|
||||
name: "docs-project",
|
||||
path: { cwd: "/mock/docs-project", root: "/mock/docs-project", absolute: "/mock/docs-project" },
|
||||
},
|
||||
]
|
||||
|
||||
export function createMockOpencodeServer(opts: MockServerOptions) {
|
||||
const { port, failAuth = false, replyText = DEFAULT_REPLY_TEXT, replyDelayMs = 300 } = opts
|
||||
const { port, failAuth = false, replyText = DEFAULT_REPLY_TEXT, replyDelayMs = 300, seedSessions = false } = opts
|
||||
|
||||
const sessions = new Map<string, StoredSession>()
|
||||
const messagesBySession = new Map<string, StoredMessage[]>()
|
||||
const sseClients = new Set<http.ServerResponse>()
|
||||
|
||||
if (seedSessions) {
|
||||
const now = Date.now()
|
||||
const seedDefault: StoredSession = {
|
||||
id: "seed-default",
|
||||
slug: "seed-def",
|
||||
projectID: "mock-project",
|
||||
directory: "/mock/project",
|
||||
title: "Default Project Session",
|
||||
version: "0.0.0-mock",
|
||||
time: { created: now - 120_000, updated: now - 120_000 },
|
||||
}
|
||||
const seedOther: StoredSession = {
|
||||
id: "seed-other",
|
||||
slug: "seed-oth",
|
||||
projectID: "mock-project-other",
|
||||
directory: "/mock/project/other-dir",
|
||||
title: "Cross-Project Session",
|
||||
version: "0.0.0-mock",
|
||||
time: { created: now - 60_000, updated: now - 60_000 },
|
||||
}
|
||||
sessions.set(seedDefault.id, seedDefault)
|
||||
messagesBySession.set(seedDefault.id, [])
|
||||
sessions.set(seedOther.id, seedOther)
|
||||
messagesBySession.set(seedOther.id, [])
|
||||
}
|
||||
|
||||
function broadcast(type: string, properties: Record<string, unknown>) {
|
||||
const line = `data: ${JSON.stringify({ type, properties })}\n\n`
|
||||
for (const res of sseClients) {
|
||||
@@ -207,7 +315,7 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
|
||||
})
|
||||
}
|
||||
if (method === "GET" && path === "/project") {
|
||||
return json(res, 200, [])
|
||||
return json(res, 200, FAKE_SERVER_PROJECTS)
|
||||
}
|
||||
if (method === "GET" && path === "/path") {
|
||||
return json(res, 200, {
|
||||
@@ -240,6 +348,13 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
|
||||
tool_call: false,
|
||||
limit: { context: 8000, output: 2000 },
|
||||
status: "active",
|
||||
// Reasoning-effort variants for VariantPicker
|
||||
// (src/components/chat/VariantPicker.tsx reads Object.keys(variants)).
|
||||
variants: {
|
||||
low: { reasoningEffort: "low" },
|
||||
medium: { reasoningEffort: "medium" },
|
||||
high: { reasoningEffort: "high" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -248,6 +363,14 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
|
||||
connected: ["mock"],
|
||||
})
|
||||
}
|
||||
|
||||
// Server-side filesystem browsing for DirectoryBrowserSheet. The client
|
||||
// always requests path="." (see src/lib/sdk.ts file.list) and scopes to a
|
||||
// directory via the x-opencode-directory header (src/lib/headers.ts).
|
||||
if (method === "GET" && path === "/file") {
|
||||
const dir = requestDirectory(req)
|
||||
return json(res, 200, FAKE_FILE_TREE[dir] || [])
|
||||
}
|
||||
if (method === "GET" && path === "/permission") {
|
||||
return json(res, 200, [])
|
||||
}
|
||||
@@ -271,11 +394,15 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
|
||||
if (method === "POST" && path === "/session") {
|
||||
const id = randomUUID()
|
||||
const now = Date.now()
|
||||
// Directory-scoped clients (connections store clientForDirectory()) send
|
||||
// the target directory via this header — used by the "create session in
|
||||
// a browsed/picked folder" flow (DirectoryBrowserSheet -> onCreateInDirectory).
|
||||
const directory = requestDirectory(req)
|
||||
const session: StoredSession = {
|
||||
id,
|
||||
slug: id.slice(0, 8),
|
||||
projectID: "mock-project",
|
||||
directory: "/mock/project",
|
||||
directory,
|
||||
title: "Mock Session",
|
||||
version: "0.0.0-mock",
|
||||
time: { created: now, updated: now },
|
||||
@@ -285,12 +412,53 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
|
||||
return json(res, 200, session)
|
||||
}
|
||||
if (method === "GET" && path === "/session") {
|
||||
return json(res, 200, Array.from(sessions.values()))
|
||||
// Directory-less "all sessions across all projects" list: the app's
|
||||
// loadSessions() (src/stores/sessions.ts) uses clientForDirectory(undefined)
|
||||
// — no x-opencode-directory header — and passes ?roots=true (src/lib/sdk.ts
|
||||
// session.list). Only that combination returns sessions from every
|
||||
// directory; otherwise the list is scoped to the request's directory, so
|
||||
// directory-scoped and directory-less clients are actually distinguishable.
|
||||
if (url.searchParams.get("roots") === "true") {
|
||||
return json(res, 200, Array.from(sessions.values()))
|
||||
}
|
||||
const dir = requestDirectory(req)
|
||||
return json(
|
||||
res,
|
||||
200,
|
||||
Array.from(sessions.values()).filter((s) => s.directory === dir),
|
||||
)
|
||||
}
|
||||
|
||||
// Single-session fetch (src/lib/sdk.ts session.get -> src/stores/sessions.ts
|
||||
// selectSession), used whenever a session from the all-sessions list (which
|
||||
// may belong to any directory) is opened — including sessions the client
|
||||
// never created itself (e.g. the seeded ones below).
|
||||
//
|
||||
// Directory ownership is ENFORCED, mirroring the real server's per-directory
|
||||
// workspace scoping: a session is only visible to a request scoped to the
|
||||
// session's own directory. This is what makes all-sessions.yaml real
|
||||
// regression coverage for #46/#48 — if the app stopped threading the
|
||||
// session's directory into clientFor()/clientForDirectory(), the request
|
||||
// would carry the wrong (or no) x-opencode-directory header and get a 404
|
||||
// here, and the flow's session-screen assertions would fail.
|
||||
const sessionGetMatch = path.match(/^\/session\/([^/]+)$/)
|
||||
if (method === "GET" && sessionGetMatch) {
|
||||
const sid = sessionGetMatch[1]
|
||||
const session = sessions.get(sid)
|
||||
if (!session || session.directory !== requestDirectory(req)) {
|
||||
return json(res, 404, { error: `unknown session ${sid}` })
|
||||
}
|
||||
return json(res, 200, session)
|
||||
}
|
||||
|
||||
const sessionMessageMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (method === "GET" && sessionMessageMatch) {
|
||||
const sid = sessionMessageMatch[1]
|
||||
const session = sessions.get(sid)
|
||||
// Same per-directory enforcement as GET /session/:id above.
|
||||
if (!session || session.directory !== requestDirectory(req)) {
|
||||
return json(res, 404, { error: `unknown session ${sid}` })
|
||||
}
|
||||
return json(res, 200, messagesBySession.get(sid) || [])
|
||||
}
|
||||
|
||||
@@ -347,11 +515,12 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): { port: number; failAuth: boolean } {
|
||||
const opts = { port: 4096, failAuth: false }
|
||||
function parseArgs(argv: string[]): { port: number; failAuth: boolean; seedSessions: boolean } {
|
||||
const opts = { port: 4096, failAuth: false, seedSessions: 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
|
||||
else if (argv[i] === "--seed-sessions") opts.seedSessions = true
|
||||
}
|
||||
return opts
|
||||
}
|
||||
@@ -363,7 +532,9 @@ 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})`)
|
||||
console.log(
|
||||
`[mock-opencode-server] listening on ${mock.url} (failAuth=${opts.failAuth}, seedSessions=${opts.seedSessions})`,
|
||||
)
|
||||
})
|
||||
const shutdown = () => {
|
||||
mock.close().then(() => process.exit(0))
|
||||
|
||||
Reference in New Issue
Block a user