chain-studio/integration_test/guide_shots_test.dart
flemming-it 64c2a77dc9 feat(workspace): one global switcher anchor in the shell sidebar
The switcher used to be embedded per page (Flows/Runs/Audit/
Approvals) — invisible on the other five pages and sitting in a
different corner depending on the page (persona review 2026-08-27,
consensus finding). It now lives ONCE in the sidebar, above the
destinations: active project/area always visible, opens the same
menu everywhere, Cmd+P from anywhere. The shell listens to the
workspace, so the sidebar endpoint label can no longer lag a
sealed switch until the next health tick.

Also in this rebuild:

* Stopped sealed areas ask before starting ("Start area X?") —
  a context switch must never boot a hub daemon as a click
  side-effect; running areas keep switching with one click.
* The switcher tooltip told a wrong scope ("filters this view") —
  it now says the choice applies everywhere and stamps new runs.
* The aggregated sealed row explains itself in place (names can
  reveal client identities) and links to the Settings toggle
  (Settings dialog gained an initialCategory jump).
* The active entry carries a checkmark in the menu.
* The Cmd+K palette knows projects and areas, ranked by recent
  use; sealed names honour the privacy setting — while hidden,
  the palette offers the guarded picker instead of the names.
* The runs empty state names the active project filter as the
  cause ("No runs in project X" + show-all action) instead of
  claiming the feature is off.

Tests updated to the anchor and made hermetic (scriptable
projects on the fake hub, sealed-area fake); new coverage for the
checkmark, the why-line, and the start confirmation.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-08-28 00:00:06 +02:00

