From c6da5025ceaa9b34c3397db93003e790850625b0 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 15 Jul 2026 00:10:01 +0200 Subject: [PATCH] fix(setup): wizard errors copyable above the dialog, CLI skew explained, exec transparency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field test of the setup wizard surfaced three trust breaks in one run: an unexplained macOS Documents permission prompt, a perceived crash, and an error message whose copy button could not be reached. Root causes and fixes: - chain init failures were shown as a SnackBar, which lands BEHIND the wizard's modal barrier: dimmed, clipped, copy unreachable — and the click aimed at it hit the barrier, dismissing the whole wizard with all answers (the perceived crash). Errors now open a modal dialog ABOVE the wizard via showChainErrorDialog with a copyable detail block, and the wizard is no longer barrier-dismissible. - When the resolved chain binary is older than Studio and rejects --plan-json, the wizard now explains the version skew in plain language (binary path + update path) instead of leaking a raw clap usage error. A missing binary gets its own localized story. - Step 3 announces which chain binary the preview will execute; when that binary physically lives (symlinks resolved) in a TCC-protected folder, the wizard pre-explains the macOS folder prompt. Supporting changes: FriendlyError passes through friendlyError() unchanged so call sites can ship precise localized stories through the shared presentation; SystemActions gains resolvedChainBinary() plus run/resolve test seams; ChainErrorBox hugs its content instead of filling an unbounded dialog; the wizard's answers file is written synchronously (the async dart:io variants never complete under the widget-test fake-async zone). Verified: flutter analyze clean, 53 tests green (6 new wizard error- path tests incl. clipboard round-trip), plus a live GUI walk on macOS in dark + light with a stale binary (skew dialog, copy verified via clipboard) and with the real binary (TCC pre-explanation with the resolved path, full plan preview). Signed-off-by: flemming-it --- CHANGELOG.md | 30 +++++ lib/data/friendly_error.dart | 15 +++ lib/data/system_actions.dart | 27 +++++ lib/l10n/app_de.arb | 14 +++ lib/l10n/app_en.arb | 14 +++ lib/l10n/app_localizations.dart | 60 ++++++++++ lib/l10n/app_localizations_de.dart | 44 +++++++ lib/l10n/app_localizations_en.dart | 44 +++++++ lib/widgets/chain_error_box.dart | 4 + lib/widgets/guided_setup_dialog.dart | 110 +++++++++++++++-- test/guided_setup_test.dart | 172 ++++++++++++++++++++++++++- 11 files changed, 524 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf5b2f5..f6645b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,36 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep. ## Unreleased +### Fixed (wizard live-run findings 2026-07-14) + +Field test of the setup wizard surfaced three trust breaks in one +run; all were reproduced against a stale CLI and fixed: + +- **Errors surfaced behind the wizard.** `chain init` failures were + shown as a SnackBar, which lands BEHIND the wizard's modal barrier: + dimmed, clipped, its copy button unreachable — and the operator's + click at it hit the barrier, which (dismissible by default) closed + the whole wizard with all answers. Now: failures open a modal error + dialog ABOVE the wizard (copy button works, verbatim CLI output + behind "Details"), and the wizard is no longer barrier-dismissible — + leaving it is explicit via Abbrechen/Zurück. +- **CLI version skew explained.** When the resolved `chain` binary is + older than Studio and rejects `--plan-json`, the wizard now names + the skew in plain language — which binary was executed, that it + predates the assistant, and the update path — instead of leaking a + raw clap usage error. A missing binary gets the same treatment plus + a hint on step 3 before anything runs. +- **macOS folder prompt pre-explained.** Step 3 now states which + `chain` binary the preview will execute; when that binary physically + lives (symlinks resolved) in a TCC-protected folder (Documents/ + Desktop/Downloads), the wizard says up front that macOS may ask for + folder access — instead of a bare permission prompt appearing in + the middle of setup. +- `FriendlyError` values now pass through `friendlyError()` unchanged + so call sites can route precise, localized stories through the + shared error presentation; `SystemActions` gained a public + `resolvedChainBinary()` and test seams for the run/resolve paths. + ### Added (guided setup on grade-1 — steps A4/A5/B1 + doc automation) - **Clickable next steps.** After apply, the wizard renders real diff --git a/lib/data/friendly_error.dart b/lib/data/friendly_error.dart index a7026b3..d12efce 100644 --- a/lib/data/friendly_error.dart +++ b/lib/data/friendly_error.dart @@ -26,12 +26,27 @@ class FriendlyError { required this.detail, this.hint, }); + + // The on-disk error log serialises thrown objects via toString — + // keep the whole story (headline, hint, verbatim detail) in one + // readable record. + @override + String toString() => [ + headline, + ?hint, + if (detail.isNotEmpty && detail != headline) detail, + ].join('\n'); } /// Map an arbitrary thrown object to a [FriendlyError]. Always /// returns a value — never throws — so callers can drop the /// result straight into UI without try/catch ceremony. FriendlyError friendlyError(Object error, AppLocalizations l) { + // A pre-built FriendlyError passes through unchanged — call + // sites that already know the precise story (e.g. the setup + // wizard's CLI-version-skew case) construct one directly and + // still route through the shared presentation helpers. + if (error is FriendlyError) return error; // We deliberately don't import package:grpc here so Studio // doesn't have to add it to its own pubspec — the dependency // lives one layer down in chain_client_sdk. `GrpcError` has a diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index 950c74f..a562091 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -18,6 +18,7 @@ import 'dart:io'; +import 'package:meta/meta.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Sentinel returned in `_runFai(...).stderr` when no `fai` @@ -73,6 +74,28 @@ class SystemActions { /// binary / read the install guide" recovery path. static bool chainBinaryExists() => _faiExecutable() != null; + /// Test seam: when set, [_runFai] returns this function's result + /// instead of spawning a real process, and [resolvedChainBinary] / + /// [chainBinaryExists] answer from [debugResolveOverride]. Lets + /// widget tests drive the CLI error paths deterministically. + @visibleForTesting + static Future<({bool ok, String stdout, String stderr})> Function( + List args, + )? + debugRunFaiOverride; + + /// Test seam companion to [debugRunFaiOverride]: overrides binary + /// resolution (may return null to simulate "no binary found"). + @visibleForTesting + static String? Function()? debugResolveOverride; + + /// Absolute path of the `chain` binary Studio would execute right + /// now, or null when none can be located. Public so surfaces that + /// are about to spawn the binary (the guided-setup wizard) can say + /// WHAT they will run — before macOS asks the operator for folder + /// permission because of where that binary happens to live. + static String? resolvedChainBinary() => _faiExecutable(); + /// Ask the OS to open [path] in the default handler. On macOS /// this opens text files in TextEdit, configs in the registered /// editor, etc. Returns true on a clean spawn (process exited @@ -219,6 +242,8 @@ class SystemActions { static Future<({bool ok, String stdout, String stderr})> _runFai( List args, ) async { + final runOverride = debugRunFaiOverride; + if (runOverride != null) return runOverride(args); final exe = _faiExecutable(); if (exe == null) { // Sentinel, not a user-facing string. Callers detect this @@ -249,6 +274,8 @@ class SystemActions { /// the file picker), $CHAIN_BIN, PATH, fallback to the canonical /// install location under the user's home dir. static String? _faiExecutable() { + final resolveOverride = debugResolveOverride; + if (resolveOverride != null) return resolveOverride(); final override = _faiBinaryOverride; if (override != null && override.isNotEmpty && diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c29d29b..7f9979a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1778,6 +1778,20 @@ "setupFreeTextPrivacyRemote": "Ihre Beschreibung wird an die eingerichtete System-KI gesendet: {model} ({provider}).", "@setupFreeTextPrivacyRemote": {"placeholders": {"model": {"type": "String"}, "provider": {"type": "String"}}}, "setupFreeTextUnavailable": "Für den Freitext-Weg braucht Ch∆In eine eingerichtete System-KI (Einstellungen → System-KI). Die Auswahl oben funktioniert immer — ganz ohne KI.", + "setupExecHint": "Für Vorschau und Einrichtung führt Studio das Programm „chain“ aus: {path}", + "@setupExecHint": {"placeholders": {"path": {"type": "String"}}}, + "setupExecHintTcc": "Das Programm liegt unter {path} — macOS fragt dafür eventuell einmalig nach Zugriff auf diesen Ordner (z. B. „Dokumente“). Das ist zu erwarten und in Ordnung.", + "@setupExecHintTcc": {"placeholders": {"path": {"type": "String"}}}, + "setupNoBinaryStepHint": "Das Programm „chain“ wurde auf diesem Rechner nicht gefunden — die Vorschau im nächsten Schritt wird fehlschlagen. Unter „Diagnose“ können Sie es auswählen oder Ch∆In installieren.", + "setupNoBinary": "Das Programm „chain“ wurde auf diesem Rechner nicht gefunden.", + "setupNoBinaryHint": "Ohne das Programm kann der Assistent die Einrichtung nicht anwenden. Wählen Sie es unter „Diagnose“ aus oder installieren Sie Ch∆In neu.", + "setupCliTooOld": "Das installierte „chain“-Programm ist älter als Studio und kennt diesen Assistenten noch nicht.", + "setupCliTooOldHint": "Gefunden wurde: {path}. Bitte Ch∆In aktualisieren (Seite „Diagnose“ → Update, oder im Terminal: chain update apply) und den Assistenten danach erneut öffnen.", + "@setupCliTooOldHint": {"placeholders": {"path": {"type": "String"}}}, + "setupPreviewFailed": "Die Plan-Vorschau konnte nicht erstellt werden — das Programm „chain“ meldet einen Fehler.", + "setupApplyFailed": "Die Einrichtung konnte nicht angewendet werden — das Programm „chain“ meldet einen Fehler.", + "setupCliUsedBinary": "Ausgeführt wurde: {path}", + "@setupCliUsedBinary": {"placeholders": {"path": {"type": "String"}}}, "setupFreeTextParseError": "Die System-KI hat keinen verwertbaren Vorschlag geliefert. Wählen Sie oben aus dem Menü — oder versuchen Sie es noch einmal.", "setupReflectionTitle": "So verstehe ich Ihre Aufgabe", "setupReflectionScenario": "Worum es geht: {label}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e56e517..00b0f1d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1817,6 +1817,20 @@ "setupFreeTextPrivacyRemote": "Your description is sent to the configured system AI: {model} ({provider}).", "@setupFreeTextPrivacyRemote": {"placeholders": {"model": {"type": "String"}, "provider": {"type": "String"}}}, "setupFreeTextUnavailable": "The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.", + "setupExecHint": "For the preview and setup, Studio runs the “chain” program: {path}", + "@setupExecHint": {"placeholders": {"path": {"type": "String"}}}, + "setupExecHintTcc": "The program lives at {path} — macOS may ask once for access to that folder (e.g. “Documents”). That is expected and fine.", + "@setupExecHintTcc": {"placeholders": {"path": {"type": "String"}}}, + "setupNoBinaryStepHint": "The “chain” program could not be found on this machine — the preview in the next step will fail. You can pick it under “Doctor”, or install Ch∆In.", + "setupNoBinary": "The “chain” program could not be found on this machine.", + "setupNoBinaryHint": "Without it the assistant cannot apply the setup. Pick it under “Doctor”, or reinstall Ch∆In.", + "setupCliTooOld": "The installed “chain” program is older than Studio and does not know this assistant yet.", + "setupCliTooOldHint": "Found: {path}. Please update Ch∆In (“Doctor” page → update, or in a terminal: chain update apply) and reopen the assistant afterwards.", + "@setupCliTooOldHint": {"placeholders": {"path": {"type": "String"}}}, + "setupPreviewFailed": "The plan preview could not be created — the “chain” program reported an error.", + "setupApplyFailed": "The setup could not be applied — the “chain” program reported an error.", + "setupCliUsedBinary": "Executed: {path}", + "@setupCliUsedBinary": {"placeholders": {"path": {"type": "String"}}}, "setupFreeTextParseError": "The system AI did not return a usable suggestion. Pick from the menu above — or try again.", "setupReflectionTitle": "This is how I read your task", "setupReflectionScenario": "What it is about: {label}", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f90f664..a2658ee 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5479,6 +5479,66 @@ abstract class AppLocalizations { /// **'The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.'** String get setupFreeTextUnavailable; + /// No description provided for @setupExecHint. + /// + /// In en, this message translates to: + /// **'For the preview and setup, Studio runs the “chain” program: {path}'** + String setupExecHint(String path); + + /// No description provided for @setupExecHintTcc. + /// + /// In en, this message translates to: + /// **'The program lives at {path} — macOS may ask once for access to that folder (e.g. “Documents”). That is expected and fine.'** + String setupExecHintTcc(String path); + + /// No description provided for @setupNoBinaryStepHint. + /// + /// In en, this message translates to: + /// **'The “chain” program could not be found on this machine — the preview in the next step will fail. You can pick it under “Doctor”, or install Ch∆In.'** + String get setupNoBinaryStepHint; + + /// No description provided for @setupNoBinary. + /// + /// In en, this message translates to: + /// **'The “chain” program could not be found on this machine.'** + String get setupNoBinary; + + /// No description provided for @setupNoBinaryHint. + /// + /// In en, this message translates to: + /// **'Without it the assistant cannot apply the setup. Pick it under “Doctor”, or reinstall Ch∆In.'** + String get setupNoBinaryHint; + + /// No description provided for @setupCliTooOld. + /// + /// In en, this message translates to: + /// **'The installed “chain” program is older than Studio and does not know this assistant yet.'** + String get setupCliTooOld; + + /// No description provided for @setupCliTooOldHint. + /// + /// In en, this message translates to: + /// **'Found: {path}. Please update Ch∆In (“Doctor” page → update, or in a terminal: chain update apply) and reopen the assistant afterwards.'** + String setupCliTooOldHint(String path); + + /// No description provided for @setupPreviewFailed. + /// + /// In en, this message translates to: + /// **'The plan preview could not be created — the “chain” program reported an error.'** + String get setupPreviewFailed; + + /// No description provided for @setupApplyFailed. + /// + /// In en, this message translates to: + /// **'The setup could not be applied — the “chain” program reported an error.'** + String get setupApplyFailed; + + /// No description provided for @setupCliUsedBinary. + /// + /// In en, this message translates to: + /// **'Executed: {path}'** + String setupCliUsedBinary(String path); + /// No description provided for @setupFreeTextParseError. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 79b055a..dba63ee 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3238,6 +3238,50 @@ class AppLocalizationsDe extends AppLocalizations { String get setupFreeTextUnavailable => 'Für den Freitext-Weg braucht Ch∆In eine eingerichtete System-KI (Einstellungen → System-KI). Die Auswahl oben funktioniert immer — ganz ohne KI.'; + @override + String setupExecHint(String path) { + return 'Für Vorschau und Einrichtung führt Studio das Programm „chain“ aus: $path'; + } + + @override + String setupExecHintTcc(String path) { + return 'Das Programm liegt unter $path — macOS fragt dafür eventuell einmalig nach Zugriff auf diesen Ordner (z. B. „Dokumente“). Das ist zu erwarten und in Ordnung.'; + } + + @override + String get setupNoBinaryStepHint => + 'Das Programm „chain“ wurde auf diesem Rechner nicht gefunden — die Vorschau im nächsten Schritt wird fehlschlagen. Unter „Diagnose“ können Sie es auswählen oder Ch∆In installieren.'; + + @override + String get setupNoBinary => + 'Das Programm „chain“ wurde auf diesem Rechner nicht gefunden.'; + + @override + String get setupNoBinaryHint => + 'Ohne das Programm kann der Assistent die Einrichtung nicht anwenden. Wählen Sie es unter „Diagnose“ aus oder installieren Sie Ch∆In neu.'; + + @override + String get setupCliTooOld => + 'Das installierte „chain“-Programm ist älter als Studio und kennt diesen Assistenten noch nicht.'; + + @override + String setupCliTooOldHint(String path) { + return 'Gefunden wurde: $path. Bitte Ch∆In aktualisieren (Seite „Diagnose“ → Update, oder im Terminal: chain update apply) und den Assistenten danach erneut öffnen.'; + } + + @override + String get setupPreviewFailed => + 'Die Plan-Vorschau konnte nicht erstellt werden — das Programm „chain“ meldet einen Fehler.'; + + @override + String get setupApplyFailed => + 'Die Einrichtung konnte nicht angewendet werden — das Programm „chain“ meldet einen Fehler.'; + + @override + String setupCliUsedBinary(String path) { + return 'Ausgeführt wurde: $path'; + } + @override String get setupFreeTextParseError => 'Die System-KI hat keinen verwertbaren Vorschlag geliefert. Wählen Sie oben aus dem Menü — oder versuchen Sie es noch einmal.'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a1425a0..c137dd3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3234,6 +3234,50 @@ class AppLocalizationsEn extends AppLocalizations { String get setupFreeTextUnavailable => 'The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.'; + @override + String setupExecHint(String path) { + return 'For the preview and setup, Studio runs the “chain” program: $path'; + } + + @override + String setupExecHintTcc(String path) { + return 'The program lives at $path — macOS may ask once for access to that folder (e.g. “Documents”). That is expected and fine.'; + } + + @override + String get setupNoBinaryStepHint => + 'The “chain” program could not be found on this machine — the preview in the next step will fail. You can pick it under “Doctor”, or install Ch∆In.'; + + @override + String get setupNoBinary => + 'The “chain” program could not be found on this machine.'; + + @override + String get setupNoBinaryHint => + 'Without it the assistant cannot apply the setup. Pick it under “Doctor”, or reinstall Ch∆In.'; + + @override + String get setupCliTooOld => + 'The installed “chain” program is older than Studio and does not know this assistant yet.'; + + @override + String setupCliTooOldHint(String path) { + return 'Found: $path. Please update Ch∆In (“Doctor” page → update, or in a terminal: chain update apply) and reopen the assistant afterwards.'; + } + + @override + String get setupPreviewFailed => + 'The plan preview could not be created — the “chain” program reported an error.'; + + @override + String get setupApplyFailed => + 'The setup could not be applied — the “chain” program reported an error.'; + + @override + String setupCliUsedBinary(String path) { + return 'Executed: $path'; + } + @override String get setupFreeTextParseError => 'The system AI did not return a usable suggestion. Pick from the menu above — or try again.'; diff --git a/lib/widgets/chain_error_box.dart b/lib/widgets/chain_error_box.dart index c86ec22..a9196da 100644 --- a/lib/widgets/chain_error_box.dart +++ b/lib/widgets/chain_error_box.dart @@ -97,6 +97,10 @@ class _ChainErrorBoxState extends State { border: Border.all(color: accent.withValues(alpha: 0.4)), ), child: Column( + // min, not max: inside an unbounded-height dialog the box + // must hug its content — otherwise a one-line error renders + // as a screen-tall empty red frame. + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Align( diff --git a/lib/widgets/guided_setup_dialog.dart b/lib/widgets/guided_setup_dialog.dart index 2ea36d8..399c03e 100644 --- a/lib/widgets/guided_setup_dialog.dart +++ b/lib/widgets/guided_setup_dialog.dart @@ -16,6 +16,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; +import '../data/friendly_error.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; @@ -118,8 +119,14 @@ class GuidedSetupDialog extends StatefulWidget { static Future show(BuildContext context) { final shell = StudioShellState.of(context); + // Not barrier-dismissible: a stray click outside the dialog must + // not throw away a half-answered wizard (and error SnackBars used + // to lure exactly that click — the operator aimed for the message + // below the barrier and lost everything). Leaving is explicit: + // Abbrechen on step 1, Zurück everywhere else. return showDialog( context: context, + barrierDismissible: false, builder: (_) => GuidedSetupDialog(shell: shell), ); } @@ -276,12 +283,15 @@ class _GuidedSetupDialogState extends State { /// racy on multi-user machines. Cleaned up in [dispose]. Directory? _answersDir; - Future _writeAnswers() async { - _answersDir ??= await Directory.systemTemp.createTemp('chain-setup-'); + String _writeAnswers() { + // Sync on purpose: the file is a handful of lines, and the async + // dart:io variants never complete inside the fake-async zone + // widget tests run in — the wizard would hang there forever. + _answersDir ??= Directory.systemTemp.createTempSync('chain-setup-'); final f = File( '${_answersDir!.path}${Platform.pathSeparator}answers.yaml', ); - await f.writeAsString(_answersYaml()); + f.writeAsStringSync(_answersYaml()); return f.path; } @@ -299,7 +309,7 @@ class _GuidedSetupDialogState extends State { _busy = true; _plan = null; }); - final path = await _writeAnswers(); + final path = _writeAnswers(); final r = await SystemActions.chainInit(['--answers', path, '--plan-json']); if (!mounted) return; setState(() => _busy = false); @@ -311,13 +321,85 @@ class _GuidedSetupDialogState extends State { _step = _totalSteps; // review }); } catch (_) { - showFaiProcessError(context, 'chain init --plan-json', r.stdout, r.stderr); + await _showCliError('chain init --plan-json', r.stdout, r.stderr); } } else { - showFaiProcessError(context, 'chain init --plan-json', r.stdout, r.stderr); + await _showCliError('chain init --plan-json', r.stdout, r.stderr); } } + /// Surface a failed `chain init` call as a modal dialog stacked + /// ABOVE this wizard. A SnackBar would land BEHIND the wizard's + /// modal barrier: visible but dimmed, its copy button unreachable — + /// and the operator's attempt to click it hits the barrier instead. + /// Known failure shapes get a localized plain-language headline; + /// the verbatim CLI output stays copyable behind "Details". + Future _showCliError(String source, String stdout, String stderr) { + final l = AppLocalizations.of(context)!; + final raw = [ + stderr.trim(), + stdout.trim(), + ].where((s) => s.isNotEmpty).join('\n\n'); + final bin = SystemActions.resolvedChainBinary(); + final FriendlyError friendly; + if (stderr.trim() == kFaiBinaryNotFound) { + // Sentinel, never shown verbatim (see SystemActions docs). + friendly = FriendlyError( + headline: l.setupNoBinary, + detail: '', + hint: l.setupNoBinaryHint, + ); + } else if (raw.contains('unexpected argument')) { + // Version skew: this Studio speaks a newer `chain init` dialect + // than the binary it found (e.g. a stale build behind a channel + // symlink). Name the binary and the way out instead of leaking + // a raw clap usage error. + friendly = FriendlyError( + headline: l.setupCliTooOld, + detail: raw, + hint: l.setupCliTooOldHint(bin ?? 'chain'), + ); + } else { + friendly = FriendlyError( + headline: source == 'chain init --apply' + ? l.setupApplyFailed + : l.setupPreviewFailed, + detail: raw.isEmpty ? 'process exited non-zero (no output)' : raw, + hint: bin == null ? null : l.setupCliUsedBinary(bin), + ); + } + return showChainErrorDialog(context, source, friendly); + } + + /// Physical location of the resolved `chain` binary (symlinks + /// followed) — the path that decides whether macOS shows a folder- + /// permission prompt when Studio executes it, regardless of how + /// harmless the symlink's own path looks. + String? _physicalChainBinary() { + final bin = SystemActions.resolvedChainBinary(); + if (bin == null) return null; + try { + return File(bin).resolveSymbolicLinksSync(); + } on FileSystemException { + return bin; + } + } + + /// True when executing the resolved binary can trigger a macOS + /// folder-permission (TCC) prompt because it physically lives in a + /// protected folder. The wizard says so BEFORE the first exec — + /// an unexplained "Studio wants access to Documents" prompt in the + /// middle of setup reads as a trust break. + bool _binaryNeedsFolderPermission() { + if (!Platform.isMacOS) return false; + final physical = _physicalChainBinary(); + final home = Platform.environment['HOME']; + if (physical == null || home == null || home.isEmpty) return false; + return physical.startsWith('$home/Documents/') || + physical.startsWith('$home/Desktop/') || + physical.startsWith('$home/Downloads/'); + } + /// Warning lines the apply emitted on success (e.g. the empty /// trusted_publishers caveat). Swallowing them made the wizard /// claim more than the config delivers — show them instead. @@ -325,7 +407,7 @@ class _GuidedSetupDialogState extends State { Future _apply() async { setState(() => _busy = true); - final path = await _writeAnswers(); + final path = _writeAnswers(); final r = await SystemActions.chainInit( ['--answers', path, '--apply', '--force'], ); @@ -343,7 +425,7 @@ class _GuidedSetupDialogState extends State { }); unawaited(_probeHub()); } else { - showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr); + await _showCliError('chain init --apply', r.stdout, r.stderr); } } @@ -527,6 +609,18 @@ class _GuidedSetupDialogState extends State { value: _dataLocal, onChanged: (v) => setState(() => _dataLocal = v), ), + // Exec transparency: the next click runs the `chain` binary. + // Say which one — and warn when its physical location will + // make macOS ask for folder access, so the prompt (if any) + // arrives explained instead of as a trust break. + const SizedBox(height: ChainSpace.sm), + if (!SystemActions.chainBinaryExists()) + _hintRow(l.setupNoBinaryStepHint) + else ...[ + _hintRow(l.setupExecHint(SystemActions.resolvedChainBinary()!)), + if (_binaryNeedsFolderPermission()) + _hintRow(l.setupExecHintTcc(_physicalChainBinary()!)), + ], ], // The free-text alternative lives on the first step: describe // the goal, the system AI pre-selects the menu answers. Menu diff --git a/test/guided_setup_test.dart b/test/guided_setup_test.dart index 7e3e6e6..94c9bee 100644 --- a/test/guided_setup_test.dart +++ b/test/guided_setup_test.dart @@ -1,12 +1,18 @@ // Guided-setup wizard — the three explained answer steps. Verifies // that options render as localized cards with their one-line // explanation (no English enum humanization) and that Weiter/Zurück -// walk the steps. The review step calls the CLI (a subprocess) and is -// out of scope for a widget test. +// walk the steps. CLI calls run through the SystemActions test seam +// so the error paths (stale binary, generic failure) are covered +// without a subprocess. + +import 'dart:io' show Directory, Platform; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:chain_studio/data/chain_log.dart'; +import 'package:chain_studio/data/system_actions.dart'; import 'package:chain_studio/l10n/app_localizations.dart'; import 'package:chain_studio/widgets/guided_setup_dialog.dart'; @@ -247,6 +253,168 @@ void main() { }, ); + group('CLI paths (SystemActions test seam)', () { + late Directory tmp; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('wizard-test-'); + ChainLog.testPathOverride = '${tmp.path}/studio-errors.log'; + }); + + tearDown(() { + ChainLog.testPathOverride = null; + SystemActions.debugRunFaiOverride = null; + SystemActions.debugResolveOverride = null; + tmp.deleteSync(recursive: true); + }); + + Future walkToStep3(WidgetTester tester) async { + await tester.pumpWidget(_host()); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Weiter')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Weiter')); + await tester.pumpAndSettle(); + expect(find.text('Schritt 3 von 3'), findsOneWidget); + } + + testWidgets('a stray click outside the wizard does not dismiss it', ( + tester, + ) async { + await tester.pumpWidget(_host()); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text('Schritt 1 von 3'), findsOneWidget); + + // The exact click that used to end the wizard: aiming at an + // error SnackBar dimmed below the modal barrier. + await tester.tapAt(const Offset(4, 4)); + await tester.pumpAndSettle(); + expect(find.text('Schritt 1 von 3'), findsOneWidget); + }); + + testWidgets( + 'stale chain binary → explained, copyable error dialog above the intact wizard', + (tester) async { + const clapError = + "error: unexpected argument '--plan-json' found\n\n" + " tip: a similar argument exists: '--plan-out'\n\n" + 'Usage: chain init --answers --plan-out '; + SystemActions.debugResolveOverride = () => '/Users/op/.chain/bin/chain'; + SystemActions.debugRunFaiOverride = (args) async => + (ok: false, stdout: '', stderr: clapError); + + final copied = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied.add((call.arguments as Map)['text'] as String); + } + return null; + }, + ); + + await walkToStep3(tester); + await tester.tap(find.text('Weiter')); // triggers `chain init` + await tester.pumpAndSettle(); + + // Plain-language headline naming the skew — not a raw clap + // usage wall — plus the binary that was executed. + expect(find.textContaining('älter als Studio'), findsOneWidget); + expect( + find.textContaining('/Users/op/.chain/bin/chain'), + findsWidgets, + ); + + // The verbatim CLI output is one copy-click away (the old + // SnackBar rendition sat behind the modal barrier where the + // copy button could not be reached at all). + await tester.tap(find.byIcon(Icons.content_copy)); + await tester.pump(); + expect(copied, hasLength(1)); + expect(copied.single, contains('unexpected argument')); + // Let the button's transient "copied" checkmark reset so no + // timer outlives the test. + await tester.pump(const Duration(seconds: 2)); + + // Dismissing the error returns to the intact wizard — the + // failure must not cost the operator their answers. + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + expect(find.text('Schritt 3 von 3'), findsOneWidget); + }, + ); + + testWidgets('generic init failure names the executed binary', ( + tester, + ) async { + SystemActions.debugResolveOverride = () => '/Users/op/.chain/bin/chain'; + SystemActions.debugRunFaiOverride = (args) async => + (ok: false, stdout: '', stderr: 'boom: config unreadable'); + + await walkToStep3(tester); + await tester.tap(find.text('Weiter')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Die Plan-Vorschau konnte nicht erstellt werden'), + findsOneWidget, + ); + expect(find.textContaining('Ausgeführt wurde:'), findsOneWidget); + }); + + testWidgets('step 3 announces which binary the wizard will run', ( + tester, + ) async { + SystemActions.debugResolveOverride = () => '/Users/op/.chain/bin/chain'; + SystemActions.debugRunFaiOverride = (args) async => + (ok: false, stdout: '', stderr: 'unused'); + + await walkToStep3(tester); + expect( + find.textContaining('führt Studio das Programm „chain“ aus'), + findsOneWidget, + ); + expect( + find.textContaining('/Users/op/.chain/bin/chain'), + findsOneWidget, + ); + }); + + testWidgets( + 'step 3 pre-explains the macOS folder prompt for a Documents-resident binary', + (tester) async { + final home = Platform.environment['HOME']; + if (!Platform.isMacOS || home == null || home.isEmpty) { + return; // TCC folder prompts are a macOS-only concern. + } + SystemActions.debugResolveOverride = () => '$home/Documents/dev/chain'; + SystemActions.debugRunFaiOverride = (args) async => + (ok: false, stdout: '', stderr: 'unused'); + + await walkToStep3(tester); + expect( + find.textContaining('nach Zugriff auf diesen Ordner'), + findsOneWidget, + ); + }, + ); + + testWidgets('step 3 says so when no chain binary can be found', ( + tester, + ) async { + SystemActions.debugResolveOverride = () => null; + + await walkToStep3(tester); + expect( + find.textContaining('wurde auf diesem Rechner nicht gefunden'), + findsOneWidget, + ); + }); + }); + group('parseSetupSuggestion', () { test('accepts a valid JSON object embedded in prose', () { final r = parseSetupSuggestion(