- Regulated path finishes without a terminal: the signed-source state offers 'Add a signed source…' (stores dialog with pin-a-key) plus the per-module install buttons and a plain-language hint why pinning the publisher's key matters — instead of a hint with no affordance. - Apply warnings (e.g. the empty-trusted-publishers caveat) surface selectable in the done state instead of being swallowed. - Truthful preview: new lines state which machine is being set up (server/container targets configure THIS machine), that regulated profiles always get the hash-chained audit log (even with WORM off), and that the curated reading list is stored with the setup record. - Language pass: onboarding checklist in Sie-form + 'System-KI' (was du-form + 'System-AI'), 'Audit-Sperre' jargon replaced, answers file moved to a private per-dialog temp dir. - Screenshot harness: GUIDE_SHOTS_THEME=light for light-parity proof runs. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
249 lines
8.7 KiB
Dart
249 lines
8.7 KiB
Dart
// 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';
|
||
|
||
/// 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;
|
||
|
||
/// 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: StudioApp(
|
||
initialThemeMode: _theme,
|
||
initialLocale: const 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);
|
||
}
|
||
});
|
||
}
|