diff --git a/CHANGELOG.md b/CHANGELOG.md index eeeb3c3..f081d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.9.9] - 2026-05-13 + +### Added +- **`deliver signal "name" ["value"]`** — new ProbeScript step that resolves + a pending `awaitSignal(name)` call in the Flutter app. Use to unblock any + OS-level interaction that isn't in the Flutter widget tree: push permission + dialogs, payment sheets, App Tracking Transparency, deep-link handlers, etc. + The value defaults to `"true"` when omitted. +- **`awaitSignal(String name)`** — new public function exported from + `flutter_probe_agent`. Returns a `Future` that resolves with the + value sent by the CLI. Generalises the `awaitBiometricResult()` pattern to + any named signal. +- **`DeliverSignal(String name, {String value})`** — new annotation step class + in `flutter_probe_annotation`. Emits `deliver signal "name"` or + `deliver signal "name" "value"`. +- Parser: `TOKEN_DELIVER`, `TOKEN_SIGNAL`, `VerbDeliverSignal`. +- Agent: `probe.signal` JSON-RPC method, `ProbeMethods.signal` constant. + ## [0.9.8] - 2026-05-12 ### Fixed diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index b0005fc..7217246 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.8**. +FlutterProbe is in active development. Current version: **0.9.9**. ### Repository Structure diff --git a/internal/parser/ast.go b/internal/parser/ast.go index 7174cb6..00a7616 100644 --- a/internal/parser/ast.go +++ b/internal/parser/ast.go @@ -170,6 +170,11 @@ const ( VerbEnrollBiometric ActionVerb = "enroll_biometric" VerbBiometricMatch ActionVerb = "biometric_match" VerbBiometricNoMatch ActionVerb = "biometric_no_match" + + // Native-prompt signal API (v0.9.9+) + // deliver signal "name" ["value"] + // Name = signal name, Text = value (default "true") + VerbDeliverSignal ActionVerb = "deliver_signal" ) type SwipeDirection string diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 93551d6..83d21cb 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -399,6 +399,8 @@ func (p *Parser) parseStep() (Step, error) { return p.parseBiometric() case TOKEN_ENROLL: return p.parseEnrollBiometric() + case TOKEN_DELIVER: + return p.parseDeliverSignal() case TOKEN_NEWLINE: p.advance() return nil, nil @@ -821,13 +823,27 @@ func (p *Parser) parseWait() (Step, error) { } } + // "wait [for] animations to end" — TOKEN_FOR_KW is a filler so skipFillers() + // above may have already consumed it; check TOKEN_ANIMATIONS directly first. + if tok.Type == TOKEN_ANIMATIONS { + p.advance() + // consume trailing words ("to end", "to finish") up to the newline + for p.peek().Type != TOKEN_NEWLINE && p.peek().Type != TOKEN_EOF { + p.advance() + } + p.consumeNewline() + return WaitStep{Kind: WaitAnimations, Line: line}, nil + } + // "wait for animations to end" / "wait for the page to load" if tok.Type == TOKEN_FOR_KW || p.peekLiteral("for") { p.advance() p.skipFillers() if p.peek().Type == TOKEN_ANIMATIONS { p.advance() - p.skipFillers() // "to end" / "to finish" + for p.peek().Type != TOKEN_NEWLINE && p.peek().Type != TOKEN_EOF { + p.advance() + } p.consumeNewline() return WaitStep{Kind: WaitAnimations, Line: line}, nil } @@ -1574,6 +1590,32 @@ func (p *Parser) parseStore() (Step, error) { return ActionStep{Verb: VerbStore, Text: value, Name: varName, Line: line}, nil } +// parseDeliverSignal parses: +// +// deliver signal "name" +// deliver signal "name" "value" +// +// Resolves a pending awaitSignal(name) call in the Flutter app. +// If value is omitted it defaults to "true". +func (p *Parser) parseDeliverSignal() (Step, error) { + line := p.peek().Line + p.advance() // deliver + p.skipFillers() + if p.peek().Type != TOKEN_SIGNAL { + return nil, fmt.Errorf("line %d: expected 'signal' after 'deliver'", line) + } + p.advance() // signal + p.skipFillers() + name := p.expectString("signal name") + p.skipFillers() + value := "true" + if p.peek().Type == TOKEN_STRING { + value = p.advance().Literal + } + p.consumeNewline() + return ActionStep{Verb: VerbDeliverSignal, Name: name, Text: value, Line: line}, nil +} + // parseBiometric parses one of: // // biometric match diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index ff9e06e..a7d25b4 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -1201,3 +1201,42 @@ func TestBiometric_NoMatch(t *testing.T) { t.Errorf("verb: got %q, want %q", a.Verb, parser.VerbBiometricNoMatch) } } + +func TestDeliverSignal(t *testing.T) { + prog := mustParse(t, ` +test "signal" + deliver signal "payment_confirmed" + deliver signal "push_token" "abc123" +`) + steps := prog.Tests[0].Body + if len(steps) != 2 { + t.Fatalf("step count: got %d, want 2", len(steps)) + } + + // default value "true" + a0, ok := steps[0].(parser.ActionStep) + if !ok { + t.Fatalf("step 0: got %T", steps[0]) + } + if a0.Verb != parser.VerbDeliverSignal { + t.Errorf("verb: got %q, want %q", a0.Verb, parser.VerbDeliverSignal) + } + if a0.Name != "payment_confirmed" { + t.Errorf("name: got %q, want %q", a0.Name, "payment_confirmed") + } + if a0.Text != "true" { + t.Errorf("value: got %q, want %q", a0.Text, "true") + } + + // explicit value + a1, ok := steps[1].(parser.ActionStep) + if !ok { + t.Fatalf("step 1: got %T", steps[1]) + } + if a1.Name != "push_token" { + t.Errorf("name: got %q, want %q", a1.Name, "push_token") + } + if a1.Text != "abc123" { + t.Errorf("value: got %q, want %q", a1.Text, "abc123") + } +} diff --git a/internal/parser/token.go b/internal/parser/token.go index 7376452..5b3e446 100644 --- a/internal/parser/token.go +++ b/internal/parser/token.go @@ -164,6 +164,10 @@ const ( // 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` + + // Native-prompt signal API (v0.9.9+) + TOKEN_DELIVER // "deliver" — head of `deliver signal "name"` + TOKEN_SIGNAL // "signal" — second word of deliver signal ) // Token is a single lexical unit. @@ -299,6 +303,8 @@ var keywords = map[string]TokenType{ "sync": TOKEN_SYNC, "biometric": TOKEN_BIOMETRIC, "enroll": TOKEN_ENROLL, + "deliver": TOKEN_DELIVER, + "signal": TOKEN_SIGNAL, } // fillerWords are stripped by the forgiving parser. diff --git a/internal/probelink/protocol.go b/internal/probelink/protocol.go index a2e52ca..9e5174d 100644 --- a/internal/probelink/protocol.go +++ b/internal/probelink/protocol.go @@ -200,6 +200,11 @@ const ( // Dart Completer resolves without depending on LAContext behaviour. MethodBiometricSignal = "probe.biometric_signal" + // Native-prompt signal API (v0.9.9) + // Sent by `deliver signal "name" ["value"]` — resolves a pending + // awaitSignal(name) call in the Flutter app. + MethodSignal = "probe.signal" + // Notification methods (agent → CLI, no response expected) NotifyRecordedEvent = "probe.recorded_event" NotifyExecDart = "probe.exec_dart" diff --git a/internal/runner/executor.go b/internal/runner/executor.go index d250a8e..bfe60ce 100644 --- a/internal/runner/executor.go +++ b/internal/runner/executor.go @@ -346,6 +346,8 @@ func (e *Executor) stepDescription(step parser.Step) string { return "biometric match" case parser.VerbBiometricNoMatch: return "biometric no match" + case parser.VerbDeliverSignal: + return fmt.Sprintf("deliver signal %q", s.Name) default: return string(s.Verb) } @@ -367,6 +369,8 @@ func (e *Executor) stepDescription(step parser.Step) string { return "wait for page to load" case parser.WaitNetworkIdle: return "wait for network idle" + case parser.WaitAnimations: + return "wait for animations to end" default: return "wait" } @@ -682,6 +686,17 @@ func (e *Executor) runAction(ctx context.Context, a parser.ActionStep) error { } _, err := e.client.Call(ctx, probelink.MethodBiometricSignal, map[string]any{"result": false}) return err + + case parser.VerbDeliverSignal: + value := a.Text + if value == "" { + value = "true" + } + _, err := e.client.Call(ctx, probelink.MethodSignal, map[string]any{ + "name": a.Name, + "value": value, + }) + return err } 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 35d1c2c..ae866b0 100644 --- a/probe_agent/CHANGELOG.md +++ b/probe_agent/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.9.9 - 2026-05-13 + +- **`awaitSignal(String name)`** — new public function. Blocks until the CLI + delivers `deliver signal "name"`. Returns the value string sent with the + step (default `"true"`). Use to unblock any OS-level interaction not in + the Flutter widget tree: push permission prompts, payment sheets, App + Tracking Transparency, custom deep-link handlers, etc. +- New `probe.signal` JSON-RPC method handled by `ProbeExecutor`. + ## 0.9.8 - 2026-05-12 - **`awaitBiometricResult()`** — new public function exported from diff --git a/probe_agent/lib/flutter_probe_agent.dart b/probe_agent/lib/flutter_probe_agent.dart index ba5d43c..21dfd8d 100644 --- a/probe_agent/lib/flutter_probe_agent.dart +++ b/probe_agent/lib/flutter_probe_agent.dart @@ -27,3 +27,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; +export 'src/signal.dart' show awaitSignal; diff --git a/probe_agent/lib/src/executor.dart b/probe_agent/lib/src/executor.dart index 91df057..6c95979 100644 --- a/probe_agent/lib/src/executor.dart +++ b/probe_agent/lib/src/executor.dart @@ -14,6 +14,7 @@ import 'biometric.dart' as biometric; import 'finder.dart'; import 'protocol.dart'; import 'recorder.dart'; +import 'signal.dart' as signal_lib; import 'sync.dart'; typedef SendFn = void Function(String message); @@ -269,6 +270,12 @@ class ProbeExecutor { biometric.completeBiometricResult(result); return {}; + case ProbeMethods.signal: + final name = (req.params['name'] as String?) ?? ''; + final value = (req.params['value'] as String?) ?? 'true'; + signal_lib.deliverSignal(name, value); + return {}; + default: throw ProbeError(ProbeError.methodNotFound, 'Unknown method: ${req.method}'); } diff --git a/probe_agent/lib/src/protocol.dart b/probe_agent/lib/src/protocol.dart index 026a142..0fd2101 100644 --- a/probe_agent/lib/src/protocol.dart +++ b/probe_agent/lib/src/protocol.dart @@ -127,6 +127,9 @@ class ProbeMethods { // Biometric simulation signal (v0.9.8) static const biometricSignal = 'probe.biometric_signal'; + // Native-prompt signal API (v0.9.9) + static const signal = 'probe.signal'; + // Notification methods (agent → CLI) static const notifyRecordedEvent = 'probe.recorded_event'; static const notifyExecDart = 'probe.exec_dart'; diff --git a/probe_agent/lib/src/signal.dart b/probe_agent/lib/src/signal.dart new file mode 100644 index 0000000..2dddb94 --- /dev/null +++ b/probe_agent/lib/src/signal.dart @@ -0,0 +1,38 @@ +import 'dart:async'; + +// name → pending Completer +final _pending = >{}; + +/// Waits for the CLI to deliver a named signal via `deliver signal "name"`. +/// +/// Returns the value sent with the step (defaults to `"true"` when no value +/// is specified in the ProbeScript). Use this to block on any OS-level +/// interaction the probe can't tap directly — system permission dialogs, +/// payment sheets, push notification prompts, etc.: +/// +/// ```dart +/// import 'package:flutter_probe_agent/flutter_probe_agent.dart'; +/// +/// // In your widget: +/// if (const bool.fromEnvironment('PROBE_AGENT')) { +/// await awaitSignal('push_permission_granted'); +/// } else { +/// await requestNotificationPermission(); +/// } +/// ``` +/// +/// ProbeScript side: +/// ``` +/// allow permission "notifications" +/// deliver signal "push_permission_granted" +/// ``` +Future awaitSignal(String name) { + final completer = Completer(); + _pending[name] = completer; + return completer.future; +} + +/// Called by [ProbeExecutor] when [probe.signal] arrives from the CLI. +void deliverSignal(String name, String value) { + _pending.remove(name)?.complete(value); +} diff --git a/probe_agent/pubspec.yaml b/probe_agent/pubspec.yaml index 39a282f..24a8dd7 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.8 +version: 0.9.9 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/lib/src/steps.dart b/probe_annotation/lib/src/steps.dart index 3b33c61..ff2364a 100644 --- a/probe_annotation/lib/src/steps.dart +++ b/probe_annotation/lib/src/steps.dart @@ -259,6 +259,30 @@ class BiometricNoMatch extends Step { const BiometricNoMatch(); } +// ---- Native-prompt signal API ---- + +/// Emits: `deliver signal "name"` or `deliver signal "name" "value"`. +/// +/// Resolves a pending [awaitSignal] call in the Flutter app. Use to unblock +/// any OS-level interaction the probe cannot tap directly — permission dialogs +/// not in the widget tree, payment sheets, push notification prompts, etc. +/// +/// ```dart +/// @ProbeSuite(tests: [ +/// ProbeTest('push permission granted', steps: [ +/// Open(), +/// DeliverSignal('push_permission'), +/// See('Notifications enabled'), +/// ]), +/// ]) +/// class NotificationScreen extends StatelessWidget {} +/// ``` +class DeliverSignal extends Step { + final String name; + final String value; + const DeliverSignal(this.name, {this.value = 'true'}); +} + // ---- Diagnostics ---- /// Emits: `take screenshot "name"`. diff --git a/probe_annotation/pubspec.yaml b/probe_annotation/pubspec.yaml index c4c3af3..4d24d36 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.7 +version: 0.9.9 homepage: https://flutterprobe.dev repository: https://github.com/AlphaWaveSystems/flutter-probe issue_tracker: https://github.com/AlphaWaveSystems/flutter-probe/issues diff --git a/probe_gen/lib/src/probe_emitter.dart b/probe_gen/lib/src/probe_emitter.dart index 1110630..5b33c81 100644 --- a/probe_gen/lib/src/probe_emitter.dart +++ b/probe_gen/lib/src/probe_emitter.dart @@ -283,6 +283,15 @@ class ProbeEmitter { case 'BiometricNoMatch': _buf.writeln('${indent}biometric no match'); break; + case 'DeliverSignal': + final sigName = _str(step, 'name') ?? ''; + final sigValue = _str(step, 'value') ?? 'true'; + if (sigValue == 'true') { + _buf.writeln('${indent}deliver signal "${_escape(sigName)}"'); + } else { + _buf.writeln('${indent}deliver signal "${_escape(sigName)}" "${_escape(sigValue)}"'); + } + break; // Diagnostics case 'TakeScreenshot': diff --git a/probe_gen/pubspec.yaml b/probe_gen/pubspec.yaml index a355b39..370c013 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.7 +version: 0.9.9 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.7 + flutter_probe_annotation: ^0.9.9 source_gen: ^4.0.0 dev_dependencies: diff --git a/probe_gen/test/fixtures/signal_delivery.dart b/probe_gen/test/fixtures/signal_delivery.dart new file mode 100644 index 0000000..575a86d --- /dev/null +++ b/probe_gen/test/fixtures/signal_delivery.dart @@ -0,0 +1,17 @@ +import 'package:flutter_probe_annotation/flutter_probe_annotation.dart'; + +@ProbeSuite( + tests: [ + ProbeTest('deliver default signal', steps: [ + Open(), + DeliverSignal('push_permission'), + See('Notifications enabled'), + ]), + ProbeTest('deliver signal with value', steps: [ + Open(), + DeliverSignal('payment_result', value: 'success'), + See('Payment confirmed'), + ]), + ], +) +class SignalScreen {} diff --git a/probe_gen/test/fixtures/signal_delivery.probe.golden b/probe_gen/test/fixtures/signal_delivery.probe.golden new file mode 100644 index 0000000..0b8feb8 --- /dev/null +++ b/probe_gen/test/fixtures/signal_delivery.probe.golden @@ -0,0 +1,12 @@ +# Generated by flutter_probe_gen — do not edit +# Source: lib/signal_delivery.dart + +test "deliver default signal" + open the app + deliver signal "push_permission" + see "Notifications enabled" + +test "deliver signal with value" + open the app + deliver signal "payment_result" "success" + see "Payment confirmed" diff --git a/vscode/package.json b/vscode/package.json index 4ec5f20..3366585 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.8", + "version": "0.9.9", "publisher": "flutterprobe", "icon": "resources/probe-icon.png", "engines": { "vscode": "^1.85.0" },