// Dialog capture harness — visual proof for dialogs the page-level // guide harness doesn't reach. Renders the target dialog against // the scriptable fake hub and writes a PNG per theme via a // RepaintBoundary (driverless, headed macOS `flutter test`). // // flutter test integration_test/dialog_shots_test.dart -d macos // // Output: build/dialog-shots/-.png (or $DIALOG_SHOTS_OUT). import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:chain_studio_flow_editor/src/l10n.dart'; import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart'; import 'package:chain_studio/data/hub.dart' show ApprovalRecord, StoreItem, ThemeModeValue; import 'package:chain_studio/data/reviewer_identity.dart'; import 'package:chain_studio/l10n/app_localizations.dart'; import 'package:chain_studio/main.dart'; import 'package:chain_studio/pages/federation.dart'; import 'package:chain_studio/widgets/chain_install_confirm.dart'; import '../test/support/fake_hub.dart'; final GlobalKey _shotKey = GlobalKey(); String get _outDir => Platform.environment['DIALOG_SHOTS_OUT'] ?? 'build/dialog-shots'; Future _shot(WidgetTester tester, String name) async { await tester.pump(const Duration(milliseconds: 150)); await tester.pump(const Duration(milliseconds: 150)); 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('dialog-shot: ${file.path}'); } StoreItem _storeItem(String verification) => StoreItem( name: 'text.extract', taglineEn: 'Extract clean text', taglineDe: 'Reinen Text extrahieren', descriptionEn: '', descriptionDe: '', category: 'text', tags: const [], requiresCapabilities: const [], requiresServices: const [], license: 'Apache-2.0', repository: '', bestVersion: '0.1.0', status: 'alpha', installed: false, featured: false, iconUrl: '', screenshotUrls: const [], docsUrl: '', kind: 'native', provider: '', source: 'bundled', installVerification: verification, maintainers: const ['Dr. Stefan Flemming (Flemming.AI) '], ); void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); for (final (themeName, mode) in [ ('light', ThemeMode.light), ('dark', ThemeMode.dark), ]) { testWidgets('add-satellite dialog — $themeName', (tester) async { SharedPreferences.setMockInitialValues({}); installFakeHub(); await tester.pumpWidget( MaterialApp( debugShowCheckedModeBanner: false, themeMode: mode, theme: ThemeData.light(useMaterial3: true), darkTheme: ThemeData.dark(useMaterial3: true), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, locale: const Locale('de'), // Wrap the whole navigator (incl. the overlay dialogs // render into) so the capture catches the dialog, not // just the page beneath it. builder: (context, child) => RepaintBoundary(key: _shotKey, child: child), home: const FederationPage(), ), ); await tester.pump(const Duration(milliseconds: 200)); await tester.tap(find.text('Satellit hinzufügen').first); await tester.pumpAndSettle(); await _shot(tester, 'add-satellite-$themeName'); expect(find.textContaining('Ein Satellit ist ein weiterer Hub'), findsOneWidget); }); } // Flow-list resolvability badge, both classified states side by // side: store-resolvable missing capability (amber chip + // Install link) vs. capability no store can install (quiet // "nicht im Store" chip; recovery paths live in its tooltip). for (final (themeName, mode) in [ ('light', ThemeMode.light), ('dark', ThemeMode.dark), ]) { testWidgets('flow-list badge states — $themeName', (tester) async { SharedPreferences.setMockInitialValues({}); await tester.pumpWidget( MaterialApp( debugShowCheckedModeBanner: false, themeMode: mode, theme: ThemeData.light(useMaterial3: true), darkTheme: ThemeData.dark(useMaterial3: true), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, locale: const Locale('de'), builder: (context, child) => RepaintBoundary(key: _shotKey, child: child), home: Scaffold( body: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 260), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ MissingModulesBadge( installable: const ['text.extract'], notInStore: const [], strings: const FlowEditorStrings(FlowEditorLocale.de), onInstall: () {}, ), const SizedBox(height: 16), MissingModulesBadge( installable: const [], notInStore: const ['acme.internal/lookup'], strings: const FlowEditorStrings(FlowEditorLocale.de), onInstall: null, ), ], ), ), ), ), ), ); await tester.pump(const Duration(milliseconds: 200)); await _shot(tester, 'flow-badge-states-$themeName'); expect(find.text('1 Modul fehlt'), findsOneWidget); expect(find.text('Installieren'), findsOneWidget); expect(find.text('nicht im Store'), findsOneWidget); }); } // Trust gate with the hub-reported verification statement — the // pinned-key (good) and policy-off (warning) variants. for (final (name, verification) in [ ('trust-gate-pinned', 'pinned-key'), ('trust-gate-unverified', 'unverified'), ]) { testWidgets('$name — dark', (tester) async { SharedPreferences.setMockInitialValues({}); installFakeHub(); await tester.pumpWidget( MaterialApp( debugShowCheckedModeBanner: false, themeMode: ThemeMode.dark, theme: ThemeData.light(useMaterial3: true), darkTheme: ThemeData.dark(useMaterial3: true), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, locale: const Locale('de'), builder: (context, child) => RepaintBoundary(key: _shotKey, child: child), home: Scaffold( body: Center( child: ChainInstallConfirmDialog( item: _storeItem(verification), ), ), ), ), ); await tester.pump(const Duration(milliseconds: 200)); await _shot(tester, '$name-dark'); }); } // Reviewer identity on the approvals inbox — the strip that names // who the audit trail will record and says, in plain words, what // that attribution is worth on this hub. Captured in both themes // because the "not verified" pill and the literal recorded value // have to stay legible in each (release gate, project CLAUDE.md). for (final (themeName, mode) in [ ('light', ThemeModeValue.light), ('dark', ThemeModeValue.dark), ]) { testWidgets('approvals reviewer identity — $themeName', (tester) async { SharedPreferences.setMockInitialValues({}); ReviewerIdentity.debugHandle = 'stefan@studio'; addTearDown(() => ReviewerIdentity.debugHandle = null); final fake = installFakeHub(); fake.approvals = [ ApprovalRecord( id: 'apr-1', flowName: 'rechnung-klassifizieren', stepId: 'pruefen', prompt: 'Bitte die Klassifikation dieser Rechnung bestätigen.', payloadPreview: '{"label":"Rechnung","betrag":"128,40 EUR"}', createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0), expiresAt: null, status: 'pending', decidedAt: null, decidedBy: '', reason: '', project: 'buergeramt', flowExecution: 'run-abc123', ), ]; tester.view.physicalSize = const Size(1280, 900); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); // The real app shell, not a bare MaterialApp: the strip has to // be judged inside the Chain theme it ships in. await tester.pumpWidget( RepaintBoundary( key: _shotKey, child: StudioApp(initialThemeMode: mode, initialLocale: Locale('de')), ), ); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byKey(const ValueKey('sidebar-item-approvals'))); for (var i = 0; i < 12; i++) { await tester.pump(const Duration(milliseconds: 200)); } await _shot(tester, 'approvals-reviewer-identity-$themeName'); expect(find.text('Sie entscheiden als'), findsOneWidget); expect( find.text('Wird gespeichert als: unverified:stefan@studio'), findsOneWidget, ); // Long-lived timers: unmount before the harness tears down. await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(const Duration(minutes: 1)); }); } }