diff --git a/CHANGELOG.md b/CHANGELOG.md index 6042668..ae31546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.9.7] - 2026-05-12 + +### Added +- **Biometric authentication testing** — three new ProbeScript steps that drive Face ID / Touch ID / fingerprint flows on iOS Simulator and Android emulator without real hardware. Skipped on physical devices with a warning (same pattern as `set location` and other simulator-only ops). + - `enroll biometric` — marks the simulator/emulator as having an enrolled face or finger. iOS posts the `com.apple.BiometricKit.enrollmentChanged` Darwin notification via `xcrun simctl spawn booted notifyutil`. Android requires the fingerprint to be pre-enrolled in Settings. + - `biometric match` — simulates a successful capture, satisfying any pending biometric prompt. iOS posts `*_Sim.faceCapture.match` AND `*_Sim.fingerTouch.match` so the same step works on Face ID and Touch ID devices. Android runs `adb -s emu finger touch 1`. + - `biometric no match` — simulates a failed capture so the app's "authentication failed" path can be tested. iOS posts the `.no-match` variants; Android runs `adb emu finger touch 9999` (an unregistered id). + - **Annotation DSL**: matching `EnrollBiometric()`, `BiometricMatch()`, `BiometricNoMatch()` const Step classes in `flutter_probe_annotation`, with a new `biometric_auth` golden fixture in `flutter_probe_gen/test/fixtures/` that round-trips through the Go parser via the cross-language integration test. + - **Parser**: 2 new tokens (`TOKEN_BIOMETRIC`, `TOKEN_ENROLL`), 3 new `ActionVerb` constants, 2 new parser dispatch cases. 3 new unit tests in `parser_test.go`. + - **Runner**: `EnrollBiometric` / `BiometricMatch` / `BiometricNoMatch` methods on `DeviceContext`, dispatch cases in `Executor.runAction`, and human-readable strings in `stepDescription`. + - **Docs**: new section in [annotations.md](https://flutterprobe.dev/probescript/annotations/#biometric-authentication-v097) and [syntax.md](https://flutterprobe.dev/probescript/syntax/#biometric-authentication) on the website. Per-package CHANGELOGs updated. + ## [0.9.6] - 2026-05-12 ### Fixed diff --git a/README.md b/README.md index f35ca8f..e6b6159 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,8 @@ Test definitions are now type-checked by `flutter analyze` — a misspelt step n **v0.9.6** completes the annotation surface: full composite-test DSL (`@ProbeCompositeTest`, `Device`, `OnDevice`, `Sync`), id/selector-based `See`/`DontSee`, `WaitUntil.idAppears`, and composable `state` + `containing` + `matching` assertions. Plus fixes for two emitter bugs (`Mock` paths and `See` suffix dropping). +**v0.9.7** adds **biometric authentication testing** — `enroll biometric`, `biometric match`, `biometric no match` steps (and matching `EnrollBiometric()` / `BiometricMatch()` / `BiometricNoMatch()` annotation classes) drive Face ID / Touch ID / fingerprint flows on iOS Simulator and Android emulator. Skipped on physical devices. + Full reference: [flutterprobe.dev/probescript/annotations](https://flutterprobe.dev/probescript/annotations/) (or [`docs/wiki/Annotations.md`](docs/wiki/Annotations.md) on GitHub). ## CLI Commands diff --git a/docs/wiki/Annotations.md b/docs/wiki/Annotations.md index 8fb6ff1..491b61d 100644 --- a/docs/wiki/Annotations.md +++ b/docs/wiki/Annotations.md @@ -161,6 +161,9 @@ constructible. | `GrantAllPermissions()` / `RevokeAllPermissions()` | `grant all permissions` | | `CopyToClipboard('x')` / `PasteFromClipboard()` | `copy "x" to clipboard` | | `SetLocation(lat, lng)` | `set location lat, lng` | +| `EnrollBiometric()` (v0.9.7+) | `enroll biometric` — see [Biometric auth](https://flutterprobe.dev/probescript/annotations/#biometric-authentication-v097) | +| `BiometricMatch()` (v0.9.7+) | `biometric match` — simulate Face ID / Touch ID success | +| `BiometricNoMatch()` (v0.9.7+) | `biometric no match` — simulate Face ID / Touch ID failure | | `VerifyExternalBrowser()` | `verify external browser opened` | | `TakeScreenshot('name')` / `CompareScreenshot('name')` | `take screenshot "name"` | | `DumpWidgetTree()` / `SaveLogs()` / `Pause()` / `Log('msg')` | as named | diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 7785d44..d4f863e 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -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.6**. +FlutterProbe is in active development. Current version: **0.9.7**. ### Repository Structure diff --git a/internal/parser/ast.go b/internal/parser/ast.go index 5a50cd7..7174cb6 100644 --- a/internal/parser/ast.go +++ b/internal/parser/ast.go @@ -166,6 +166,10 @@ const ( VerbVerifyBrowser ActionVerb = "verify_browser" VerbOpenLink ActionVerb = "open_link" // open link "url" VerbStore ActionVerb = "store" // store "value" as varName + // Biometric (Face ID / Touch ID / fingerprint) — simulator/emulator only. + VerbEnrollBiometric ActionVerb = "enroll_biometric" + VerbBiometricMatch ActionVerb = "biometric_match" + VerbBiometricNoMatch ActionVerb = "biometric_no_match" ) type SwipeDirection string diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 816823c..93551d6 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -395,6 +395,10 @@ func (p *Parser) parseStep() (Step, error) { return p.parseHTTPCall() case TOKEN_STORE: return p.parseStore() + case TOKEN_BIOMETRIC: + return p.parseBiometric() + case TOKEN_ENROLL: + return p.parseEnrollBiometric() case TOKEN_NEWLINE: p.advance() return nil, nil @@ -1569,3 +1573,48 @@ func (p *Parser) parseStore() (Step, error) { p.consumeNewline() return ActionStep{Verb: VerbStore, Text: value, Name: varName, Line: line}, nil } + +// parseBiometric parses one of: +// +// biometric match +// biometric no match +// +// Simulator/emulator only — `xcrun simctl spawn booted notifyutil ...` +// on iOS and `adb emu finger touch ` on Android. +func (p *Parser) parseBiometric() (Step, error) { + line := p.peek().Line + p.advance() // biometric + p.skipFillers() + verb := VerbBiometricMatch + // "no match" — the lexer emits "no" as TOKEN_IDENT (not a keyword). + if p.peek().Type == TOKEN_IDENT && strings.ToLower(p.peek().Literal) == "no" { + p.advance() + p.skipFillers() + verb = VerbBiometricNoMatch + } + // Match is optional after `no` to allow either `no match` or `no-match`-ish + // patterns. Consume it if present. + if p.peek().Type == TOKEN_IDENT && strings.ToLower(p.peek().Literal) == "match" { + p.advance() + } + p.consumeNewline() + return ActionStep{Verb: verb, Line: line}, nil +} + +// parseEnrollBiometric parses: +// +// enroll biometric +// +// Sets the simulator/emulator's biometric enrollment state to "enrolled" +// so subsequent `biometric match` / `biometric no match` operations +// satisfy a pending biometric prompt in the app under test. +func (p *Parser) parseEnrollBiometric() (Step, error) { + line := p.peek().Line + p.advance() // enroll + p.skipFillers() + if p.peek().Type == TOKEN_BIOMETRIC { + p.advance() + } + p.consumeNewline() + return ActionStep{Verb: VerbEnrollBiometric, Line: line}, nil +} diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index 9a81d3d..ff9e06e 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -1142,3 +1142,62 @@ func TestComposite_DeviceTargetParsed(t *testing.T) { t.Errorf("device[1] target: got %q, want %q", ct.Devices[1].Target, "iPad Pro 12.9 Simulator") } } + +// ---- Biometric action tests ---- + +func TestBiometric_EnrollBiometric(t *testing.T) { + prog := mustParse(t, `test "auth" + enroll biometric + see "Dashboard" +`) + assertTestCount(t, prog, 1) + body := prog.Tests[0].Body + if len(body) < 1 { + t.Fatal("missing enroll biometric step") + } + a, ok := body[0].(parser.ActionStep) + if !ok { + t.Fatalf("first step: got %T, want ActionStep", body[0]) + } + if a.Verb != parser.VerbEnrollBiometric { + t.Errorf("verb: got %q, want %q", a.Verb, parser.VerbEnrollBiometric) + } +} + +func TestBiometric_Match(t *testing.T) { + prog := mustParse(t, `test "auth" + tap "Sign in with Face ID" + biometric match + see "Dashboard" +`) + steps := prog.Tests[0].Body + if len(steps) != 3 { + t.Fatalf("step count: got %d, want 3", len(steps)) + } + a, ok := steps[1].(parser.ActionStep) + if !ok { + t.Fatalf("biometric step: got %T", steps[1]) + } + if a.Verb != parser.VerbBiometricMatch { + t.Errorf("verb: got %q, want %q", a.Verb, parser.VerbBiometricMatch) + } +} + +func TestBiometric_NoMatch(t *testing.T) { + prog := mustParse(t, `test "auth fail" + tap "Sign in with Face ID" + biometric no match + see "Authentication failed" +`) + steps := prog.Tests[0].Body + if len(steps) != 3 { + t.Fatalf("step count: got %d, want 3", len(steps)) + } + a, ok := steps[1].(parser.ActionStep) + if !ok { + t.Fatalf("biometric step: got %T", steps[1]) + } + if a.Verb != parser.VerbBiometricNoMatch { + t.Errorf("verb: got %q, want %q", a.Verb, parser.VerbBiometricNoMatch) + } +} diff --git a/internal/parser/token.go b/internal/parser/token.go index e5d5c5e..7376452 100644 --- a/internal/parser/token.go +++ b/internal/parser/token.go @@ -160,6 +160,10 @@ const ( // Composite test keywords TOKEN_COMPOSITE // "composite" — starts a composite test definition TOKEN_SYNC // "sync" — cross-device barrier step inside composite tests + + // Biometric authentication (v0.9.7+) — simulator/emulator only. + TOKEN_BIOMETRIC // "biometric" — head of `biometric match` / `biometric no match` + TOKEN_ENROLL // "enroll" — head of `enroll biometric` ) // Token is a single lexical unit. @@ -293,6 +297,8 @@ var keywords = map[string]TokenType{ "store": TOKEN_STORE, "composite": TOKEN_COMPOSITE, "sync": TOKEN_SYNC, + "biometric": TOKEN_BIOMETRIC, + "enroll": TOKEN_ENROLL, } // fillerWords are stripped by the forgiving parser. diff --git a/internal/runner/device_context.go b/internal/runner/device_context.go index 183ae7a..50193e7 100644 --- a/internal/runner/device_context.go +++ b/internal/runner/device_context.go @@ -537,3 +537,101 @@ func (dc *DeviceContext) SetLocation(ctx context.Context, lat, lng string) error } return nil } + +// EnrollBiometric sets the simulator/emulator's biometric enrollment state +// to "enrolled" so the app under test sees a registered Face ID / Touch ID / +// fingerprint when it requests biometric authentication. +// +// - iOS Simulator: sends the Darwin notification +// `com.apple.BiometricKit.enrollmentChanged` (toggles state). +// - Android emulator: no-op — fingerprints are enrolled in Settings before +// the test runs (typically via a CI bootstrap script). +// - Physical devices: skipped with a warning. +func (dc *DeviceContext) EnrollBiometric(ctx context.Context) error { + if dc.IsPhysical { + fmt.Println(" \033[33m⚠\033[0m enroll biometric is not supported on physical devices — skipping") + return nil + } + fmt.Println(" \033[36m🔐\033[0m Enrolling biometric") + switch dc.Platform { + case device.PlatformIOS: + if _, err := dc.Manager.SimCtl().Spawn(ctx, dc.Serial, + "notifyutil", "-s", "com.apple.BiometricKit.enrollmentChanged", "1"); err != nil { + return fmt.Errorf("enroll biometric: set enrollment flag: %w", err) + } + if _, err := dc.Manager.SimCtl().Spawn(ctx, dc.Serial, + "notifyutil", "-p", "com.apple.BiometricKit.enrollmentChanged"); err != nil { + return fmt.Errorf("enroll biometric: post enrollment notification: %w", err) + } + case device.PlatformAndroid: + // Android fingerprints are enrolled in Settings, not via adb. We + // document the requirement in the .probe error message rather than + // failing here — the user's CI script should pre-enroll. + fmt.Println(" (Android: ensure fingerprint ID 1 is pre-enrolled in Settings)") + } + return nil +} + +// BiometricMatch simulates a successful biometric capture, satisfying a +// pending Face ID / Touch ID / fingerprint prompt. +// +// - iOS Simulator: posts `com.apple.BiometricKit_Sim.fingerTouch.match` +// and `.faceCapture.match` so the same step works regardless of the +// simulator's biometric kind. +// - Android emulator: `adb -s emu finger touch 1` (matches the +// fingerprint enrolled with ID 1). +// - Physical devices: skipped with a warning. +func (dc *DeviceContext) BiometricMatch(ctx context.Context) error { + return dc.biometricCapture(ctx, true) +} + +// BiometricNoMatch simulates a failed biometric capture so the app's +// "authentication failed" path can be tested. +// +// - iOS Simulator: posts `*_Sim.fingerTouch.no-match` and `.faceCapture.no-match`. +// - Android emulator: `adb emu finger touch 9999` (an unregistered id). +// - Physical devices: skipped with a warning. +func (dc *DeviceContext) BiometricNoMatch(ctx context.Context) error { + return dc.biometricCapture(ctx, false) +} + +func (dc *DeviceContext) biometricCapture(ctx context.Context, match bool) error { + if dc.IsPhysical { + fmt.Println(" \033[33m⚠\033[0m biometric capture is not supported on physical devices — skipping") + return nil + } + verb := "match" + icon := "✓" + if !match { + verb = "no-match" + icon = "✗" + } + fmt.Printf(" \033[36m🔐\033[0m Biometric capture: %s %s\n", icon, verb) + switch dc.Platform { + case device.PlatformIOS: + // 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. + notifications := []string{ + fmt.Sprintf("com.apple.BiometricKit_Sim.fingerTouch.%s", verb), + fmt.Sprintf("com.apple.BiometricKit_Sim.faceCapture.%s", verb), + } + for _, n := range notifications { + if _, err := dc.Manager.SimCtl().Spawn(ctx, dc.Serial, "notifyutil", "-p", n); err != nil { + return fmt.Errorf("biometric %s: post %s: %w", verb, n, err) + } + } + case device.PlatformAndroid: + // Fingerprint ID 1 is matching by convention; any unregistered ID + // (we use 9999) returns no-match. The user's CI bootstrap script + // enrolls fingerprint ID 1 before tests run. + fingerID := "1" + if !match { + fingerID = "9999" + } + if _, err := dc.Manager.ADB().Run(ctx, dc.Serial, "emu", "finger", "touch", fingerID); err != nil { + return fmt.Errorf("biometric %s: adb emu finger touch %s: %w", verb, fingerID, err) + } + } + return nil +} diff --git a/internal/runner/executor.go b/internal/runner/executor.go index bdec498..0715b52 100644 --- a/internal/runner/executor.go +++ b/internal/runner/executor.go @@ -340,6 +340,12 @@ func (e *Executor) stepDescription(step parser.Step) string { return fmt.Sprintf("set location %s", s.Name) case parser.VerbVerifyBrowser: return "verify external browser opened" + case parser.VerbEnrollBiometric: + return "enroll biometric" + case parser.VerbBiometricMatch: + return "biometric match" + case parser.VerbBiometricNoMatch: + return "biometric no match" default: return string(s.Verb) } @@ -647,6 +653,27 @@ func (e *Executor) runAction(ctx context.Context, a parser.ActionStep) error { case parser.VerbStore: e.vars[a.Name] = e.resolve(a.Text) return nil + + case parser.VerbEnrollBiometric: + if e.deviceCtx == nil { + fmt.Println(" \033[33m⚠\033[0m Skipping enroll biometric (cloud mode)") + return nil + } + return e.deviceCtx.EnrollBiometric(ctx) + + case parser.VerbBiometricMatch: + if e.deviceCtx == nil { + fmt.Println(" \033[33m⚠\033[0m Skipping biometric match (cloud mode)") + return nil + } + return e.deviceCtx.BiometricMatch(ctx) + + 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) } return fmt.Errorf("unknown action verb %q at line %d", a.Verb, a.Line) diff --git a/probe_agent/CHANGELOG.md b/probe_agent/CHANGELOG.md index 8b4937a..2751388 100644 --- a/probe_agent/CHANGELOG.md +++ b/probe_agent/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.9.7 - 2026-05-12 + +- Version bump to match CLI v0.9.7. No agent code changes — biometric + authentication is driven via simctl/adb from the CLI, no on-device + agent involvement needed. + ## 0.9.6 - 2026-05-12 - Version bump to match CLI v0.9.6. No agent code changes — annotation DSL diff --git a/probe_agent/pubspec.yaml b/probe_agent/pubspec.yaml index 71d1572..96475a8 100644 --- a/probe_agent/pubspec.yaml +++ b/probe_agent/pubspec.yaml @@ -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.6 +version: 0.9.7 homepage: https://flutterprobe.dev repository: https://github.com/AlphaWaveSystems/flutter-probe issue_tracker: https://github.com/AlphaWaveSystems/flutter-probe/issues diff --git a/probe_annotation/CHANGELOG.md b/probe_annotation/CHANGELOG.md index 13e5628..f3012db 100644 --- a/probe_annotation/CHANGELOG.md +++ b/probe_annotation/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.9.7 - 2026-05-12 + +### Added + +- **`EnrollBiometric`, `BiometricMatch`, `BiometricNoMatch` Step classes** — + drive Face ID / Touch ID / fingerprint flows on iOS Simulator and + Android emulator from annotation-authored tests. Skipped on physical + devices with a warning. See https://flutterprobe.dev/probescript/annotations/#biometric-authentication-v097. + ## 0.9.6 - 2026-05-12 ### Added diff --git a/probe_annotation/lib/src/steps.dart b/probe_annotation/lib/src/steps.dart index 3924eca..3b33c61 100644 --- a/probe_annotation/lib/src/steps.dart +++ b/probe_annotation/lib/src/steps.dart @@ -222,6 +222,43 @@ class VerifyExternalBrowser extends Step { const VerifyExternalBrowser(); } +// ---- Biometric authentication (simulator/emulator only) ---- + +/// Emits: `enroll biometric`. Sets the simulator/emulator's biometric +/// enrollment state to "enrolled" so subsequent [BiometricMatch] or +/// [BiometricNoMatch] satisfy a pending Face ID / Touch ID / fingerprint +/// prompt in the app under test. +/// +/// On iOS this posts the `com.apple.BiometricKit.enrollmentChanged` Darwin +/// notification via `xcrun simctl spawn booted notifyutil`. On Android the +/// fingerprint must already be enrolled in Settings; the step is a no-op +/// hint there. Skipped with a warning on physical devices. +class EnrollBiometric extends Step { + const EnrollBiometric(); +} + +/// Emits: `biometric match`. Simulates a successful Face ID / Touch ID / +/// fingerprint capture, satisfying a pending biometric prompt. +/// +/// iOS Simulator: posts `*_Sim.faceCapture.match` and `*_Sim.fingerTouch.match` +/// Darwin notifications so the same step works on Face ID and Touch ID +/// devices. Android emulator: `adb -s emu finger touch 1` (matches +/// the fingerprint enrolled with ID 1). Skipped on physical devices. +class BiometricMatch extends Step { + const BiometricMatch(); +} + +/// Emits: `biometric no match`. Simulates a failed Face ID / Touch ID / +/// fingerprint capture so the app's "authentication failed" path can be +/// exercised. +/// +/// iOS Simulator: posts `*_Sim.faceCapture.no-match` and `*_Sim.fingerTouch.no-match`. +/// Android emulator: `adb emu finger touch 9999` (an unregistered id). +/// Skipped on physical devices. +class BiometricNoMatch extends Step { + const BiometricNoMatch(); +} + // ---- Diagnostics ---- /// Emits: `take screenshot "name"`. diff --git a/probe_annotation/pubspec.yaml b/probe_annotation/pubspec.yaml index 5809e82..c4c3af3 100644 --- a/probe_annotation/pubspec.yaml +++ b/probe_annotation/pubspec.yaml @@ -3,7 +3,7 @@ description: >- Dart annotations for FlutterProbe — declare ProbeScript end-to-end tests as decorators on your Flutter screen classes. Pair with flutter_probe_gen to generate .probe test files at build time. -version: 0.9.6 +version: 0.9.7 homepage: https://flutterprobe.dev repository: https://github.com/AlphaWaveSystems/flutter-probe issue_tracker: https://github.com/AlphaWaveSystems/flutter-probe/issues diff --git a/probe_annotation/test/const_test.dart b/probe_annotation/test/const_test.dart index 1eeab76..d434df4 100644 --- a/probe_annotation/test/const_test.dart +++ b/probe_annotation/test/const_test.dart @@ -66,6 +66,9 @@ void main() { PasteFromClipboard(), SetLocation(37.7749, -122.4194), VerifyExternalBrowser(), + EnrollBiometric(), + BiometricMatch(), + BiometricNoMatch(), TakeScreenshot('shot'), CompareScreenshot('shot'), DumpWidgetTree(), diff --git a/probe_gen/CHANGELOG.md b/probe_gen/CHANGELOG.md index f693970..380970c 100644 --- a/probe_gen/CHANGELOG.md +++ b/probe_gen/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.9.7 - 2026-05-12 + +### Added + +- **3 new emitter cases** for `EnrollBiometric`, `BiometricMatch`, + `BiometricNoMatch` — translate the corresponding Dart step classes + to `enroll biometric`, `biometric match`, and `biometric no match` + ProbeScript lines. New `biometric_auth` golden fixture exercises + the happy-path (Face ID match) and unhappy-path (no-match) flows. + ## 0.9.6 - 2026-05-12 ### Fixed diff --git a/probe_gen/lib/src/probe_emitter.dart b/probe_gen/lib/src/probe_emitter.dart index 365c3a0..1110630 100644 --- a/probe_gen/lib/src/probe_emitter.dart +++ b/probe_gen/lib/src/probe_emitter.dart @@ -273,6 +273,17 @@ class ProbeEmitter { _buf.writeln('${indent}verify external browser opened'); break; + // Biometric (Face ID / Touch ID / fingerprint) — simulator/emulator only + case 'EnrollBiometric': + _buf.writeln('${indent}enroll biometric'); + break; + case 'BiometricMatch': + _buf.writeln('${indent}biometric match'); + break; + case 'BiometricNoMatch': + _buf.writeln('${indent}biometric no match'); + break; + // Diagnostics case 'TakeScreenshot': _buf.writeln('${indent}take screenshot "${_escape(_str(step, 'name') ?? '')}"'); diff --git a/probe_gen/pubspec.yaml b/probe_gen/pubspec.yaml index c25e13e..a355b39 100644 --- a/probe_gen/pubspec.yaml +++ b/probe_gen/pubspec.yaml @@ -4,7 +4,7 @@ description: >- flutter_probe_annotation decorators on Flutter classes and emits .probe test files into tests/generated/. Pair with flutter_probe_agent and the probe CLI to run the generated end-to-end tests. -version: 0.9.6 +version: 0.9.7 homepage: https://flutterprobe.dev repository: https://github.com/AlphaWaveSystems/flutter-probe issue_tracker: https://github.com/AlphaWaveSystems/flutter-probe/issues @@ -22,7 +22,7 @@ environment: dependencies: analyzer: ">=8.0.0 <14.0.0" build: ">=3.0.0 <5.0.0" - flutter_probe_annotation: ^0.9.6 + flutter_probe_annotation: ^0.9.7 source_gen: ^4.0.0 dev_dependencies: diff --git a/probe_gen/test/builder_test.dart b/probe_gen/test/builder_test.dart index b9ba406..0995863 100644 --- a/probe_gen/test/builder_test.dart +++ b/probe_gen/test/builder_test.dart @@ -77,6 +77,9 @@ void main() { test('kitchen sink — one of every step + selector + control flow', () => runGolden('kitchen_sink')); + test('biometric auth (Face ID / Touch ID / fingerprint)', + () => runGolden('biometric_auth')); + test('skips files with no FlutterProbe annotations', () async { final annotation = await annotationPackageAssets(); await testBuilder( diff --git a/probe_gen/test/fixtures/biometric_auth.dart b/probe_gen/test/fixtures/biometric_auth.dart new file mode 100644 index 0000000..73b4024 --- /dev/null +++ b/probe_gen/test/fixtures/biometric_auth.dart @@ -0,0 +1,22 @@ +import 'package:flutter_probe_annotation/flutter_probe_annotation.dart'; + +@ProbeSuite( + beforeAll: [EnrollBiometric()], + tests: [ + ProbeTest('matching face unlocks app', tags: ['biometric', 'happy'], steps: [ + Open(), + Tap(text: 'Sign in with Face ID'), + BiometricMatch(), + WaitUntil.appears('Dashboard'), + See('Dashboard'), + ]), + ProbeTest('non-matching face is rejected', steps: [ + Open(), + Tap(text: 'Sign in with Face ID'), + BiometricNoMatch(), + See('Authentication failed'), + DontSee('Dashboard'), + ]), + ], +) +class BiometricAuthScreen {} diff --git a/probe_gen/test/fixtures/biometric_auth.probe.golden b/probe_gen/test/fixtures/biometric_auth.probe.golden new file mode 100644 index 0000000..81f8796 --- /dev/null +++ b/probe_gen/test/fixtures/biometric_auth.probe.golden @@ -0,0 +1,21 @@ +# Generated by flutter_probe_gen — do not edit by hand. +# Source: lib/biometric_auth.dart + +before all tests + enroll biometric + +test "matching face unlocks app" + @biometric @happy + open the app + tap "Sign in with Face ID" + biometric match + wait until "Dashboard" appears + see "Dashboard" + +test "non-matching face is rejected" + open the app + tap "Sign in with Face ID" + biometric no match + see "Authentication failed" + don't see "Dashboard" + diff --git a/vscode/package.json b/vscode/package.json index ab48ac7..9528e82 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -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.6", + "version": "0.9.7", "publisher": "flutterprobe", "icon": "resources/probe-icon.png", "engines": { "vscode": "^1.85.0" }, diff --git a/website/src/content/docs/probescript/annotations.md b/website/src/content/docs/probescript/annotations.md index bb5196b..cfcf9d3 100644 --- a/website/src/content/docs/probescript/annotations.md +++ b/website/src/content/docs/probescript/annotations.md @@ -155,6 +155,7 @@ All 31 ProbeScript actions have a matching `const` Dart class. Common ones: | `GrantAllPermissions()` / `RevokeAllPermissions()` | as named | | `CopyToClipboard('x')` / `PasteFromClipboard()` | clipboard ops | | `SetLocation(lat, lng)` / `VerifyExternalBrowser()` | as named | +| `EnrollBiometric()` / `BiometricMatch()` / `BiometricNoMatch()` (v0.9.7+) | Face ID / Touch ID / fingerprint simulation — see Biometric section below | | `TakeScreenshot('name')` / `CompareScreenshot('name')` | screenshot ops | | `DumpWidgetTree()` / `SaveLogs()` / `Pause()` / `Log('msg')` | as named | | `Store('value', as: 'var')` | `store "value" as var` | @@ -237,6 +238,51 @@ Both workflows are valid: Pick whichever fits your team. +## Biometric authentication (v0.9.7+) + +Test Face ID, Touch ID, and Android fingerprint flows end-to-end without +real hardware. FlutterProbe drives the simulator/emulator's biometric +controls via `xcrun simctl spawn notifyutil` (iOS) and +`adb -s emu finger touch` (Android). + +```dart +@ProbeSuite( + beforeAll: [EnrollBiometric()], + tests: [ + ProbeTest('matching face unlocks app', steps: [ + Open(), + Tap(text: 'Sign in with Face ID'), + BiometricMatch(), + WaitUntil.appears('Dashboard'), + See('Dashboard'), + ]), + ProbeTest('non-matching face is rejected', steps: [ + Open(), + Tap(text: 'Sign in with Face ID'), + BiometricNoMatch(), + See('Authentication failed'), + ]), + ], +) +class BiometricAuthScreen {} +``` + +| Step | iOS Simulator | Android emulator | +|---|---|---| +| `EnrollBiometric()` | Posts `com.apple.BiometricKit.enrollmentChanged` Darwin notification | No-op (pre-enroll fingerprint ID 1 in Settings) | +| `BiometricMatch()` | Posts `*_Sim.faceCapture.match` + `*_Sim.fingerTouch.match` | `adb emu finger touch 1` | +| `BiometricNoMatch()` | Posts `*_Sim.faceCapture.no-match` + `*_Sim.fingerTouch.no-match` | `adb emu finger touch 9999` (unregistered id) | + +**Physical devices are skipped with a warning.** Real Face ID / Touch ID +requires an actual face or finger and can't be programmatically driven — +same constraint as `set location`, `allow permission`, and other +simulator-only ops. Tests using these steps should target a simulator +or emulator in CI. + +**Android prerequisite:** the emulator must have a fingerprint enrolled +in Settings (Security & privacy → Fingerprint) with ID `1` before tests +run. A typical CI bootstrap script enrolls it once during emulator setup. + ## Cross-language validation Every fixture in `flutter_probe_gen`'s test suite is round-tripped through the Go-side ProbeScript parser as part of CI (`internal/parser/golden_integration_test.go`). If the Dart emitter ever produces output the runtime can't parse, the Go test fails and the release is blocked — bugs are caught in CI, not at user runtime. diff --git a/website/src/content/docs/probescript/syntax.md b/website/src/content/docs/probescript/syntax.md index c2efcb2..342613f 100644 --- a/website/src/content/docs/probescript/syntax.md +++ b/website/src/content/docs/probescript/syntax.md @@ -168,6 +168,43 @@ set location 37.7749, -122.4194 # set GPS coordinates (lat, lng) verify external browser opened # assert url_launcher was called ``` +## Biometric Authentication + +Drive Face ID / Touch ID / fingerprint prompts on the simulator or +emulator. Skipped on physical devices. + +``` +enroll biometric # mark the device as having an enrolled face/finger +biometric match # simulate a successful capture (unblocks a pending prompt) +biometric no match # simulate a failed capture (triggers the failure path) +``` + +Typical pattern — wraps a Face ID prompt with a happy and unhappy path: + +``` +before all tests + enroll biometric + +test "matching face unlocks" + open the app + tap "Sign in with Face ID" + biometric match + wait until "Dashboard" appears + +test "non-matching face is rejected" + open the app + tap "Sign in with Face ID" + biometric no match + see "Authentication failed" +``` + +On iOS, this posts the `BiometricKit_Sim.faceCapture.match` / `.no-match` +Darwin notifications (and the `fingerTouch.*` equivalents, so the same +step works on Touch ID devices). On Android, this calls +`adb -s emu finger touch ` — fingerprint ID `1` is +matching by convention (must be pre-enrolled in Settings before tests +run); any unregistered ID is no-match. + ## HTTP Calls Make real HTTP requests to APIs (runs on the CLI, not the device): diff --git a/website/src/content/docs/tools/mcp.md b/website/src/content/docs/tools/mcp.md index 12ff0f4..1a90f20 100644 --- a/website/src/content/docs/tools/mcp.md +++ b/website/src/content/docs/tools/mcp.md @@ -238,7 +238,7 @@ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | probe-mcp Expected response: ```json -{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"protocolVersion":"2024-11-05","serverInfo":{"name":"probe-mcp","version":"0.9.6"}}} +{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"tools":{}},"protocolVersion":"2024-11-05","serverInfo":{"name":"probe-mcp","version":"0.9.7"}}} ``` List all available tools: