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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [0.9.8] - 2026-05-12

### Fixed
- **Biometric no-match on iOS 26+ simulator** — `notifyutil` no-match notifications
no longer resolve `LAContext.evaluatePolicy` on iOS 26 / Xcode 26.5. The CLI now
sends a `probe.biometric_signal {result: bool}` JSON-RPC command to the agent
after firing platform-level notifications. Test apps call `awaitBiometricResult()`
from `flutter_probe_agent` instead of `local_auth.authenticate()` in PROBE_AGENT
builds; the agent resolves a Dart `Completer` with the CLI-delivered result, making
biometric no-match reliable on all iOS simulator versions.

### Added
- **`awaitBiometricResult()`** — new public function in `flutter_probe_agent`.
Returns a `Future<bool>` that resolves when the CLI delivers
`probe.biometric_signal`. Usage pattern in test apps:
```dart
final ok = const bool.fromEnvironment('PROBE_AGENT')
? await awaitBiometricResult()
: await localAuth.authenticate(...);
```
- **`probe.biometric_signal`** — new JSON-RPC method. Sent by the CLI after
the platform-level biometric simulation commands so the result is always
delivered regardless of simulator version behavior.

## [0.9.7] - 2026-05-12

### Added
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Home.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Welcome to the FlutterProbe wiki. This documentation covers architecture details

## Project Status

FlutterProbe is in active development. Current version: **0.9.7**.
FlutterProbe is in active development. Current version: **0.9.8**.

### Repository Structure

