Files
jwinterm c396e94b77 Add store capture harness: record real wallet flows into screenshots + video
Drives the real app through real flows on a device while recording the screen,
then cuts ONE recording into both deliverables — store screenshots and
marketing video — so a listing can't drift out of sync with its own trailer.

integration_test/demo/
  DemoSession adds what a passing test lacks: pacing (beat/hold/typeSlowly, so
  footage is watchable rather than a strobe), timing marks reported through
  binding.reportData, and seed guards. Scenarios reuse the existing 25 page
  robots rather than reimplementing navigation:
    core_store_set     dashboard, receive, send (composed, NEVER broadcast),
                       wallets, security — the store screenshot set
    onboarding_create  welcome -> PIN -> seed -> dashboard
    restore_from_seed  restore -> syncing
    swap_and_buy       Trocador quote, stopping BEFORE creating a trade; Buy
                       skipped when no provider key, matching what reviewers see

tools/demo/
  capture.sh   orchestrates boot/record/cut/shots/reel
  marks.py     maps timing marks onto the recording; cuts, extracts frames,
               builds manifests
  reel.py      branded master; reuses such_graphics video_transitions/video_qa
               when ~/src/such-graphics is present, degrades cleanly when not
               (this repo is public and must build without it)
  build-apk.sh local APK via the CI container image

Seed safety — the demo wallet is a real wallet with real funds:
  - seed comes from Vaultwarden at capture time, never the repo
  - guardNoSeedOnScreen() fails the run on a visible mnemonic, catching both
    the known seed and the generic shape of one (so freshly generated seeds
    are caught too)
  - unavoidable seed screens are wrapped in blindfold(); those spans are
    excised from the video with 0.6s padding, and screenshots honour the same
    padding
  - marks.py refuses to build a publishable master if any clip was cut without
    marks, since blindfold spans could not have been excised

Apple App Preview gets no touch indicators, captions, cards or music, and
reel.py enforces the 15-30s window rather than emitting an out-of-spec file.
Everything else keeps taps on, drawn by SurfaceFlinger via show_touches rather
than composited, so they look real.

Play sizing: the harness builds its own 1080x1920 AVD. The stock Medium Phone
AVD is 1080x2400 = 2.22:1 and Play rejects phone screenshots above 2:1.

