From 065939be748b4d10a63b191af1e49cc58b1495dd Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 17 Jul 2026 00:59:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(setup):=20first-run=20gate,=20hub-first=20?= =?UTF-8?q?preview,=20honest=20wording=20=E2=80=94=20setup=20before=20the?= =?UTF-8?q?=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stefan's live findings, all four addressed at the root: - 'In 3 Fragen loslegen' read like ad copy → the entry is now plainly 'Einrichtung starten' / 'Start setup'. - The setup button sat permanently on the Welcome page of a running app ('setup after the app runs is backwards' — reported twice). A fresh install now starts INSIDE the setup: SetupGateScreen hosts the wizard embedded as the page (new embedded/onFinished modes on GuidedSetupDialog), with an explicit 'Später einrichten' skip. Welcome loses the setup button entirely and stays a calm intro. - Re-running the setup later lives in Settings → General ('Run setup again…'), the single post-first-run home. - 'You must grant access first and only then see what will be done': the preview used to spawn the chain CLI, whose first run could pop the macOS permission prompt BEFORE the plan was ever shown. The preview now calls the new PlanSetup RPC over the live hub connection (no subprocess, nothing granted); the CLI remains only a fallback when no hub is reachable — and applying stays the explicit, separate step. Widget tests: gate hosts the wizard + skip/cancel leave it; CLI-path tests drive the fallback through the new hub-preview test seam. Suite 76 green, analyze clean. Signed-off-by: flemming-it --- lib/data/hub.dart | 35 +++++++++ lib/l10n/app_de.arb | 6 +- lib/l10n/app_en.arb | 6 +- lib/l10n/app_localizations.dart | 26 ++++++- lib/l10n/app_localizations_de.dart | 16 +++- lib/l10n/app_localizations_en.dart | 16 +++- lib/main.dart | 20 ++++- lib/pages/setup_gate.dart | 81 ++++++++++++++++++++ lib/pages/welcome.dart | 50 ++---------- lib/widgets/chain_settings_dialog.dart | 21 +++++ lib/widgets/guided_setup_dialog.dart | 102 ++++++++++++++++++++++--- test/guided_setup_test.dart | 5 ++ test/setup_gate_test.dart | 55 +++++++++++++ 13 files changed, 377 insertions(+), 62 deletions(-) create mode 100644 lib/pages/setup_gate.dart create mode 100644 test/setup_gate_test.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 68a149e..992e9af 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -259,6 +259,41 @@ class HubService { /// hub is up and needs a token fix, not an endpoint fix. Future probeHealth() => _client.probe(); + /// Assemble the guided-setup plan over the live hub connection — + /// a pure computation, nothing applied. Returned in exactly the + /// `chain init --plan-json` map shape so the wizard renders the + /// same preview from either source. Using the hub avoids spawning + /// the CLI for the preview: on macOS that subprocess can be the + /// app's first file-system touch and pop a permission prompt + /// BEFORE the operator saw what would be set up. + Future> planSetup({ + required String scenario, + required String intent, + required String target, + bool requireApproval = false, + bool dataMustStayLocal = false, + bool allowUnsignedModules = false, + }) async { + final r = await _client.planSetup( + scenario: scenario, + intent: intent, + target: target, + requireApproval: requireApproval, + dataMustStayLocal: dataMustStayLocal, + allowUnsignedModules: allowUnsignedModules, + ); + return { + 'profile': r.profile, + 'modules': List.from(r.modules), + 'starter_flow': r.starterFlow, + 'runbook': r.runbook, + 'curated_docs': List.from(r.curatedDocs), + 'require_signatures': r.requireSignatures, + 'worm_audit': r.wormAudit, + 'approval_step': r.approvalStep, + }; + } + /// Configured module stores (+ the bundled seed) for the store manager. Future> listStores() => _client.listStores(); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index eb50312..b3e54d4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1791,7 +1791,11 @@ "setupSigPublicStoreNotice": "Diese Einrichtung lässt nur signierte Module zu. Der öffentliche Store liefert zurzeit unsignierte Pakete — die vorbereiteten Module kommen daher aus einer eigenen, signierten Quelle.", "setupAllowUnsigned": "Installation aus dem öffentlichen Store erlauben", "setupAllowUnsignedSub": "Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.", - "setupStartCta": "In 3 Fragen loslegen", + "setupStartCta": "Einrichtung starten", + "setupGateIntro": "Bevor es losgeht: drei Fragen, dann richtet Ch∆In diesen Rechner passend ein. Nichts wird ohne Ihre Bestätigung geändert.", + "setupGateSkip": "Später einrichten", + "settingsRunSetup": "Einrichtung erneut ausführen…", + "settingsRunSetupHint": "Öffnet den Einrichtungs-Assistenten (drei Fragen, Vorschau, Übernehmen). Die bestehende Konfiguration wird erst nach Ihrer Bestätigung ersetzt.", "setupPlanRunbookLocal": "Eingerichtet wird dieser Rechner; der Hub läuft lokal und wird bei Bedarf gestartet.", "setupPlanRunbookService": "Eingerichtet wird dieser Rechner: Der Hub startet künftig automatisch beim Hochfahren (Hintergrund-Dienst). Für einen anderen Server führen Sie die Einrichtung dort aus.", "setupPlanRunbookAirgap": "Vorgesehen für einen isolierten Server ohne Internet — Module und Updates kommen als Offline-Paket.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bb44a2b..45898d0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1830,7 +1830,11 @@ "setupSigPublicStoreNotice": "This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.", "setupAllowUnsigned": "Allow installing from the public store", "setupAllowUnsignedSub": "Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.", - "setupStartCta": "Get started in 3 questions", + "setupStartCta": "Start setup", + "setupGateIntro": "Before you start: three questions, then Ch∆In sets this machine up to match. Nothing changes without your confirmation.", + "setupGateSkip": "Set up later", + "settingsRunSetup": "Run setup again…", + "settingsRunSetupHint": "Opens the setup assistant (three questions, preview, apply). Your existing configuration is only replaced after you confirm.", "setupPlanRunbookLocal": "This machine is being set up; the hub runs locally and starts on demand.", "setupPlanRunbookService": "This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.", "setupPlanRunbookAirgap": "Intended for an isolated server without internet — modules and updates arrive as offline bundles.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 47232ad..c794973 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5524,9 +5524,33 @@ abstract class AppLocalizations { /// No description provided for @setupStartCta. /// /// In en, this message translates to: - /// **'Get started in 3 questions'** + /// **'Start setup'** String get setupStartCta; + /// No description provided for @setupGateIntro. + /// + /// In en, this message translates to: + /// **'Before you start: three questions, then Ch∆In sets this machine up to match. Nothing changes without your confirmation.'** + String get setupGateIntro; + + /// No description provided for @setupGateSkip. + /// + /// In en, this message translates to: + /// **'Set up later'** + String get setupGateSkip; + + /// No description provided for @settingsRunSetup. + /// + /// In en, this message translates to: + /// **'Run setup again…'** + String get settingsRunSetup; + + /// No description provided for @settingsRunSetupHint. + /// + /// In en, this message translates to: + /// **'Opens the setup assistant (three questions, preview, apply). Your existing configuration is only replaced after you confirm.'** + String get settingsRunSetupHint; + /// No description provided for @setupPlanRunbookLocal. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2a7ccc0..4a4a454 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3270,7 +3270,21 @@ class AppLocalizationsDe extends AppLocalizations { 'Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.'; @override - String get setupStartCta => 'In 3 Fragen loslegen'; + String get setupStartCta => 'Einrichtung starten'; + + @override + String get setupGateIntro => + 'Bevor es losgeht: drei Fragen, dann richtet Ch∆In diesen Rechner passend ein. Nichts wird ohne Ihre Bestätigung geändert.'; + + @override + String get setupGateSkip => 'Später einrichten'; + + @override + String get settingsRunSetup => 'Einrichtung erneut ausführen…'; + + @override + String get settingsRunSetupHint => + 'Öffnet den Einrichtungs-Assistenten (drei Fragen, Vorschau, Übernehmen). Die bestehende Konfiguration wird erst nach Ihrer Bestätigung ersetzt.'; @override String get setupPlanRunbookLocal => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c9a0a47..18289f3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3265,7 +3265,21 @@ class AppLocalizationsEn extends AppLocalizations { 'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.'; @override - String get setupStartCta => 'Get started in 3 questions'; + String get setupStartCta => 'Start setup'; + + @override + String get setupGateIntro => + 'Before you start: three questions, then Ch∆In sets this machine up to match. Nothing changes without your confirmation.'; + + @override + String get setupGateSkip => 'Set up later'; + + @override + String get settingsRunSetup => 'Run setup again…'; + + @override + String get settingsRunSetupHint => + 'Opens the setup assistant (three questions, preview, apply). Your existing configuration is only replaced after you confirm.'; @override String get setupPlanRunbookLocal => diff --git a/lib/main.dart b/lib/main.dart index 66e2832..bf3f29a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,8 @@ import 'data/system_actions.dart'; import 'data/theme_plugin.dart'; import 'l10n/app_localizations.dart'; import 'pages/approvals.dart'; +import 'pages/setup_gate.dart'; +import 'widgets/guided_setup_dialog.dart'; import 'pages/audit.dart'; import 'pages/doctor.dart'; import 'pages/federation.dart'; @@ -114,6 +116,13 @@ class StudioApp extends StatefulWidget { } class StudioAppState extends State { + /// First-run gate: on a fresh install (no config, no recorded + /// setup plan) the app starts INSIDE the setup instead of layering + /// a dialog over a shell that isn't set up yet. Flips to true when + /// the operator finishes or skips; also true from the start on any + /// already-configured machine. + bool _setupGateDone = !GuidedSetupDialog.isFreshInstall(); + /// Exposed so any descendant can listen via /// `ValueListenableBuilder` and rebuild instantly when the /// theme flips. setState alone wouldn't propagate through @@ -263,9 +272,14 @@ class StudioAppState extends State { supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, - home: StudioShell( - startSidebarExpanded: widget.startSidebarExpanded, - ), + home: _setupGateDone + ? StudioShell( + startSidebarExpanded: widget.startSidebarExpanded, + ) + : SetupGateScreen( + onDone: () => + setState(() => _setupGateDone = true), + ), ); }, ), diff --git a/lib/pages/setup_gate.dart b/lib/pages/setup_gate.dart new file mode 100644 index 0000000..32555ba --- /dev/null +++ b/lib/pages/setup_gate.dart @@ -0,0 +1,81 @@ +// First-run gate. On a fresh install (no ~/.chain/config.yaml, no +// setup-plan.yaml) the app opens INTO the setup instead of showing +// the full shell with a wizard dialog on top — setup before the +// app, not after. The guided-setup wizard renders embedded as the +// page content; "Später einrichten" skips into the app for this +// run (the gate returns on the next launch as long as nothing is +// configured — the wizard IS the onboarding until then). + +import 'package:flutter/material.dart'; + +import '../l10n/app_localizations.dart'; +import '../theme/tokens.dart'; +import '../widgets/guided_setup_dialog.dart'; + +class SetupGateScreen extends StatelessWidget { + /// Called when the operator finishes the setup or skips — the + /// app swaps this screen for the normal shell. + final VoidCallback onDone; + + const SetupGateScreen({super.key, required this.onDone}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Scaffold( + backgroundColor: theme.scaffoldBackgroundColor, + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(ChainSpace.xl), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ch∆In Studio', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: ChainSpace.xs), + Text( + l.setupGateIntro, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: ChainSpace.lg), + Container( + padding: const EdgeInsets.all(ChainSpace.xl), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(ChainRadius.md), + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + ), + child: GuidedSetupDialog( + embedded: true, + onFinished: onDone, + ), + ), + const SizedBox(height: ChainSpace.sm), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: onDone, + child: Text(l.setupGateSkip), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 301a5c4..52bcaaf 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -30,35 +30,10 @@ class WelcomePage extends StatefulWidget { } class _WelcomePageState extends State { - /// Bumped when the guided-setup dialog closes so the onboarding - /// checklist remounts and re-probes — the assistant may have just - /// installed the modules the checklist looks for. - int _checklistEpoch = 0; - - /// One auto-open per app run: a fresh install that dismisses the - /// wizard should not have it spring back on every rebuild. - static bool _autoOpenedThisRun = false; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoOpen()); - } - - /// On a fresh hub (no config, no recorded setup plan) the wizard - /// IS the onboarding — open it rather than hoping the operator - /// finds the button. Once a setup exists it steps back for good. - Future _maybeAutoOpen() async { - if (_autoOpenedThisRun || !mounted) return; - if (!GuidedSetupDialog.isFreshInstall()) return; - _autoOpenedThisRun = true; - await _openSetup(); - } - - Future _openSetup() async { - await GuidedSetupDialog.show(context); - if (mounted) setState(() => _checklistEpoch++); - } + // Setup no longer lives on this page. A fresh install starts in + // the SetupGateScreen (setup BEFORE the app, not a dialog over + // it), and re-running the setup later is a Settings action — + // Welcome stays a calm introduction, not a second setup surface. @override Widget build(BuildContext context) { @@ -95,19 +70,6 @@ class _WelcomePageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const _Hero(), - const SizedBox(height: ChainSpace.md), - Align( - alignment: Alignment.centerLeft, - // High-emphasis: the guided setup is the fastest path - // to a working first run, so lead with a filled button - // rather than a low-key outlined one. Honest framing: - // "get started in 3 questions", not tool-speak. - child: FilledButton.icon( - onPressed: _openSetup, - icon: const Icon(Icons.auto_fix_high), - label: Text(l.setupStartCta), - ), - ), const SizedBox(height: ChainSpace.xxl), // When the hub is down the onboarding checklist // would render dead (all-unchecked, nothing to @@ -117,7 +79,7 @@ class _WelcomePageState extends State { const _HubDownHero(), const SizedBox(height: ChainSpace.xxl), ] else - _OnboardingChecklist(key: ValueKey(_checklistEpoch)), + const _OnboardingChecklist(), const _PillarRow(), const SizedBox(height: ChainSpace.xxl), const _SectionLabel(textKey: _SectionLabelKey.trust), @@ -551,7 +513,7 @@ const String _kChecklistDismissedKey = 'welcome.checklist.dismissed'; /// parallel; failures stay false and the checklist still /// renders (with the "Refresh" button for retry). class _OnboardingChecklist extends StatefulWidget { - const _OnboardingChecklist({super.key}); + const _OnboardingChecklist(); @override State<_OnboardingChecklist> createState() => _OnboardingChecklistState(); diff --git a/lib/widgets/chain_settings_dialog.dart b/lib/widgets/chain_settings_dialog.dart index 2a77dfc..35f78ef 100644 --- a/lib/widgets/chain_settings_dialog.dart +++ b/lib/widgets/chain_settings_dialog.dart @@ -15,6 +15,7 @@ import '../pages/welcome.dart' show showFaiDoc; import '../theme/theme.dart'; import '../theme/tokens.dart'; import 'chain_error_box.dart'; +import 'guided_setup_dialog.dart'; import 'chain_pill.dart'; import 'chain_system_ai_editor.dart'; import 'hub_auth_policy_panel.dart'; @@ -572,6 +573,26 @@ class _FaiSettingsDialogState extends State { ], const SizedBox(height: ChainSpace.xl), const _DefaultScopePanel(), + const SizedBox(height: ChainSpace.xl), + // Re-run the guided setup. This is the ONLY place it lives + // after first run — the first-run gate owns the fresh-install + // case, and Welcome stays a calm introduction instead of a + // permanent setup surface. + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: () => GuidedSetupDialog.show(context), + icon: const Icon(Icons.auto_fix_high, size: 18), + label: Text(l.settingsRunSetup), + ), + ), + const SizedBox(height: 4), + Text( + l.settingsRunSetupHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), ]; } diff --git a/lib/widgets/guided_setup_dialog.dart b/lib/widgets/guided_setup_dialog.dart index 399c03e..227132c 100644 --- a/lib/widgets/guided_setup_dialog.dart +++ b/lib/widgets/guided_setup_dialog.dart @@ -109,12 +109,29 @@ class GuidedSetupDialog extends StatefulWidget { @visibleForTesting final bool debugSkipAiProbe; + /// Test seam: replaces the hub-first plan preview (a live gRPC + /// call) so widget tests can drive the CLI fallback — or return a + /// canned plan — without a hub. + @visibleForTesting + static Future> Function()? debugPlanViaHubOverride; + + /// Render as page content instead of an [AlertDialog] — used by + /// the first-run gate, where the wizard IS the screen. + final bool embedded; + + /// Called instead of popping a dialog route when the wizard is + /// [embedded] (the gate owns what comes next). Also invoked after + /// a completed setup so the gate can enter the app. + final VoidCallback? onFinished; + const GuidedSetupDialog({ super.key, this.shell, this.debugPlan, this.debugApplied = false, this.debugSkipAiProbe = false, + this.embedded = false, + this.onFinished, }); static Future show(BuildContext context) { @@ -309,6 +326,33 @@ class _GuidedSetupDialogState extends State { _busy = true; _plan = null; }); + // Preview order matters for trust: try the LIVE HUB first — a + // pure PlanSetup RPC over the existing connection, no subprocess. + // The CLI fallback (hub down, first run without a daemon) spawns + // `chain`, which on macOS can be the app's very first file-system + // touch and pop a permission prompt; that must never happen + // BEFORE the operator has seen the plan when a hub is available. + try { + final plan = GuidedSetupDialog.debugPlanViaHubOverride != null + ? await GuidedSetupDialog.debugPlanViaHubOverride!() + : await HubService.instance.planSetup( + scenario: _scenario, + intent: _intent, + target: _target, + requireApproval: _requireApproval, + dataMustStayLocal: _dataLocal, + allowUnsignedModules: _allowUnsigned, + ); + if (!mounted) return; + setState(() { + _busy = false; + _plan = plan; + _step = _totalSteps; // review + }); + return; + } catch (_) { + // Hub unreachable / RPC unavailable — fall through to the CLI. + } final path = _writeAnswers(); final r = await SystemActions.chainInit(['--answers', path, '--plan-json']); if (!mounted) return; @@ -536,6 +580,18 @@ class _GuidedSetupDialogState extends State { } } + /// Leave the wizard: pop the dialog route, or — embedded full + /// screen in the first-run gate, where there is no route to pop — + /// hand control back to the gate. + void _close() { + final onFinished = widget.onFinished; + if (onFinished != null) { + onFinished(); + } else { + Navigator.of(context).pop(); + } + } + @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; @@ -545,17 +601,43 @@ class _GuidedSetupDialogState extends State { : _showReflection ? l.setupReflectionTitle : _stepTitle(l); + final body = SingleChildScrollView( + child: reviewing + ? _reviewStep(l) + : _showReflection + ? _reflectionView(l) + : _answerStep(l), + ); + if (widget.embedded) { + // First-run gate: same content as the dialog, hosted as a + // full page instead of a modal over an app that isn't set + // up yet ("setup after the app runs" reads backwards). + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.headlineSmall), + const SizedBox(height: ChainSpace.md), + Flexible(child: body), + const SizedBox(height: ChainSpace.md), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + for (final a in _actions(l)) ...[ + const SizedBox(width: ChainSpace.sm), + a, + ], + ], + ), + ], + ); + } return AlertDialog( title: Text(title), content: SizedBox( width: 480, - child: SingleChildScrollView( - child: reviewing - ? _reviewStep(l) - : _showReflection - ? _reflectionView(l) - : _answerStep(l), - ), + child: body, ), actions: _actions(l), ); @@ -901,7 +983,7 @@ class _GuidedSetupDialogState extends State { _actionRow( OutlinedButton.icon( onPressed: () { - Navigator.of(context).pop(); + _close(); widget.shell?.navigateTo('flows'); }, icon: const Icon(Icons.account_tree_outlined, size: 18), @@ -1027,7 +1109,7 @@ class _GuidedSetupDialogState extends State { if (_applied) { return [ FilledButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: _close, child: Text(l.guidedSetupClose), ), ]; @@ -1053,7 +1135,7 @@ class _GuidedSetupDialogState extends State { return [ TextButton( onPressed: _step == 0 - ? () => Navigator.of(context).pop() + ? _close : () => setState(() => _step -= 1), child: Text(_step == 0 ? l.guidedSetupCancel : l.guidedSetupBack), ), diff --git a/test/guided_setup_test.dart b/test/guided_setup_test.dart index 6e640fd..23e8e42 100644 --- a/test/guided_setup_test.dart +++ b/test/guided_setup_test.dart @@ -259,12 +259,17 @@ void main() { setUp(() { tmp = Directory.systemTemp.createTempSync('wizard-test-'); ChainLog.testPathOverride = '${tmp.path}/studio-errors.log'; + // These tests exercise the CLI fallback — make the hub-first + // preview fail immediately instead of attempting a live RPC. + GuidedSetupDialog.debugPlanViaHubOverride = () async => + throw Exception('hub unavailable (test)'); }); tearDown(() { ChainLog.testPathOverride = null; SystemActions.debugRunFaiOverride = null; SystemActions.debugResolveOverride = null; + GuidedSetupDialog.debugPlanViaHubOverride = null; tmp.deleteSync(recursive: true); }); diff --git a/test/setup_gate_test.dart b/test/setup_gate_test.dart new file mode 100644 index 0000000..ee547e0 --- /dev/null +++ b/test/setup_gate_test.dart @@ -0,0 +1,55 @@ +// First-run gate: on a fresh install the app opens INTO the setup +// (embedded wizard as the page), with an explicit skip. Setup +// before the app — not a dialog over a shell that isn't set up. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/setup_gate.dart'; + +void main() { + testWidgets('gate hosts the embedded wizard + a skip action', ( + tester, + ) async { + var done = false; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: SetupGateScreen(onDone: () => done = true), + ), + ); + await tester.pumpAndSettle(); + + // The wizard renders as PAGE content (step 1 visible, no dialog). + expect(find.text('Worum geht es?'), findsOneWidget); + expect(find.text('Erst mal ausprobieren'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + // Honest framing + a way past the gate. + expect(find.textContaining('Nichts wird ohne Ihre'), findsOneWidget); + + await tester.ensureVisible(find.text('Später einrichten')); + await tester.tap(find.text('Später einrichten')); + await tester.pumpAndSettle(); + expect(done, isTrue, reason: 'skip must hand control to the app'); + }); + + testWidgets('cancel on step 1 also leaves the gate', (tester) async { + var done = false; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: SetupGateScreen(onDone: () => done = true), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Abbrechen')); + await tester.tap(find.text('Abbrechen')); + await tester.pumpAndSettle(); + expect(done, isTrue); + }); +}