fix(setup): wizard errors copyable above the dialog, CLI skew explained, exec transparency
Some checks failed
Security / Security check (push) Failing after 2s
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:
parent
4ceb5bb567
commit
c6da5025ce
11 changed files with 524 additions and 10 deletions
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -5,6 +5,36 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep.
|
||||||
|
|
||||||
## Unreleased
|
## 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)
|
### Added (guided setup on grade-1 — steps A4/A5/B1 + doc automation)
|
||||||
|
|
||||||
- **Clickable next steps.** After apply, the wizard renders real
|
- **Clickable next steps.** After apply, the wizard renders real
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,27 @@ class FriendlyError {
|
||||||
required this.detail,
|
required this.detail,
|
||||||
this.hint,
|
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
|
/// Map an arbitrary thrown object to a [FriendlyError]. Always
|
||||||
/// returns a value — never throws — so callers can drop the
|
/// returns a value — never throws — so callers can drop the
|
||||||
/// result straight into UI without try/catch ceremony.
|
/// result straight into UI without try/catch ceremony.
|
||||||
FriendlyError friendlyError(Object error, AppLocalizations l) {
|
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
|
// We deliberately don't import package:grpc here so Studio
|
||||||
// doesn't have to add it to its own pubspec — the dependency
|
// doesn't have to add it to its own pubspec — the dependency
|
||||||
// lives one layer down in chain_client_sdk. `GrpcError` has a
|
// lives one layer down in chain_client_sdk. `GrpcError` has a
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
/// Sentinel returned in `_runFai(...).stderr` when no `fai`
|
/// Sentinel returned in `_runFai(...).stderr` when no `fai`
|
||||||
|
|
@ -73,6 +74,28 @@ class SystemActions {
|
||||||
/// binary / read the install guide" recovery path.
|
/// binary / read the install guide" recovery path.
|
||||||
static bool chainBinaryExists() => _faiExecutable() != null;
|
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
|
/// Ask the OS to open [path] in the default handler. On macOS
|
||||||
/// this opens text files in TextEdit, configs in the registered
|
/// this opens text files in TextEdit, configs in the registered
|
||||||
/// editor, etc. Returns true on a clean spawn (process exited
|
/// 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(
|
static Future<({bool ok, String stdout, String stderr})> _runFai(
|
||||||
List<String> args,
|
List<String> args,
|
||||||
) async {
|
) async {
|
||||||
|
final runOverride = debugRunFaiOverride;
|
||||||
|
if (runOverride != null) return runOverride(args);
|
||||||
final exe = _faiExecutable();
|
final exe = _faiExecutable();
|
||||||
if (exe == null) {
|
if (exe == null) {
|
||||||
// Sentinel, not a user-facing string. Callers detect this
|
// 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
|
/// the file picker), $CHAIN_BIN, PATH, fallback to the canonical
|
||||||
/// install location under the user's home dir.
|
/// install location under the user's home dir.
|
||||||
static String? _faiExecutable() {
|
static String? _faiExecutable() {
|
||||||
|
final resolveOverride = debugResolveOverride;
|
||||||
|
if (resolveOverride != null) return resolveOverride();
|
||||||
final override = _faiBinaryOverride;
|
final override = _faiBinaryOverride;
|
||||||
if (override != null &&
|
if (override != null &&
|
||||||
override.isNotEmpty &&
|
override.isNotEmpty &&
|
||||||
|
|
|
||||||
|
|
@ -1778,6 +1778,20 @@
|
||||||
"setupFreeTextPrivacyRemote": "Ihre Beschreibung wird an die eingerichtete System-KI gesendet: {model} ({provider}).",
|
"setupFreeTextPrivacyRemote": "Ihre Beschreibung wird an die eingerichtete System-KI gesendet: {model} ({provider}).",
|
||||||
"@setupFreeTextPrivacyRemote": {"placeholders": {"model": {"type": "String"}, "provider": {"type": "String"}}},
|
"@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.",
|
"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.",
|
"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",
|
"setupReflectionTitle": "So verstehe ich Ihre Aufgabe",
|
||||||
"setupReflectionScenario": "Worum es geht: {label}",
|
"setupReflectionScenario": "Worum es geht: {label}",
|
||||||
|
|
|
||||||
|
|
@ -1817,6 +1817,20 @@
|
||||||
"setupFreeTextPrivacyRemote": "Your description is sent to the configured system AI: {model} ({provider}).",
|
"setupFreeTextPrivacyRemote": "Your description is sent to the configured system AI: {model} ({provider}).",
|
||||||
"@setupFreeTextPrivacyRemote": {"placeholders": {"model": {"type": "String"}, "provider": {"type": "String"}}},
|
"@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.",
|
"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.",
|
"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",
|
"setupReflectionTitle": "This is how I read your task",
|
||||||
"setupReflectionScenario": "What it is about: {label}",
|
"setupReflectionScenario": "What it is about: {label}",
|
||||||
|
|
|
||||||
|
|
@ -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.'**
|
/// **'The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.'**
|
||||||
String get setupFreeTextUnavailable;
|
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.
|
/// No description provided for @setupFreeTextParseError.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -3238,6 +3238,50 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get setupFreeTextUnavailable =>
|
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.';
|
'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
|
@override
|
||||||
String get setupFreeTextParseError =>
|
String get setupFreeTextParseError =>
|
||||||
'Die System-KI hat keinen verwertbaren Vorschlag geliefert. Wählen Sie oben aus dem Menü — oder versuchen Sie es noch einmal.';
|
'Die System-KI hat keinen verwertbaren Vorschlag geliefert. Wählen Sie oben aus dem Menü — oder versuchen Sie es noch einmal.';
|
||||||
|
|
|
||||||
|
|
@ -3234,6 +3234,50 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get setupFreeTextUnavailable =>
|
String get setupFreeTextUnavailable =>
|
||||||
'The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.';
|
'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
|
@override
|
||||||
String get setupFreeTextParseError =>
|
String get setupFreeTextParseError =>
|
||||||
'The system AI did not return a usable suggestion. Pick from the menu above — or try again.';
|
'The system AI did not return a usable suggestion. Pick from the menu above — or try again.';
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,10 @@ class _ChainErrorBoxState extends State<ChainErrorBox> {
|
||||||
border: Border.all(color: accent.withValues(alpha: 0.4)),
|
border: Border.all(color: accent.withValues(alpha: 0.4)),
|
||||||
),
|
),
|
||||||
child: Column(
|
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,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Align(
|
Align(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../data/error_presentation.dart';
|
import '../data/error_presentation.dart';
|
||||||
|
import '../data/friendly_error.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/system_actions.dart';
|
import '../data/system_actions.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
@ -118,8 +119,14 @@ class GuidedSetupDialog extends StatefulWidget {
|
||||||
|
|
||||||
static Future<void> show(BuildContext context) {
|
static Future<void> show(BuildContext context) {
|
||||||
final shell = StudioShellState.of(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>(
|
return showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
builder: (_) => GuidedSetupDialog(shell: shell),
|
builder: (_) => GuidedSetupDialog(shell: shell),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -276,12 +283,15 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
/// racy on multi-user machines. Cleaned up in [dispose].
|
/// racy on multi-user machines. Cleaned up in [dispose].
|
||||||
Directory? _answersDir;
|
Directory? _answersDir;
|
||||||
|
|
||||||
Future<String> _writeAnswers() async {
|
String _writeAnswers() {
|
||||||
_answersDir ??= await Directory.systemTemp.createTemp('chain-setup-');
|
// 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(
|
final f = File(
|
||||||
'${_answersDir!.path}${Platform.pathSeparator}answers.yaml',
|
'${_answersDir!.path}${Platform.pathSeparator}answers.yaml',
|
||||||
);
|
);
|
||||||
await f.writeAsString(_answersYaml());
|
f.writeAsStringSync(_answersYaml());
|
||||||
return f.path;
|
return f.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,7 +309,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
_busy = true;
|
_busy = true;
|
||||||
_plan = null;
|
_plan = null;
|
||||||
});
|
});
|
||||||
final path = await _writeAnswers();
|
final path = _writeAnswers();
|
||||||
final r = await SystemActions.chainInit(['--answers', path, '--plan-json']);
|
final r = await SystemActions.chainInit(['--answers', path, '--plan-json']);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _busy = false);
|
setState(() => _busy = false);
|
||||||
|
|
@ -311,13 +321,85 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
_step = _totalSteps; // review
|
_step = _totalSteps; // review
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
showFaiProcessError(context, 'chain init --plan-json', r.stdout, r.stderr);
|
await _showCliError('chain init --plan-json', r.stdout, r.stderr);
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
/// Warning lines the apply emitted on success (e.g. the empty
|
||||||
/// trusted_publishers caveat). Swallowing them made the wizard
|
/// trusted_publishers caveat). Swallowing them made the wizard
|
||||||
/// claim more than the config delivers — show them instead.
|
/// claim more than the config delivers — show them instead.
|
||||||
|
|
@ -325,7 +407,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
|
|
||||||
Future<void> _apply() async {
|
Future<void> _apply() async {
|
||||||
setState(() => _busy = true);
|
setState(() => _busy = true);
|
||||||
final path = await _writeAnswers();
|
final path = _writeAnswers();
|
||||||
final r = await SystemActions.chainInit(
|
final r = await SystemActions.chainInit(
|
||||||
['--answers', path, '--apply', '--force'],
|
['--answers', path, '--apply', '--force'],
|
||||||
);
|
);
|
||||||
|
|
@ -343,7 +425,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
|
||||||
});
|
});
|
||||||
unawaited(_probeHub());
|
unawaited(_probeHub());
|
||||||
} else {
|
} 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,
|
value: _dataLocal,
|
||||||
onChanged: (v) => setState(() => _dataLocal = v),
|
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 free-text alternative lives on the first step: describe
|
||||||
// the goal, the system AI pre-selects the menu answers. Menu
|
// the goal, the system AI pre-selects the menu answers. Menu
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
// Guided-setup wizard — the three explained answer steps. Verifies
|
// Guided-setup wizard — the three explained answer steps. Verifies
|
||||||
// that options render as localized cards with their one-line
|
// that options render as localized cards with their one-line
|
||||||
// explanation (no English enum humanization) and that Weiter/Zurück
|
// explanation (no English enum humanization) and that Weiter/Zurück
|
||||||
// walk the steps. The review step calls the CLI (a subprocess) and is
|
// walk the steps. CLI calls run through the SystemActions test seam
|
||||||
// out of scope for a widget test.
|
// 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/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/l10n/app_localizations.dart';
|
||||||
import 'package:chain_studio/widgets/guided_setup_dialog.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', () {
|
group('parseSetupSuggestion', () {
|
||||||
test('accepts a valid JSON object embedded in prose', () {
|
test('accepts a valid JSON object embedded in prose', () {
|
||||||
final r = parseSetupSuggestion(
|
final r = parseSetupSuggestion(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue