chain-studio/integration_test/guide_shots_test.dart
flemming-it cf4024a4e2 test: deterministic operator-guide screenshot harness
integration_test/guide_shots_test.dart boots a hermetic hub
(HubFixture), seeds demo projects (open Bürgeramt; sealed
Ratsinformation and a setup.applied audit event only when the
runner confirms an isolated $HOME), launches the app in German +
dark mode, walks every sidebar page in Cmd order with
content-aware waits, opens the workspace switcher and the setup
wizard, and writes the guide PNGs via a driverless RepaintBoundary
capture. Driven by the platform repo's scripts/regen-studio-guide.sh.

Also fixes the hub fixture's binary resolution, dead since the
rename (it looked for 'fai' and ../chain_platform/): now $CHAIN_BIN,
'chain' on PATH, then ../fai_chain/target/{release,debug}/chain —
the integration tests actually run again instead of silently
skipping.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-13 00:47:41 +02:00

242 lines
8.5 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/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';
/// 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',
]);
}
// Hermetic prefs: never read or write the operator's real ones.
SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(
RepaintBoundary(
key: _shotKey,
child: const StudioApp(
initialThemeMode: ThemeModeValue.dark,
initialLocale: Locale('de'),
),
),
);
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).
shell.navigateTo('audit');
await _pumpFrames(tester);
final switcher = find.byType(ChainWorkspaceSwitcher);
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.
shell.navigateTo('welcome');
await _pumpFrames(tester);
final cta = find.text('In 3 Fragen loslegen');
if (cta.evaluate().isNotEmpty) {
await tester.tap(cta.first);
await _pumpFrames(tester);
await _shot(tester, '11-einrichtung');
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await _pumpFrames(tester, 5);
}
});
}