Expand Down
5 changes: 5 additions & 0 deletions internal/probelink/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ const (
MethodSetOutput = "probe.set_output"
MethodDrainOutput = "probe.drain_output"

// Biometric simulation signal (v0.9.8)
// Sent by CLI after firing platform biometric commands so the agent-side
// Dart Completer resolves without depending on LAContext behaviour.
MethodBiometricSignal = "probe.biometric_signal"

// Notification methods (agent → CLI, no response expected)
NotifyRecordedEvent = "probe.recorded_event"
NotifyExecDart = "probe.exec_dart"
Expand Down
4 changes: 4 additions & 0 deletions internal/runner/device_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,10 @@ func (dc *DeviceContext) biometricCapture(ctx context.Context, match bool) error
// Post both fingerprint and face notifications so the same step
// works on Touch ID devices and Face ID devices alike — the simulator
// ignores the one that doesn't match its hardware profile.
// Note: on iOS 26+ simulator, no-match notifications no longer resolve
// LAContext.evaluatePolicy. Apps using flutter_probe_agent should call
// awaitBiometricResult() instead of local_auth.authenticate() when
// PROBE_AGENT=true; the CLI sends probe.biometric_signal to resolve it.
notifications := []string{
fmt.Sprintf("com.apple.BiometricKit_Sim.fingerTouch.%s", verb),
fmt.Sprintf("com.apple.BiometricKit_Sim.faceCapture.%s", verb),
Expand Down
12 changes: 10 additions & 2 deletions internal/runner/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -666,14 +666,22 @@ func (e *Executor) runAction(ctx context.Context, a parser.ActionStep) error {
fmt.Println(" \033[33m⚠\033[0m Skipping biometric match (cloud mode)")
return nil
}
return e.deviceCtx.BiometricMatch(ctx)
if err := e.deviceCtx.BiometricMatch(ctx); err != nil {
return err
}
_, err := e.client.Call(ctx, probelink.MethodBiometricSignal, map[string]any{"result": true})
return err

case parser.VerbBiometricNoMatch:
if e.deviceCtx == nil {
fmt.Println(" \033[33m⚠\033[0m Skipping biometric no-match (cloud mode)")
return nil
}
return e.deviceCtx.BiometricNoMatch(ctx)
if err := e.deviceCtx.BiometricNoMatch(ctx); err != nil {
return err
}
_, err := e.client.Call(ctx, probelink.MethodBiometricSignal, map[string]any{"result": false})
return err
}

return fmt.Errorf("unknown action verb %q at line %d", a.Verb, a.Line)
Expand Down
12 changes: 12 additions & 0 deletions probe_agent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 0.9.8 - 2026-05-12

- **`awaitBiometricResult()`** — new public function exported from
`flutter_probe_agent`. Test apps in PROBE_AGENT builds call this instead
of `local_auth.authenticate()` to receive the biometric match/no-match
result from the CLI via the new `probe.biometric_signal` JSON-RPC command.
Required on iOS 26+ simulator where `notifyutil` no-match notifications
no longer resolve `LAContext.evaluatePolicy`.
- New `probe.biometric_signal` JSON-RPC method (`ProbeMethods.biometricSignal`)
that delivers `true` (match) or `false` (no-match) to a pending
`awaitBiometricResult()` Dart Completer.

## 0.9.7 - 2026-05-12

- Version bump to match CLI v0.9.7. No agent code changes — biometric
Expand Down
1 change: 1 addition & 0 deletions probe_agent/lib/flutter_probe_agent.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ library flutter_probe_agent;

// Public API — only what app developers need
export 'src/agent.dart' show ProbeAgent, isProbeEnabled;
export 'src/biometric.dart' show awaitBiometricResult;
6 changes: 4 additions & 2 deletions probe_agent/lib/src/agent.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,10 @@ class ProbeAgent {
/// Whether the agent is currently running.
static bool get isRunning => _server != null || _relayClient != null;

/// The port the agent is listening on (0 in relay mode).
static int get port => _server?.port ?? 0;
/// The port the agent is actually listening on (0 in relay mode).
/// May differ from the [port] parameter passed to [start] if the preferred
/// port was busy and the server fell back to the next available port.
static int get port => _server?.actualPort ?? 0;

/// Whether the agent is running in relay mode.
static bool get isRelayMode => _relayClient != null;
Expand Down
31 changes: 31 additions & 0 deletions probe_agent/lib/src/biometric.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import 'dart:async';

// Single pending completer — only one biometric prompt can be active at a time.
Completer<bool>? _pending;

/// Returns a [Future] that resolves to `true` (match) or `false` (no-match)
/// when the FlutterProbe CLI delivers a [probe.biometric_signal] command.
///
/// Call this in PROBE_AGENT builds instead of `local_auth.authenticate()`:
///
/// ```dart
/// import 'package:flutter_probe_agent/flutter_probe_agent.dart';
///
/// final ok = const bool.fromEnvironment('PROBE_AGENT')
/// ? await awaitBiometricResult()
/// : await localAuth.authenticate(...);
/// ```
///
/// The CLI resolves this automatically after the `biometric match` or
/// `biometric no match` ProbeScript step fires — no app-side changes to
/// the test script are required.
Future<bool> awaitBiometricResult() {
_pending = Completer<bool>();
return _pending!.future;
}

/// Called by [ProbeExecutor] when [probe.biometric_signal] arrives.
void completeBiometricResult(bool result) {
_pending?.complete(result);
_pending = null;
}
6 changes: 6 additions & 0 deletions probe_agent/lib/src/executor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:flutter/scheduler.dart' show timeDilation;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

import 'biometric.dart' as biometric;
import 'finder.dart';
import 'protocol.dart';
import 'recorder.dart';
Expand Down Expand Up @@ -263,6 +264,11 @@ class ProbeExecutor {
_output.clear();
return result;

case ProbeMethods.biometricSignal:
final result = (req.params['result'] as bool?) ?? false;
biometric.completeBiometricResult(result);
return {};

default:
throw ProbeError(ProbeError.methodNotFound, 'Unknown method: ${req.method}');
}
Expand Down
3 changes: 3 additions & 0 deletions probe_agent/lib/src/protocol.dart
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ class ProbeMethods {
static const setOutput = 'probe.set_output';
static const drainOutput = 'probe.drain_output';

// Biometric simulation signal (v0.9.8)
static const biometricSignal = 'probe.biometric_signal';

// Notification methods (agent → CLI)
static const notifyRecordedEvent = 'probe.recorded_event';
static const notifyExecDart = 'probe.exec_dart';
Expand Down
75 changes: 72 additions & 3 deletions probe_agent/lib/src/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,17 @@ const String probeAgentVersion = '0.7.0';
/// Supports two transport modes:
/// - **WebSocket** (default): persistent connection at `ws://host:port/probe?token=<token>`
/// - **HTTP POST** (fallback for physical devices): stateless `POST /probe/rpc?token=<token>`
///
/// If the preferred port is in use, the server tries successive ports up to
/// [portRange] candidates. When a busy port is occupied by another probe agent
/// (detected via `GET /probe/status`), a `PROBE_PORT_BUSY` log line is emitted
/// so the CLI can surface a clear error instead of a silent connection refusal.
class ProbeServer {
final int port;

/// Maximum number of consecutive ports to try when the preferred port is busy.
/// Range: [port, port + portRange). Default 10 tries (48686–48695).
final int portRange;
final bool allowRemoteConnections;
HttpServer? _server;
String? _token;
Expand All @@ -39,10 +48,15 @@ class ProbeServer {
/// Creates a ProbeServer.
/// Set [allowRemoteConnections] to true for WiFi testing (binds to 0.0.0.0
/// instead of localhost). Only use in debug/profile builds — never in release.
ProbeServer({this.port = 48686, this.allowRemoteConnections = false});
ProbeServer({this.port = 48686, this.portRange = 10, this.allowRemoteConnections = false});

Timer? _tokenTimer;

/// Starts the WebSocket server and prints the session token.
/// The port the server is actually listening on after [start].
/// May differ from [port] if the preferred port was busy.
int get actualPort => _server?.port ?? port;

/// Starts the WebSocket server and prints the session token.
/// If a pre-shared token was persisted (via `set_next_token`), uses that
/// instead of generating a random one. This enables reconnection after
Expand All @@ -52,11 +66,15 @@ class ProbeServer {
final bindAddress = allowRemoteConnections
? InternetAddress.anyIPv4
: InternetAddress.loopbackIPv4;
_server = await HttpServer.bind(bindAddress, port);
_server = await _bindWithFallback(bindAddress);

// Emit token so the CLI (via adb logcat / simctl log) can read it
// Emit token (and port when non-default) so the CLI can read them.
// ignore: avoid_print
print('PROBE_TOKEN=$_token');
if (actualPort != port) {
// ignore: avoid_print
print('PROBE_PORT=$actualPort');
}

// Write token to a file so the CLI can read it directly
await _writeTokenFile();
Expand Down Expand Up @@ -86,8 +104,59 @@ class ProbeServer {
_serve();
}

/// Tries [port], then [port+1] … [port+portRange-1] until one binds.
///
/// For each busy port, checks whether it is occupied by another probe agent
/// (via GET /probe/status). Emits `PROBE_PORT_BUSY=<port> (probe agent)` or
/// `PROBE_PORT_BUSY=<port>` so the CLI / developer can distinguish a port
/// collision from two probe instances running simultaneously.
Future<HttpServer> _bindWithFallback(InternetAddress bindAddress) async {
for (var i = 0; i < portRange; i++) {
final candidate = port + i;
try {
return await HttpServer.bind(bindAddress, candidate);
} on SocketException catch (e) {
final code = e.osError?.errorCode ?? 0;
// EADDRINUSE = 48 on macOS/iOS, 98 on Linux/Android.
if (code != 48 && code != 98) rethrow;
final isAgent = await _probePing(candidate);
// ignore: avoid_print
print('PROBE_PORT_BUSY=$candidate${isAgent ? " (another probe agent is running)" : ""}');
if (i + 1 < portRange) continue;
rethrow;
}
}
// Unreachable: the final iteration either returns or rethrows.
throw StateError('port range exhausted');
}

/// Returns true when [p] responds to `GET /probe/status` with a probe-agent
/// signature, identifying a concurrent agent on that port.
Future<bool> _probePing(int p) async {
try {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 1);
final req = await client.get('127.0.0.1', p, '/probe/status');
final res = await req.close().timeout(const Duration(seconds: 1));
final body = await res.transform(utf8.decoder).join();
client.close();
return body.contains('"agent":"flutter_probe"');
} catch (_) {
return false;
}
}

Future<void> _serve() async {
await for (final req in _server!) {
// Status endpoint — no token required; used for port-collision detection.
if (req.method == 'GET' && req.uri.path == '/probe/status') {
req.response
..statusCode = HttpStatus.ok
..headers.contentType = ContentType.json
..write('{"agent":"flutter_probe","version":"$probeAgentVersion"}');
await req.response.close();
continue;
}

// Validate token (shared by both WebSocket and HTTP paths)
final queryToken = req.uri.queryParameters['token'] ??
req.headers.value('x-probe-token');
Expand Down
2 changes: 1 addition & 1 deletion probe_agent/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ description: >-
On-device E2E test agent for FlutterProbe. Embeds in your Flutter app and
executes test commands via direct widget-tree access with sub-50ms latency.

version: 0.9.7
version: 0.9.8
homepage: https://flutterprobe.dev
repository: https://github.com/AlphaWaveSystems/flutter-probe
issue_tracker: https://github.com/AlphaWaveSystems/flutter-probe/issues
Expand Down
2 changes: 1 addition & 1 deletion vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "flutterprobe",
"displayName": "FlutterProbe",
"description": "High-performance E2E testing for Flutter apps — ProbeScript language support, local & cloud device testing (BrowserStack, Sauce Labs, AWS Device Farm, Firebase Test Lab, LambdaTest), visual regression, test recording, and Studio integration",
"version": "0.9.7",
"version": "0.9.8",
"publisher": "flutterprobe",
"icon": "resources/probe-icon.png",
"engines": { "vscode": "^1.85.0" },
Expand Down
Loading