329 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Deterministic operator-guide screenshot harness (one command
// instead of the fragile cliclick/screencapture procedure).
//
// Boots a hermetic hub (HubFixture: temp data dir, free port),
// seeds deterministic demo data via the CLI (an open "Bürgeramt"
// project; optionally a sealed "Ratsinformation" when the runner
// confirms an isolated $HOME), launches the real app in German +
// dark mode against that hub, walks every sidebar destination in
// nav order, opens the workspace switcher and the guided-setup
// wizard, and writes the guide PNGs via a RepaintBoundary capture
// (driverless — works headed on macOS with plain `flutter test`).
//
// Run through the platform repo's scripts/regen-studio-guide.sh,
// which isolates $HOME, builds the hub binary, and copies the
// PNGs into fai_chain/docs/studio/images/. Direct invocation:
//
// flutter test integration_test/guide_shots_test.dart -d macos
//
// Output lands in build/guide-shots/ unless GUIDE_SHOTS_OUT is
// set in the environment.
//
// Hermeticity notes:
// - SharedPreferences is mocked empty — the operator's real Studio
// prefs are neither read nor written.
// - The app reconnects to the fixture hub with persist: false.
// - The sealed seed writes to $HOME/.chain/sealed/ by design, so
// it only runs when CHAIN_GUIDE_SHOTS_SEALED=1 confirms the
// caller isolated $HOME (the regen script does).
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint;
import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/main.dart';
import 'package:chain_studio/widgets/hub_auth_policy_panel.dart';
import 'package:chain_studio/widgets/widgets.dart';
import '../test/integration/hub_fixture.dart';
final GlobalKey _shotKey = GlobalKey();
/// Pump a fixed number of frames instead of pumpAndSettle: the
/// sidebar clock ticks every second, so the tree never settles and
/// pumpAndSettle degenerates into its multi-minute timeout.
Future<void> _pumpFrames(WidgetTester tester, [int frames = 20]) async {
for (var i = 0; i < frames; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
}
/// Pump until [finder] matches (page content arrived) or ~10 s
/// pass — pages load their data from the hub asynchronously, and a
/// blind fixed wait raced the slower ones (the flows editor).
Future<void> _pumpUntil(
WidgetTester tester,
Finder finder, {
int maxFrames = 100,
}) async {
for (var i = 0; i < maxFrames; i++) {
if (finder.evaluate().isNotEmpty) return;
await tester.pump(const Duration(milliseconds: 100));
}
}
String get _outDir =>
Platform.environment['GUIDE_SHOTS_OUT'] ?? 'build/guide-shots';
/// Guide images ship dark; GUIDE_SHOTS_THEME=light exists for the
/// light-parity proof runs (release gate: both themes must hold).
ThemeModeValue get _theme =>
Platform.environment['GUIDE_SHOTS_THEME'] == 'light'
? ThemeModeValue.light
: ThemeModeValue.dark;
/// Guide images ship German; GUIDE_SHOTS_LOCALE=en exists for
/// English-parity proof runs.
Locale get _locale => Locale(
Platform.environment['GUIDE_SHOTS_LOCALE'] == 'en' ? 'en' : 'de',
);
/// Rasterize the app's RepaintBoundary into `<outDir>/<name>.png`.
Future<void> _shot(WidgetTester tester, String name) async {
// Two extra frames so ripples/route transitions settle visually.
await tester.pump(const Duration(milliseconds: 120));
await tester.pump(const Duration(milliseconds: 120));
final boundary =
_shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 2.0);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
final file = File('$_outDir/$name.png');
file.parent.createSync(recursive: true);
file.writeAsBytesSync(bytes!.buffer.asUint8List());
// ignore: avoid_print
print('guide-shot: ${file.path}');
}
Future<void> _cli(
String binary,
HubFixture fixture,
List<String> args,
) async {
final r = await Process.run(
binary,
args,
environment: {
...Platform.environment,
'CHAIN_DATA_DIR': fixture.tempDir.path,
'CHAIN_MODULES_DIR': '${fixture.tempDir.path}/modules',
},
);
if (r.exitCode != 0) {
fail('seed command `chain ${args.join(' ')}` failed: ${r.stderr}');
}
}
/// The guide's image set, in sidebar (= Cmd number) order. Must
/// stay aligned with docs/nav.generated.json — the nav guard in
/// the platform repo keeps the guide itself honest.
const _pageShots = [
('welcome', '01-willkommen'),
('store', '02-store'),
('doctor', '03-diagnose'),
('flows', '04-flows'),
('audit', '05-protokoll'),
('approvals', '06-freigaben'),
('runs', '07-laeufe'),
('federation', '08-foederation'),
];
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('capture the operator-guide screenshots', (tester) async {
final fixture = await HubFixture.start(skipIfBinaryMissing: false);
addTearDown(fixture!.dispose);
final binary = (await HubFixture.binaryPath())!;
// Deterministic demo data. The slug is explicit — the derived
// one would drop the umlaut ("brgeramt").
await _cli(binary, fixture, [
'project',
'create',
'Bürgeramt',
'--slug',
'buergeramt',
'--color',
'#2e8f9e',
]);
// The sealed area writes a manifest under $HOME/.chain/sealed/,
// so it needs the runner's confirmation that $HOME is isolated.
final sealedSeed =
Platform.environment['CHAIN_GUIDE_SHOTS_SEALED'] == '1';
if (sealedSeed) {
await _cli(binary, fixture, [
'project',
'create',
'Ratsinformation',
'--slug',
'ratsinformation',
'--isolation',
'sealed',
]);
// Seed one audit event the guide can show (setup.applied) by
// applying a minimal guided setup against the fixture's data
// dir. Writes $HOME/.chain/config.yaml, hence the isolated-
// $HOME guard: only the regen script's throwaway HOME is
// ever touched.
final answers = File('${fixture.tempDir.path}/seed-answers.yaml');
answers.writeAsStringSync(
'scenario: trying-out\nintent: hello-world\ntarget: this-laptop\n',
);
await _cli(binary, fixture, [
'init',
'--answers',
answers.path,
'--apply',
'--force',
]);
}
// Seed two demo flows so the Flows page shows the real list
// instead of its empty state (and proves the missing-module
// logic both ways): hello needs debug.echo — not installed on
// the fixture hub, so it must carry the "1 module missing"
// note; the approval flow only uses the hub builtin
// system.approval and must show a plain play button.
final flowsDir = Directory(
'${Platform.environment['HOME']}/.chain/data/flows',
)..createSync(recursive: true);
File('${flowsDir.path}/hello.yaml').writeAsStringSync(
'# F∆I sample flow\n'
'inputs:\n'
' name:\n'
' type: string\n'
'steps:\n'
' - id: echo\n'
' use: debug.echo@^0\n'
' with:\n'
' text: "Hello, {{ inputs.name }}!"\n'
'outputs:\n'
' greeting: "{{ steps.echo.text }}"\n',
);
File('${flowsDir.path}/genehmigung-demo.yaml').writeAsStringSync(
'# F∆I sample flow\n'
'inputs:\n'
' antrag:\n'
' type: string\n'
'steps:\n'
' - id: freigabe\n'
' use: system.approval@^0\n'
' with:\n'
' show: "{{ inputs.antrag }}"\n'
'outputs:\n'
' entscheidung: "{{ steps.freigabe.decision }}"\n',
);
// Hermetic prefs: never read or write the operator's real ones.
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(
RepaintBoundary(
key: _shotKey,
child: StudioApp(
initialThemeMode: _theme,
initialLocale: _locale,
),
),
);
await HubService.instance.reconnect(
HubEndpoint(host: '127.0.0.1', port: fixture.port),
authToken: null,
persist: false,
);
await _pumpFrames(tester, 30);
final shell =
tester.state<StudioShellState>(find.byType(StudioShell));
// 0108: every sidebar destination, in Cmd order. Where a page
// loads slower content from the hub, wait for a sentinel that
// proves the content arrived before capturing.
for (final (id, shotName) in _pageShots) {
shell.navigateTo(id);
await _pumpFrames(tester);
if (id == 'flows') {
// The imported sample flows populate the file list.
await _pumpUntil(tester, find.textContaining('extract-summarize'));
} else if (id == 'audit') {
// The seeded setup.applied event (isolated-$HOME runs).
await _pumpUntil(tester, find.textContaining('setup.applied'));
}
await _shot(tester, shotName);
}
// 09: the workspace switcher, opened (shows the demo projects;
// with the sealed seed also the shield entry). The switcher is
// the ONE sidebar anchor now — global on every page.
shell.navigateTo('audit');
await _pumpFrames(tester);
final switcher = find.byType(ChainWorkspaceAnchor);
if (switcher.evaluate().isNotEmpty) {
await tester.tap(switcher.first);
await _pumpFrames(tester);
await _shot(tester, '09-projekt-umschalter');
// Close the menu again before moving on.
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await _pumpFrames(tester, 5);
}
// 11: the guided-setup wizard, step 1 (localized option cards).
// 10 (inside a sealed area) still needs the manual procedure —
// it requires switching to a second, isolated hub instance.
// The wizard no longer lives on Welcome (first-run gate owns a
// fresh install; re-running is a Settings action) — capture it
// via Settings → General like an operator would reach it.
shell.navigateTo('welcome');
await _pumpFrames(tester);
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // close leftovers
await _pumpFrames(tester, 5);
ChainSettingsDialog.show(tester.element(find.byType(StudioShell)));
await _pumpFrames(tester);
final runSetup = find.textContaining(
_locale.languageCode == 'en' ? 'Run setup again' : 'Einrichtung erneut',
);
if (runSetup.evaluate().isNotEmpty) {
await tester.ensureVisible(runSetup.first);
await tester.tap(runSetup.first);
await _pumpFrames(tester);
await _shot(tester, '11-einrichtung');
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await _pumpFrames(tester, 5);
await tester.sendKeyEvent(LogicalKeyboardKey.escape); // settings too
await _pumpFrames(tester, 5);
}
// Proof shot (not in the guide's image set, hence env-gated —
// the regen script keeps its stable 0111 output): Settings →
// Security with the live auth-policy panel loaded from the
// fixture hub.
if (Platform.environment['GUIDE_SHOTS_SECURITY'] == '1') {
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await _pumpFrames(tester, 5);
ChainSettingsDialog.show(tester.element(find.byType(StudioShell)));
await _pumpFrames(tester);
final securityTab = find.text(
_locale.languageCode == 'en' ? 'Security' : 'Sicherheit',
);
await _pumpUntil(tester, securityTab);
await tester.tap(securityTab.first);
await _pumpFrames(tester);
final policyPanel = find.byType(HubAuthPolicyPanel);
await _pumpUntil(tester, policyPanel);
await tester.ensureVisible(policyPanel.first);
await _pumpFrames(tester, 10);
await _shot(tester, '12-sicherheit');
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await _pumpFrames(tester, 5);
}
});
}