Also gitignores android/app/upload-keystore.jks (and *.jks/*.keystore): the
Android workflow decodes ANDROID_KEYSTORE_BASE64 to exactly that path, so the
Play upload signing key was one `git add -A` away from being committed.
2026-07-31 15:25:29 -04:00

242 lines
9.2 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
/// Shared plumbing for the marketing / store-capture scenarios.
///
/// These scenarios are NOT correctness tests. They exist to drive the real app
/// through real flows at a pace a human can watch, while the host records the
/// screen. Three things separate them from the suites in `test_suites/`:
///
/// 1. **Pacing.** A passing test rips through screens in milliseconds. That
/// footage is unwatchable. [beat] holds on a screen long enough to read it.
///
/// 2. **Marks.** Each beat is timestamped relative to session start and
/// reported through `binding.reportData`, which the driver writes to
/// `integration_test/integration_response_data.json`. `capture.sh` reads
/// that file to build the reel manifest, so captions land on the exact
/// frame the action happens instead of being hand-timed.
///
/// 3. **Seed safety.** The demo wallet is a REAL wallet. [guardNoSeedOnScreen]
/// fails the run if a mnemonic is visible while recording, so a seed can
/// never end up in a published video. Scenarios that must pass through a
/// seed screen wrap it in [blindfold].
class DemoSession {
DemoSession(this.tester, this.binding, this.scenario);
final WidgetTester tester;
final IntegrationTestWidgetsFlutterBinding binding;
final String scenario;
final Stopwatch _clock = Stopwatch();
final List<Map<String, dynamic>> _marks = <Map<String, dynamic>>[];
/// Device-clock epoch (ms) at the moment the scenario started.
///
/// The host starts `adb screenrecord` before the app is even installed, so
/// mark timestamps (relative to scenario start) mean nothing to the video
/// on their own. capture.sh reads the device clock at record start, and the
/// difference between that and this value is the offset from the first frame
/// of video to mark 0. Both numbers come from the DEVICE clock, so host/guest
/// clock skew cannot corrupt the alignment.
int startedAtEpochMs = 0;
/// Words that, seen together in bulk, mean a mnemonic is on screen.
static const int _mnemonicWordThreshold = 12;
/// Extra secrets the host injects (the demo wallet seed) that must never be
/// rendered while the camera rolls. Populated from --dart-define.
static const String _demoSeed = String.fromEnvironment('DEMO_WALLET_SEED');
double get _elapsed => _clock.elapsedMilliseconds / 1000.0;
void begin() {
startedAtEpochMs = DateTime.now().millisecondsSinceEpoch;
_clock.start();
_marks.add(<String, dynamic>{
'at': 0.0,
'label': 'start',
'caption': null,
});
}
/// Record a beat. [caption], when given, becomes a burned caption in the
/// social reel at exactly this timestamp.
void mark(String label, {String? caption}) {
_marks.add(<String, dynamic>{
'at': double.parse(_elapsed.toStringAsFixed(3)),
'label': label,
'caption': caption,
});
tester.printToConsole('[demo:$scenario] ${_elapsed.toStringAsFixed(2)}s $label');
}
/// Hold on the current screen for [seconds] of WALL CLOCK time, pumping
/// frames throughout.
///
/// Deliberately not `pumpAndSettle`: the dashboard runs a perpetual sync
/// indicator animation, so `pumpAndSettle` would spin until it times out.
/// Pumping on a wall-clock deadline is both safe and what makes the footage
/// watchable.
Future<void> beat([double seconds = 1.6]) async {
final deadline = DateTime.now().add(
Duration(milliseconds: (seconds * 1000).round()),
);
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 33)); // ~30fps
}
}
/// Beat, then record a mark — the common "let the viewer read this" move.
Future<void> hold(String label, {String? caption, double seconds = 1.8}) async {
mark(label, caption: caption);
await beat(seconds);
}
/// Tap a finder with camera-friendly lead-in and follow-through, so the
/// viewer sees WHAT is about to be tapped before the screen changes.
Future<void> tap(
Finder finder, {
required String label,
String? caption,
double before = 0.9,
double after = 1.4,
}) async {
await beat(before);
mark(label, caption: caption);
await tester.tap(finder, warnIfMissed: false);
await beat(after);
}
/// Type text at human speed instead of instantly, which reads as a paste and
/// looks fake on camera. `perChar` is the inter-keystroke delay.
Future<void> typeSlowly(
Finder field,
String text, {
double perChar = 0.06,
}) async {
await tester.tap(field, warnIfMissed: false);
await beat(0.4);
final buffer = StringBuffer();
for (final rune in text.runes) {
buffer.writeCharCode(rune);
await tester.enterText(field, buffer.toString());
await tester.pump(Duration(milliseconds: (perChar * 1000).round()));
}
await beat(0.5);
}
/// Every rendered string currently on screen.
Iterable<String> _visibleText() sync* {
for (final element in find.byType(Text).evaluate()) {
final widget = element.widget as Text;
final data = widget.data;
if (data != null && data.isNotEmpty) yield data;
}
for (final element in find.byType(EditableText).evaluate()) {
final widget = element.widget as EditableText;
final data = widget.controller.text;
if (data.isNotEmpty) yield data;
}
}
/// Fail loudly if a mnemonic is on screen while recording.
///
/// Two independent checks, because either alone has a blind spot: the known
/// demo seed may be split across widgets, and an unknown seed (a freshly
/// generated one) will not match any known string.
void guardNoSeedOnScreen({String? because}) {
final visible = _visibleText().toList();
if (_demoSeed.isNotEmpty) {
final needle = _demoSeed.trim().toLowerCase();
final haystack = visible.join(' ').toLowerCase();
if (haystack.contains(needle)) {
fail('SEED ON CAMERA (demo wallet seed rendered) '
'${because ?? ''} — scenario "$scenario". Recording aborted.');
}
// Also catch the seed rendered one-word-per-widget.
final words = needle.split(RegExp(r'\s+')).toSet();
final onScreenWords = haystack.split(RegExp(r'[^a-z]+')).toSet();
final overlap = words.intersection(onScreenWords).length;
if (words.length >= _mnemonicWordThreshold &&
overlap >= _mnemonicWordThreshold) {
fail('SEED ON CAMERA (demo seed words rendered individually, '
'$overlap matches) ${because ?? ''} — scenario "$scenario".');
}
}
// Generic shape check: any single widget holding many lowercase words is
// almost certainly a mnemonic in this app.
for (final text in visible) {
final words = text
.trim()
.split(RegExp(r'\s+'))
.where((w) => RegExp(r'^[a-zA-Z]{3,}$').hasMatch(w))
.toList();
if (words.length >= _mnemonicWordThreshold) {
fail('SEED ON CAMERA (widget holds ${words.length} word-like tokens) '
'${because ?? ''} — scenario "$scenario". Recording aborted.');
}
}
}
/// Run [body] with the recording explicitly marked as unsafe-to-publish.
///
/// Used for the unavoidable seed screens in the create/restore scenarios.
/// The frames still get recorded — we cannot stop the host recorder from
/// here — so this brackets them with marks. `capture.sh` reads the resulting
/// `blindfold` spans and passes them to reel.py as regions to cut, and
/// refuses to emit a publishable master if any span is unhandled.
Future<void> blindfold(String reason, Future<void> Function() body) async {
mark('blindfold_start', caption: null);
tester.printToConsole(
'[demo:$scenario] BLINDFOLD START ($reason) — frames from here are '
'NOT publishable until cut',
);
try {
await body();
} finally {
mark('blindfold_end', caption: null);
tester.printToConsole('[demo:$scenario] BLINDFOLD END');
}
}
/// Publish the marks back to the host through the driver.
void finish() {
_clock.stop();
mark('end');
final existing = binding.reportData ?? <String, dynamic>{};
existing['demo'] = <String, dynamic>{
'scenario': scenario,
'started_at_epoch_ms': startedAtEpochMs,
'duration': double.parse(_elapsed.toStringAsFixed(3)),
'marks': _marks,
'blindfold_spans': _blindfoldSpans(),
};
binding.reportData = existing;
}
/// Pair up blindfold_start/blindfold_end marks into [start, end] windows.
List<Map<String, double>> _blindfoldSpans() {
final spans = <Map<String, double>>[];
double? open;
for (final m in _marks) {
if (m['label'] == 'blindfold_start') {
open = m['at'] as double;
} else if (m['label'] == 'blindfold_end' && open != null) {
spans.add(<String, double>{'start': open, 'end': m['at'] as double});
open = null;
}
}
// An unclosed span means the scenario crashed mid-seed. Treat the whole
// remainder as unsafe rather than assuming it ended.
if (open != null) {
spans.add(<String, double>{'start': open, 'end': _elapsed});
}
return spans;
}
}