// Deterministic operator-guide screenshot harness (one command // instead of the fragile cliclick/screencapture procedure). // // Boots a hermetic hub (HubFixture: temp data dir, free port), // seeds deterministic demo data via the CLI (an open "Bürgeramt" // project; optionally a sealed "Ratsinformation" when the runner // confirms an isolated $HOME), launches the real app in German + // dark mode against that hub, walks every sidebar destination in // nav order, opens the workspace switcher and the guided-setup // wizard, and writes the guide PNGs via a RepaintBoundary capture // (driverless — works headed on macOS with plain `flutter test`). // // Run through the platform repo's scripts/regen-studio-guide.sh, // which isolates $HOME, builds the hub binary, and copies the // PNGs into fai_chain/docs/studio/images/. Direct invocation: // // flutter test integration_test/guide_shots_test.dart -d macos // // Output lands in build/guide-shots/ unless GUIDE_SHOTS_OUT is // set in the environment. // // Hermeticity notes: // - SharedPreferences is mocked empty — the operator's real Studio // prefs are neither read nor written. // - The app reconnects to the fixture hub with persist: false. // - The sealed seed writes to $HOME/.chain/sealed/ by design, so // it only runs when CHAIN_GUIDE_SHOTS_SEALED=1 confirms the // caller isolated $HOME (the regen script does). import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint; import 'package:chain_studio/data/hub.dart'; import 'package:chain_studio/main.dart'; import 'package:chain_studio/widgets/widgets.dart'; import '../test/integration/hub_fixture.dart'; final GlobalKey _shotKey = GlobalKey(); /// Pump a fixed number of frames instead of pumpAndSettle: the /// sidebar clock ticks every second, so the tree never settles and /// pumpAndSettle degenerates into its multi-minute timeout. Future _pumpFrames(WidgetTester tester, [int frames = 20]) async { for (var i = 0; i < frames; i++) { await tester.pump(const Duration(milliseconds: 100)); } } /// Pump until [finder] matches (page content arrived) or ~10 s /// pass — pages load their data from the hub asynchronously, and a /// blind fixed wait raced the slower ones (the flows editor). Future _pumpUntil( WidgetTester tester, Finder finder, { int maxFrames = 100, }) async { for (var i = 0; i < maxFrames; i++) { if (finder.evaluate().isNotEmpty) return; await tester.pump(const Duration(milliseconds: 100)); } } String get _outDir => Platform.environment['GUIDE_SHOTS_OUT'] ?? 'build/guide-shots'; /// Guide images ship dark; GUIDE_SHOTS_THEME=light exists for the /// light-parity proof runs (release gate: both themes must hold). ThemeModeValue get _theme => Platform.environment['GUIDE_SHOTS_THEME'] == 'light' ? ThemeModeValue.light : ThemeModeValue.dark; /// Guide images ship German; GUIDE_SHOTS_LOCALE=en exists for /// English-parity proof runs. Locale get _locale => Locale( Platform.environment['GUIDE_SHOTS_LOCALE'] == 'en' ? 'en' : 'de', ); /// Rasterize the app's RepaintBoundary into `/.png`. Future _shot(WidgetTester tester, String name) async { // Two extra frames so ripples/route transitions settle visually. await tester.pump(const Duration(milliseconds: 120)); await tester.pump(const Duration(milliseconds: 120)); final boundary = _shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary; final image = await boundary.toImage(pixelRatio: 2.0); final bytes = await image.toByteData(format: ui.ImageByteFormat.png); image.dispose(); final file = File('$_outDir/$name.png'); file.parent.createSync(recursive: true); file.writeAsBytesSync(bytes!.buffer.asUint8List()); // ignore: avoid_print print('guide-shot: ${file.path}'); } Future _cli( String binary, HubFixture fixture, List args, ) async { final r = await Process.run( binary, args, environment: { ...Platform.environment, 'CHAIN_DATA_DIR': fixture.tempDir.path, 'CHAIN_MODULES_DIR': '${fixture.tempDir.path}/modules', }, ); if (r.exitCode != 0) { fail('seed command `chain ${args.join(' ')}` failed: ${r.stderr}'); } } /// The guide's image set, in sidebar (= Cmd number) order. Must /// stay aligned with docs/nav.generated.json — the nav guard in /// the platform repo keeps the guide itself honest. const _pageShots = [ ('welcome', '01-willkommen'), ('store', '02-store'), ('doctor', '03-diagnose'), ('flows', '04-flows'), ('audit', '05-protokoll'), ('approvals', '06-freigaben'), ('runs', '07-laeufe'), ('federation', '08-foederation'), ]; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('capture the operator-guide screenshots', (tester) async { final fixture = await HubFixture.start(skipIfBinaryMissing: false); addTearDown(fixture!.dispose); final binary = (await HubFixture.binaryPath())!; // Deterministic demo data. The slug is explicit — the derived // one would drop the umlaut ("brgeramt"). await _cli(binary, fixture, [ 'project', 'create', 'Bürgeramt', '--slug', 'buergeramt', '--color', '#2e8f9e', ]); // The sealed area writes a manifest under $HOME/.chain/sealed/, // so it needs the runner's confirmation that $HOME is isolated. final sealedSeed = Platform.environment['CHAIN_GUIDE_SHOTS_SEALED'] == '1'; if (sealedSeed) { await _cli(binary, fixture, [ 'project', 'create', 'Ratsinformation', '--slug', 'ratsinformation', '--isolation', 'sealed', ]); // Seed one audit event the guide can show (setup.applied) by // applying a minimal guided setup against the fixture's data // dir. Writes $HOME/.chain/config.yaml, hence the isolated- // $HOME guard: only the regen script's throwaway HOME is // ever touched. final answers = File('${fixture.tempDir.path}/seed-answers.yaml'); answers.writeAsStringSync( 'scenario: trying-out\nintent: hello-world\ntarget: this-laptop\n', ); await _cli(binary, fixture, [ 'init', '--answers', answers.path, '--apply', '--force', ]); } // Hermetic prefs: never read or write the operator's real ones. SharedPreferences.setMockInitialValues({}); await tester.pumpWidget( RepaintBoundary( key: _shotKey, child: StudioApp( initialThemeMode: _theme, initialLocale: _locale, ), ), ); await HubService.instance.reconnect( HubEndpoint(host: '127.0.0.1', port: fixture.port), authToken: null, persist: false, ); await _pumpFrames(tester, 30); final shell = tester.state(find.byType(StudioShell)); // 01–08: every sidebar destination, in Cmd order. Where a page // loads slower content from the hub, wait for a sentinel that // proves the content arrived before capturing. for (final (id, shotName) in _pageShots) { shell.navigateTo(id); await _pumpFrames(tester); if (id == 'flows') { // The imported sample flows populate the file list. await _pumpUntil(tester, find.textContaining('extract-summarize')); } else if (id == 'audit') { // The seeded setup.applied event (isolated-$HOME runs). await _pumpUntil(tester, find.textContaining('setup.applied')); } await _shot(tester, shotName); } // 09: the workspace switcher, opened (shows the demo projects; // with the sealed seed also the shield entry). shell.navigateTo('audit'); await _pumpFrames(tester); final switcher = find.byType(ChainWorkspaceSwitcher); if (switcher.evaluate().isNotEmpty) { await tester.tap(switcher.first); await _pumpFrames(tester); await _shot(tester, '09-projekt-umschalter'); // Close the menu again before moving on. await tester.sendKeyEvent(LogicalKeyboardKey.escape); await _pumpFrames(tester, 5); } // 11: the guided-setup wizard, step 1 (localized option cards). // 10 (inside a sealed area) still needs the manual procedure — // it requires switching to a second, isolated hub instance. // The wizard no longer lives on Welcome (first-run gate owns a // fresh install; re-running is a Settings action) — capture it // via Settings → General like an operator would reach it. shell.navigateTo('welcome'); await _pumpFrames(tester); await tester.sendKeyEvent(LogicalKeyboardKey.escape); // close leftovers await _pumpFrames(tester, 5); ChainSettingsDialog.show(tester.element(find.byType(StudioShell))); await _pumpFrames(tester); final runSetup = find.textContaining( _locale.languageCode == 'en' ? 'Run setup again' : 'Einrichtung erneut', ); if (runSetup.evaluate().isNotEmpty) { await tester.ensureVisible(runSetup.first); await tester.tap(runSetup.first); await _pumpFrames(tester); await _shot(tester, '11-einrichtung'); await tester.sendKeyEvent(LogicalKeyboardKey.escape); await _pumpFrames(tester, 5); await tester.sendKeyEvent(LogicalKeyboardKey.escape); // settings too await _pumpFrames(tester, 5); } }); }