fix(setup): wizard errors copyable above the dialog, CLI skew explained, exec transparency
Some checks failed
Security / Security check (push) Failing after 2s

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-15 00:10:01 +02:00
parent 4ceb5bb567
commit c6da5025ce
11 changed files with 524 additions and 10 deletions

View file

@ -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

View file

@ -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

View file

@ -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<String> 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<String> 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 &&

View file

@ -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}",

View file

@ -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}",

View file

@ -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:

View file

@ -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.';

View file

@ -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.';

View file

@ -97,6 +97,10 @@ class _ChainErrorBoxState extends State<ChainErrorBox> {
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(

View file

@ -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<void> 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<void>(
context: context,
barrierDismissible: false,
builder: (_) => GuidedSetupDialog(shell: shell),
);
}
@ -276,12 +283,15 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
/// racy on multi-user machines. Cleaned up in [dispose].
Directory? _answersDir;
Future<String> _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<GuidedSetupDialog> {
_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<GuidedSetupDialog> {
_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<void> _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<GuidedSetupDialog> {
Future<void> _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<GuidedSetupDialog> {
});
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<GuidedSetupDialog> {
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

View file

@ -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<void> 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 <ANSWERS> --plan-out <PLAN_OUT>';
SystemActions.debugResolveOverride = () => '/Users/op/.chain/bin/chain';
SystemActions.debugRunFaiOverride = (args) async =>
(ok: false, stdout: '', stderr: clapError);
final copied = <String>[];
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(