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

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