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>
This commit is contained in:
parent
ed2c5ace26
commit
cf4024a4e2
6 changed files with 346 additions and 16 deletions
242
integration_test/guide_shots_test.dart
Normal file
242
integration_test/guide_shots_test.dart
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// 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));
|
||||
|
||||
// 01–08: 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue