Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [0.5.1] - 2026-03-26

### Added

- Pre-shared restart token (`probe.set_next_token`) — CLI sends a token to the agent before `restart the app`; agent persists it and uses it after restart, enabling WiFi reconnection without `idevicesyslog`
- `--host` flag for WiFi testing — connect directly to device IP, no iproxy needed
- `--token` flag to skip USB-dependent token auto-detection
- `PROBE_WIFI=true` dart-define — binds agent to `0.0.0.0` for network access
- HTTP POST fallback transport (`POST /probe/rpc`) — stateless per-request communication for physical devices
- `ProbeClient` interface — both WebSocket and HTTP clients satisfy it for transport-agnostic execution
- `tap "X" if visible` ProbeScript syntax — silently skips when widget is not found; works with tap, type, clear, long press, double tap
- Direct `onTap` invocation fallback for `Semantics`-wrapped `GestureDetector` widgets on physical devices
- `take screenshot "name"` now accepts name directly (no `called` keyword needed)
- Physical device E2E test suite for FlutterProbe Test App (12 tests covering all 10 screens)

### Fixed

- `clear app data` on physical iOS now skips immediately (before confirmation prompt) to avoid killing the agent
- Connection error detection in `if visible` — propagates connection errors for auto-reconnect instead of silently swallowing them
- Screenshot parser accepts `take screenshot "name"` without requiring `called` keyword

## [0.5.0] - 2026-03-26

### Added

- Physical iOS device support: launch/terminate via `xcrun devicectl`, token reading via `idevicesyslog`, port forwarding via `iproxy`
- Physical Android device validation: `EnsureADB()` verifies binary, device reachability, and cleans stale port forwards
- Physical device detection: `IsPhysicalIOS` (simctl list check) and `IsPhysicalAndroid` (ro.hardware property check)
- Physical iOS devices listed in `probe device list` via `idevice_id`
- WebSocket ping/pong keepalive (5s interval) — prevents idle connection drops on physical devices via iproxy
- Auto-reconnect on WebSocket connection loss — up to 2 transparent retries per step with full re-dial
- `EnsureIProxy()` — automatic iproxy lifecycle management: checks installation, kills stale processes, starts fresh, defers cleanup
- Visibility filtering in widget finder — off-screen widgets (behind routes, Offstage, Visibility) no longer match `see`/`if appears`
- Unique pointer IDs for synthetic gestures — prevents collision with real touch events on physical devices
- ProbeAgent profile mode support — `ProbeAgent.start()` works in profile builds (required for physical iOS)
- ProbeAgent release mode safeguards — blocked by default, opt-in via `allowReleaseBuild: true` + `PROBE_AGENT_FORCE=true`
- Test files for all packages: `cmd/probe`, `internal/cli`, `internal/ios`, `internal/device` (manager tests)
- HTTP POST fallback transport (`POST /probe/rpc`) — stateless alternative to WebSocket for physical devices, eliminates persistent connection drops
- `ProbeClient` interface — both WebSocket `Client` and `HTTPClient` satisfy it, enabling transport-agnostic test execution
- WiFi testing mode (`--host <ip>` + `--token <token>` + `--dart-define=PROBE_WIFI=true`) — test physical devices without USB, no iproxy needed
- `tap "X" if visible` ProbeScript syntax — silently skips tap when widget is not found, replaces verbose dialog-dismissal recipes
- Direct `onTap` invocation fallback for `Semantics`-wrapped widgets — fixes tap failures on physical devices where synthetic gestures don't reach `GestureDetector`
- `take screenshot "name"` now accepts name directly (previously required `called` keyword)

### Changed

- Operations unsupported on physical devices now skip gracefully with warnings instead of crashing:
- `clear app data` on physical iOS → warning + skip
- `allow/deny permission` on physical iOS → warning + skip
- `set location` on any physical device → warning + skip
- `restart the app` on physical iOS uses `xcrun devicectl` instead of `simctl`
- iOS connection setup now branches: simulator path uses simctl permissions + loopback; physical path uses iproxy + idevicesyslog
- Android connection setup validates ADB availability and device state before port forwarding

## [0.4.2] - 2026-03-25

### Added
Expand Down
19 changes: 19 additions & 0 deletions cmd/probe/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package main

import (
"os"
"testing"
)

func TestMainBinaryExists(t *testing.T) {
// Verify the main package compiles (this test existing is the assertion).
// The actual CLI is tested via internal/cli tests.
}

func TestMainNotNilEntrypoint(t *testing.T) {
// Ensure we don't accidentally nil-deref on startup by checking
// that the os package is available (basic sanity check).
if os.Args == nil {
t.Fatal("os.Args is nil")
}
}
24 changes: 21 additions & 3 deletions docs/wiki/Architecture-Overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ FlutterProbe consists of two main components that communicate via WebSocket + JS
```
+------------------+ WebSocket (JSON-RPC 2.0) +------------------+
| Go CLI | <----------------------------------> | Dart Agent |
| (probe) | ws://127.0.0.1:48686 | (ProbeAgent) |
| (probe) | ws://127.0.0.1:48686 + ping/pong | (ProbeAgent) |
+------------------+ +------------------+
| | | |
| - Parser | | - Server |
Expand Down Expand Up @@ -35,8 +35,8 @@ The CLI is the orchestrator. It parses `.probe` test files, manages device conne
| `parser/` | Indent-aware lexer + recursive-descent parser producing AST |
| `runner/` | Test orchestration: loads files, walks AST, dispatches to ProbeLink |
| `probelink/` | JSON-RPC 2.0 WebSocket client |
| `device/` | ADB integration for Android (port forwarding, permissions, token extraction) |
| `ios/` | iOS simulator management via `xcrun simctl` |
| `device/` | ADB integration for Android, device detection (physical vs emulator), iproxy management |
| `ios/` | iOS simulator management via `xcrun simctl`, physical device management via `xcrun devicectl` |
| `config/` | `probe.yaml` parsing with layered resolution (CLI flag > config > default) |
| `report/` | HTML report generation with portable relative paths |
| `cloud/` | Cloud provider integration (BrowserStack, SauceLabs, AWS, Firebase, LambdaTest) |
Expand Down Expand Up @@ -75,6 +75,24 @@ The agent runs **inside the production Flutter app** using `WidgetsFlutterBindin
3. CLI connects: `ws://127.0.0.1:<port>/probe?token=<token>`
4. Fallback: parse token from `simctl spawn ... log show`

### iOS Physical Device (USB)

1. CLI detects physical device (UDID not in `simctl list`)
2. CLI kills stale `iproxy` processes, starts fresh `iproxy`
3. CLI reads token from `idevicesyslog`
4. CLI connects via HTTP POST: `POST http://127.0.0.1:<port>/probe/rpc?token=<token>`
5. Each command is an independent HTTP request (no persistent connection to drop)
6. Auto-reconnect on connection loss (up to 2 retries per step)
7. App lifecycle managed via `xcrun devicectl` (launch/terminate)

### iOS Physical Device (WiFi — recommended)

1. App built with `--dart-define=PROBE_WIFI=true` (binds to `0.0.0.0`)
2. CLI connects directly to device IP: `--host <ip> --token <token>`
3. No `iproxy` needed — direct HTTP POST over WiFi
4. Zero USB-C charge/data switching drops
5. App lifecycle managed via `xcrun devicectl`

### Cloud (Relay Mode)

1. CLI creates relay session (gets URL + token)
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Welcome to the FlutterProbe wiki. This documentation covers architecture details

## Project Status

FlutterProbe is in active development. Current version: **0.4.2**.
FlutterProbe is in active development. Current version: **0.5.1**.

### Repository Structure

Expand Down
27 changes: 27 additions & 0 deletions docs/wiki/Troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,33 @@ if (!probeEnabled) {
3. Read the token: `cat <container>/tmp/probe/token`
4. Ensure `PROBE_AGENT=true` was passed during build

### Physical iOS Device — Connection Drops via USB

**Cause**: USB-C cables cause intermittent drops when the device switches between charging and data transfer modes. This kills the iproxy tunnel.

**Fix**: Use **WiFi mode** instead of USB to eliminate drops entirely:
```bash
# Build with WiFi enabled
flutter build ios --profile --dart-define=PROBE_AGENT=true --dart-define=PROBE_WIFI=true

# Run over WiFi (find token in app console logs)
probe test tests/ --host <device-ip> --token <probe-token>
```

If you must use USB: FlutterProbe v0.5.0+ uses HTTP POST transport (not WebSocket) for physical devices, with auto-reconnect (up to 2 retries per step). Use a USB-A cable if available — no charge/data switching issue.

### Physical iOS Device — "devicectl launch: not installed"

**Cause**: Bundle ID mismatch between `probe.yaml` and the installed app. Debug builds may use a `.dev` suffix.

**Fix**: Check the installed bundle ID: `xcrun devicectl device info apps --device <UDID> | grep <appname>` and update `project.app` in `probe.yaml` to match.

### Physical iOS Device — `tap #key` Not Working

**Cause**: Widgets matched by `Semantics(identifier:)` may not forward gestures properly if pointer IDs collide with real touches.

**Fix**: Update to FlutterProbe v0.5.0+ which uses unique pointer IDs starting at 900 to avoid collisions.

## Android-Specific Issues

### Permission Dialogs Block Tests
Expand Down
81 changes: 81 additions & 0 deletions docs/wiki/iOS-Integration-Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,87 @@ DART_DEFINES=$(echo 'PROBE_AGENT=true' | base64)
xcodebuild ... GCC_PREPROCESSOR_DEFINITIONS='$(inherited)' DART_DEFINES="$DART_DEFINES"
```

## Physical iOS Devices

Physical device testing requires additional tools and has some limitations compared to simulator testing.

### Prerequisites

```bash
# Install libimobiledevice for device communication
brew install libimobiledevice

# Verify device is detected
idevice_id -l
```

### Build and Run

Physical iOS devices require **debug** or **profile** mode (release mode blocks ProbeAgent by default):

```bash
# Debug mode (USB-connected)
flutter run --debug --dart-define=PROBE_AGENT=true \
--device-id <UDID>

# Profile mode (better performance, no debug overhead)
flutter run --profile --dart-define=PROBE_AGENT=true \
--device-id <UDID>
```

### Connection Modes

**USB mode** (default):
1. FlutterProbe starts `iproxy` to forward port 48686 from host to device
2. CLI reads the agent token from `idevicesyslog`
3. Uses HTTP POST transport (stateless, no persistent connection to drop)

**WiFi mode** (recommended — no USB-C charging/data switching drops):
```bash
# Build with WiFi enabled
flutter build ios --profile --flavor <flavor> \
--dart-define=PROBE_AGENT=true \
--dart-define=PROBE_WIFI=true

# Run tests over WiFi (no USB cable needed)
probe test tests/ --host <device-ip> --token <probe-token> --device <UDID>
```

To find the token, check the app's console output for `PROBE_TOKEN=...` (printed every 3 seconds).

**`restart the app` over WiFi**: The CLI automatically pre-shares a token with the agent before restarting. After restart, the agent uses the pre-shared token instead of generating a new one — no manual token re-reading needed.

### How It Works

1. **USB**: `iproxy` forwards port 48686; CLI reads token via `idevicesyslog`; HTTP POST transport
2. **WiFi**: Agent binds to `0.0.0.0` (via `PROBE_WIFI=true`); CLI connects directly to device IP; no iproxy needed
3. **App lifecycle**: `xcrun devicectl` handles launch/terminate (instead of `simctl`)
4. **Keepalive**: WebSocket ping/pong frames every 5s prevent idle connection drops (USB mode)
5. **Auto-reconnect**: If the connection drops mid-test, the CLI reconnects transparently (up to 2 retries)

### Limitations on Physical Devices

| Operation | Physical iOS | Notes |
|---|---|---|
| `tap`, `type`, `see`, etc. | Works | Via agent RPC |
| `restart the app` | Works | Via `xcrun devicectl` |
| `take screenshot` | Works | Via agent RPC |
| `clear app data` | Skipped | No filesystem access; uninstall/reinstall as workaround |
| `allow/deny permission` | Skipped | Requires MDM or manual action |
| `set location` | Skipped | No CLI tool for GPS mocking on real hardware |

Skipped operations print a warning and continue — they do not fail the test.

### Troubleshooting Physical Devices

**"iproxy not found"**: Install libimobiledevice: `brew install libimobiledevice`

**"token not found within 30s"**: Ensure the app is running with `--dart-define=PROBE_AGENT=true`. Check `idevicesyslog -u <UDID> | grep PROBE_TOKEN` to verify the agent is printing tokens.

**Stale iproxy processes**: FlutterProbe automatically kills stale iproxy processes matching your device UDID. If you still have port conflicts, run: `pkill -f "iproxy.*<UDID>"`

**White/grey screen on launch**: Profile builds may fail if Firebase or other native SDKs aren't configured for the device. Try debug mode instead.

## Common probe.yaml for iOS

```yaml
Expand Down
47 changes: 47 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package cli

import (
"bytes"
"testing"
)

func TestStatusOK(t *testing.T) {
var buf bytes.Buffer
statusOK(&buf, "Connected to %s", "device-1")
out := buf.String()
if out == "" {
t.Error("statusOK produced no output")
}
if !bytes.Contains([]byte(out), []byte("Connected to device-1")) {
t.Errorf("statusOK output missing message: %q", out)
}
}

func TestStatusWarn(t *testing.T) {
var buf bytes.Buffer
statusWarn(&buf, "timeout exceeded")
out := buf.String()
if out == "" {
t.Error("statusWarn produced no output")
}
if !bytes.Contains([]byte(out), []byte("timeout exceeded")) {
t.Errorf("statusWarn output missing message: %q", out)
}
}

func TestMessageConstants(t *testing.T) {
// Verify key message constants are non-empty
messages := []struct {
name string
val string
}{
{"msgNoProbeFiles", msgNoProbeFiles},
{"msgWaitingForTokenIOS", msgWaitingForTokenIOS},
{"msgWaitingForToken", msgWaitingForToken},
}
for _, m := range messages {
if m.val == "" {
t.Errorf("%s is empty", m.name)
}
}
}
Loading
Loading