Skip to content

Commit ab5e4b2

Browse files
authored
feat(biometric): Face ID / Touch ID / fingerprint testing (v0.9.7) (#110)
New ProbeScript steps and matching annotation DSL classes for driving biometric auth flows on iOS Simulator and Android emulator without real hardware. Skipped on physical devices with a warning (same pattern as set location, allow permission, and other simulator-only ops). Parser 2 new keywords (TOKEN_BIOMETRIC, TOKEN_ENROLL), 3 new ActionVerb constants (VerbEnrollBiometric, VerbBiometricMatch, VerbBiometricNoMatch), 2 new dispatch cases in parseStep, parseBiometric and parseEnrollBiometric helpers. 3 new parser_test.go tests covering every form. Runner EnrollBiometric, BiometricMatch, BiometricNoMatch methods on DeviceContext. iOS uses xcrun simctl spawn booted notifyutil -p com.apple.BiometricKit_Sim.faceCapture.match / .no-match (and the fingerTouch.* equivalents, so the same step works on Face ID AND Touch ID devices). Android uses adb -s <serial> emu finger touch <id> where id=1 is matching (must be pre-enrolled) and id=9999 is no-match. Physical devices skip with a warning, same pattern as SetLocation. Dispatch cases in Executor.runAction; human-readable strings in stepDescription so verbose runner output reads naturally. Annotation DSL 3 new const Step classes — EnrollBiometric, BiometricMatch, BiometricNoMatch — added to probe_annotation/lib/src/steps.dart. Added to the const-constructibility test. Comprehensive doc comments document the iOS/Android command paths and the physical-device skip. Emitter 3 new cases in probe_gen/lib/src/probe_emitter.dart that produce 'enroll biometric', 'biometric match', 'biometric no match' lines. New biometric_auth fixture+golden pair covering happy path (match unlocks Dashboard) and unhappy path (no-match shows Authentication failed). Cross-language golden_integration_test auto-picks it up. Tests (strict) go test ./... 16/16 packages pass staticcheck ./... zero issues parser_test.go 3 new biometric tests pass probe_annotation dart test 3/3 const tests pass probe_gen dart test 12/12 builder tests pass (was 11; +biometric_auth) GoldenIntegration Go test 11/11 fixtures parse cleanly Astro npm run build 37 pages built clean Docs website/src/content/docs/probescript/syntax.md new Biometric section website/src/content/docs/probescript/annotations.md new Biometric subsection + DSL table entry docs/wiki/Annotations.md step DSL table updated README.md v0.9.7 callout added CHANGELOG.md (root) [0.9.7] entry probe_agent/CHANGELOG.md version-only bump probe_annotation/CHANGELOG.md feature entry probe_gen/CHANGELOG.md feature entry vscode/package.json, docs/wiki/Home.md, mcp.md version refs bumped Trade-offs (per user acknowledgement) Real Face ID and Touch ID require an actual face or finger — these steps are simulator/emulator only. Tests should target a simulator/emulator in CI; physical iOS/Android device paths skip with a warning so the test still completes.
1 parent 61f0a17 commit ab5e4b2

26 files changed

Lines changed: 472 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
## [0.9.7] - 2026-05-12
10+
11+
### Added
12+
- **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).
13+
- `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.
14+
- `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 <serial> emu finger touch 1`.
15+
- `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).
16+
- **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.
17+
- **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`.
18+
- **Runner**: `EnrollBiometric` / `BiometricMatch` / `BiometricNoMatch` methods on `DeviceContext`, dispatch cases in `Executor.runAction`, and human-readable strings in `stepDescription`.
19+
- **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.
20+
921
## [0.9.6] - 2026-05-12
1022

1123
### Fixed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,8 @@ Test definitions are now type-checked by `flutter analyze` — a misspelt step n
417417

418418
**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).
419419

420+
**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.
421+
420422
Full reference: [flutterprobe.dev/probescript/annotations](https://flutterprobe.dev/probescript/annotations/) (or [`docs/wiki/Annotations.md`](docs/wiki/Annotations.md) on GitHub).
421423

422424
## CLI Commands

docs/wiki/Annotations.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@ constructible.
161161
| `GrantAllPermissions()` / `RevokeAllPermissions()` | `grant all permissions` |
162162
| `CopyToClipboard('x')` / `PasteFromClipboard()` | `copy "x" to clipboard` |
163163
| `SetLocation(lat, lng)` | `set location lat, lng` |
164+
| `EnrollBiometric()` (v0.9.7+) | `enroll biometric` — see [Biometric auth](https://flutterprobe.dev/probescript/annotations/#biometric-authentication-v097) |
165+
| `BiometricMatch()` (v0.9.7+) | `biometric match` — simulate Face ID / Touch ID success |
166+
| `BiometricNoMatch()` (v0.9.7+) | `biometric no match` — simulate Face ID / Touch ID failure |
164167
| `VerifyExternalBrowser()` | `verify external browser opened` |
165168
| `TakeScreenshot('name')` / `CompareScreenshot('name')` | `take screenshot "name"` |
166169
| `DumpWidgetTree()` / `SaveLogs()` / `Pause()` / `Log('msg')` | as named |

docs/wiki/Home.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Welcome to the FlutterProbe wiki. This documentation covers architecture details
1818

1919
## Project Status
2020

21-
FlutterProbe is in active development. Current version: **0.9.6**.
21+
FlutterProbe is in active development. Current version: **0.9.7**.
2222

2323
### Repository Structure
2424

internal/parser/ast.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ const (
166166
VerbVerifyBrowser ActionVerb = "verify_browser"
167167
VerbOpenLink ActionVerb = "open_link" // open link "url"
168168
VerbStore ActionVerb = "store" // store "value" as varName
169+
// Biometric (Face ID / Touch ID / fingerprint) — simulator/emulator only.
170+
VerbEnrollBiometric ActionVerb = "enroll_biometric"
171+
VerbBiometricMatch ActionVerb = "biometric_match"
172+
VerbBiometricNoMatch ActionVerb = "biometric_no_match"
169173
)
170174

171175
type SwipeDirection string

internal/parser/parser.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,10 @@ func (p *Parser) parseStep() (Step, error) {
395395
return p.parseHTTPCall()
396396
case TOKEN_STORE:
397397
return p.parseStore()
398+
case TOKEN_BIOMETRIC:
399+
return p.parseBiometric()
400+
case TOKEN_ENROLL:
401+
return p.parseEnrollBiometric()
398402
case TOKEN_NEWLINE:
399403
p.advance()
400404
return nil, nil
@@ -1569,3 +1573,48 @@ func (p *Parser) parseStore() (Step, error) {
15691573
p.consumeNewline()
15701574
return ActionStep{Verb: VerbStore, Text: value, Name: varName, Line: line}, nil
15711575
}
1576+
1577+
// parseBiometric parses one of:
1578+
//
1579+
// biometric match
1580+
// biometric no match
1581+
//
1582+
// Simulator/emulator only — `xcrun simctl spawn booted notifyutil ...`
1583+
// on iOS and `adb emu finger touch <id>` on Android.
1584+
func (p *Parser) parseBiometric() (Step, error) {
1585+
line := p.peek().Line
1586+
p.advance() // biometric
1587+
p.skipFillers()
1588+
verb := VerbBiometricMatch
1589+
// "no match" — the lexer emits "no" as TOKEN_IDENT (not a keyword).
1590+
if p.peek().Type == TOKEN_IDENT && strings.ToLower(p.peek().Literal) == "no" {
1591+
p.advance()
1592+
p.skipFillers()
1593+
verb = VerbBiometricNoMatch
1594+
}
1595+
// Match is optional after `no` to allow either `no match` or `no-match`-ish
1596+
// patterns. Consume it if present.
1597+
if p.peek().Type == TOKEN_IDENT && strings.ToLower(p.peek().Literal) == "match" {
1598+
p.advance()
1599+
}
1600+
p.consumeNewline()
1601+
return ActionStep{Verb: verb, Line: line}, nil
1602+
}
1603+
1604+
// parseEnrollBiometric parses:
1605+
//
1606+
// enroll biometric
1607+
//
1608+
// Sets the simulator/emulator's biometric enrollment state to "enrolled"
1609+
// so subsequent `biometric match` / `biometric no match` operations
1610+
// satisfy a pending biometric prompt in the app under test.
1611+
func (p *Parser) parseEnrollBiometric() (Step, error) {
1612+
line := p.peek().Line
1613+
p.advance() // enroll
1614+
p.skipFillers()
1615+
if p.peek().Type == TOKEN_BIOMETRIC {
1616+
p.advance()
1617+
}
1618+
p.consumeNewline()
1619+
return ActionStep{Verb: VerbEnrollBiometric, Line: line}, nil
1620+
}

internal/parser/parser_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,3 +1142,62 @@ func TestComposite_DeviceTargetParsed(t *testing.T) {
11421142
t.Errorf("device[1] target: got %q, want %q", ct.Devices[1].Target, "iPad Pro 12.9 Simulator")
11431143
}
11441144
}
1145+
1146+
// ---- Biometric action tests ----
1147+
1148+
func TestBiometric_EnrollBiometric(t *testing.T) {
1149+
prog := mustParse(t, `test "auth"
1150+
enroll biometric
1151+
see "Dashboard"
1152+
`)
1153+
assertTestCount(t, prog, 1)
1154+
body := prog.Tests[0].Body
1155+
if len(body) < 1 {
1156+
t.Fatal("missing enroll biometric step")
1157+
}
1158+
a, ok := body[0].(parser.ActionStep)
1159+
if !ok {
1160+
t.Fatalf("first step: got %T, want ActionStep", body[0])
1161+
}
1162+
if a.Verb != parser.VerbEnrollBiometric {
1163+
t.Errorf("verb: got %q, want %q", a.Verb, parser.VerbEnrollBiometric)
1164+
}
1165+
}
1166+
1167+
func TestBiometric_Match(t *testing.T) {
1168+
prog := mustParse(t, `test "auth"
1169+
tap "Sign in with Face ID"
1170+
biometric match
1171+
see "Dashboard"
1172+
`)
1173+
steps := prog.Tests[0].Body
1174+
if len(steps) != 3 {
1175+
t.Fatalf("step count: got %d, want 3", len(steps))
1176+
}
1177+
a, ok := steps[1].(parser.ActionStep)
1178+
if !ok {
1179+
t.Fatalf("biometric step: got %T", steps[1])
1180+
}
1181+
if a.Verb != parser.VerbBiometricMatch {
1182+
t.Errorf("verb: got %q, want %q", a.Verb, parser.VerbBiometricMatch)
1183+
}
1184+
}
1185+
1186+
func TestBiometric_NoMatch(t *testing.T) {
1187+
prog := mustParse(t, `test "auth fail"
1188+
tap "Sign in with Face ID"
1189+
biometric no match
1190+
see "Authentication failed"
1191+
`)
1192+
steps := prog.Tests[0].Body
1193+
if len(steps) != 3 {
1194+
t.Fatalf("step count: got %d, want 3", len(steps))
1195+
}
1196+
a, ok := steps[1].(parser.ActionStep)
1197+
if !ok {
1198+
t.Fatalf("biometric step: got %T", steps[1])
1199+
}
1200+
if a.Verb != parser.VerbBiometricNoMatch {
1201+
t.Errorf("verb: got %q, want %q", a.Verb, parser.VerbBiometricNoMatch)
1202+
}
1203+
}

internal/parser/token.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ const (
160160
// Composite test keywords
161161
TOKEN_COMPOSITE // "composite" — starts a composite test definition
162162
TOKEN_SYNC // "sync" — cross-device barrier step inside composite tests
163+
164+
// Biometric authentication (v0.9.7+) — simulator/emulator only.
165+
TOKEN_BIOMETRIC // "biometric" — head of `biometric match` / `biometric no match`
166+
TOKEN_ENROLL // "enroll" — head of `enroll biometric`
163167
)
164168

165169
// Token is a single lexical unit.
@@ -293,6 +297,8 @@ var keywords = map[string]TokenType{
293297
"store": TOKEN_STORE,
294298
"composite": TOKEN_COMPOSITE,
295299
"sync": TOKEN_SYNC,
300+
"biometric": TOKEN_BIOMETRIC,
301+
"enroll": TOKEN_ENROLL,
296302
}
297303

298304
// fillerWords are stripped by the forgiving parser.

internal/runner/device_context.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,3 +537,101 @@ func (dc *DeviceContext) SetLocation(ctx context.Context, lat, lng string) error
537537
}
538538
return nil
539539
}
540+
541+
// EnrollBiometric sets the simulator/emulator's biometric enrollment state
542+
// to "enrolled" so the app under test sees a registered Face ID / Touch ID /
543+
// fingerprint when it requests biometric authentication.
544+
//
545+
// - iOS Simulator: sends the Darwin notification
546+
// `com.apple.BiometricKit.enrollmentChanged` (toggles state).
547+
// - Android emulator: no-op — fingerprints are enrolled in Settings before
548+
// the test runs (typically via a CI bootstrap script).
549+
// - Physical devices: skipped with a warning.
550+
func (dc *DeviceContext) EnrollBiometric(ctx context.Context) error {
551+
if dc.IsPhysical {
552+
fmt.Println(" \033[33m⚠\033[0m enroll biometric is not supported on physical devices — skipping")
553+
return nil
554+
}
555+
fmt.Println(" \033[36m🔐\033[0m Enrolling biometric")
556+
switch dc.Platform {
557+
case device.PlatformIOS:
558+
if _, err := dc.Manager.SimCtl().Spawn(ctx, dc.Serial,
559+
"notifyutil", "-s", "com.apple.BiometricKit.enrollmentChanged", "1"); err != nil {
560+
return fmt.Errorf("enroll biometric: set enrollment flag: %w", err)
561+
}
562+
if _, err := dc.Manager.SimCtl().Spawn(ctx, dc.Serial,
563+
"notifyutil", "-p", "com.apple.BiometricKit.enrollmentChanged"); err != nil {
564+
return fmt.Errorf("enroll biometric: post enrollment notification: %w", err)
565+
}
566+
case device.PlatformAndroid:
567+
// Android fingerprints are enrolled in Settings, not via adb. We
568+
// document the requirement in the .probe error message rather than
569+
// failing here — the user's CI script should pre-enroll.
570+
fmt.Println(" (Android: ensure fingerprint ID 1 is pre-enrolled in Settings)")
571+
}
572+
return nil
573+
}
574+
575+
// BiometricMatch simulates a successful biometric capture, satisfying a
576+
// pending Face ID / Touch ID / fingerprint prompt.
577+
//
578+
// - iOS Simulator: posts `com.apple.BiometricKit_Sim.fingerTouch.match`
579+
// and `.faceCapture.match` so the same step works regardless of the
580+
// simulator's biometric kind.
581+
// - Android emulator: `adb -s <serial> emu finger touch 1` (matches the
582+
// fingerprint enrolled with ID 1).
583+
// - Physical devices: skipped with a warning.
584+
func (dc *DeviceContext) BiometricMatch(ctx context.Context) error {
585+
return dc.biometricCapture(ctx, true)
586+
}
587+
588+
// BiometricNoMatch simulates a failed biometric capture so the app's
589+
// "authentication failed" path can be tested.
590+
//
591+
// - iOS Simulator: posts `*_Sim.fingerTouch.no-match` and `.faceCapture.no-match`.
592+
// - Android emulator: `adb emu finger touch 9999` (an unregistered id).
593+
// - Physical devices: skipped with a warning.
594+
func (dc *DeviceContext) BiometricNoMatch(ctx context.Context) error {
595+
return dc.biometricCapture(ctx, false)
596+
}
597+
598+
func (dc *DeviceContext) biometricCapture(ctx context.Context, match bool) error {
599+
if dc.IsPhysical {
600+
fmt.Println(" \033[33m⚠\033[0m biometric capture is not supported on physical devices — skipping")
601+
return nil
602+
}
603+
verb := "match"
604+
icon := "✓"
605+
if !match {
606+
verb = "no-match"
607+
icon = "✗"
608+
}
609+
fmt.Printf(" \033[36m🔐\033[0m Biometric capture: %s %s\n", icon, verb)
610+
switch dc.Platform {
611+
case device.PlatformIOS:
612+
// Post both fingerprint and face notifications so the same step
613+
// works on Touch ID devices and Face ID devices alike — the simulator
614+
// ignores the one that doesn't match its hardware profile.
615+
notifications := []string{
616+
fmt.Sprintf("com.apple.BiometricKit_Sim.fingerTouch.%s", verb),
617+
fmt.Sprintf("com.apple.BiometricKit_Sim.faceCapture.%s", verb),
618+
}
619+
for _, n := range notifications {
620+
if _, err := dc.Manager.SimCtl().Spawn(ctx, dc.Serial, "notifyutil", "-p", n); err != nil {
621+
return fmt.Errorf("biometric %s: post %s: %w", verb, n, err)
622+
}
623+
}
624+
case device.PlatformAndroid:
625+
// Fingerprint ID 1 is matching by convention; any unregistered ID
626+
// (we use 9999) returns no-match. The user's CI bootstrap script
627+
// enrolls fingerprint ID 1 before tests run.
628+
fingerID := "1"
629+
if !match {
630+
fingerID = "9999"
631+
}
632+
if _, err := dc.Manager.ADB().Run(ctx, dc.Serial, "emu", "finger", "touch", fingerID); err != nil {
633+
return fmt.Errorf("biometric %s: adb emu finger touch %s: %w", verb, fingerID, err)
634+
}
635+
}
636+
return nil
637+
}

internal/runner/executor.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,12 @@ func (e *Executor) stepDescription(step parser.Step) string {
340340
return fmt.Sprintf("set location %s", s.Name)
341341
case parser.VerbVerifyBrowser:
342342
return "verify external browser opened"
343+
case parser.VerbEnrollBiometric:
344+
return "enroll biometric"
345+
case parser.VerbBiometricMatch:
346+
return "biometric match"
347+
case parser.VerbBiometricNoMatch:
348+
return "biometric no match"
343349
default:
344350
return string(s.Verb)
345351
}
@@ -647,6 +653,27 @@ func (e *Executor) runAction(ctx context.Context, a parser.ActionStep) error {
647653
case parser.VerbStore:
648654
e.vars[a.Name] = e.resolve(a.Text)
649655
return nil
656+
657+
case parser.VerbEnrollBiometric:
658+
if e.deviceCtx == nil {
659+
fmt.Println(" \033[33m⚠\033[0m Skipping enroll biometric (cloud mode)")
660+
return nil
661+
}
662+
return e.deviceCtx.EnrollBiometric(ctx)
663+
664+
case parser.VerbBiometricMatch:
665+
if e.deviceCtx == nil {
666+
fmt.Println(" \033[33m⚠\033[0m Skipping biometric match (cloud mode)")
667+
return nil
668+
}
669+
return e.deviceCtx.BiometricMatch(ctx)
670+
671+
case parser.VerbBiometricNoMatch:
672+
if e.deviceCtx == nil {
673+
fmt.Println(" \033[33m⚠\033[0m Skipping biometric no-match (cloud mode)")
674+
return nil
675+
}
676+
return e.deviceCtx.BiometricNoMatch(ctx)
650677
}
651678

652679
return fmt.Errorf("unknown action verb %q at line %d", a.Verb, a.Line)

0 commit comments

Comments
 (0)