chain-studio/test/integration/hub_fixture.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

202 lines
7 KiB
Dart

// Hub fixture for integration-level tests.
//
// Spawns a real `fai serve` subprocess on a free port in a
// temporary directory, waits for its gRPC endpoint to answer
// `Healthy`, and exposes the port + a teardown so test files
// can talk to a clean Hub without setting up an isolate-side
// gRPC server in-process.
//
// Cost / scope notes:
//
// * The `fai` binary must be on the operator's PATH or at
// `target/release/fai` relative to the platform repo. We
// resolve those two paths in order; if neither exists, the
// fixture skips the suite with a clear message rather than
// failing — these tests are opt-in.
// * The hub is started with `--quiet` (no log spam) against a
// temp dir, so the operator's real `~/.fai/` stays untouched.
// * Cleanup runs `process.kill(SIGTERM)` then `process.kill()`
// after a grace period. The temp dir is removed.
// * Each fixture is independent — call `HubFixture.start()` in
// `setUp`, `fixture.dispose()` in `tearDown`. Don't share
// between tests; the few seconds of startup beat debugging
// state-bleed.
import 'dart:async';
import 'dart:io';
import 'package:chain_client_sdk/chain_client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';
class HubFixture {
/// The hub subprocess. Kept private so callers go through
/// [dispose] for shutdown.
final Process _process;
/// gRPC port the hub is listening on. Free port chosen by
/// the OS at startup time.
final int port;
/// Temp directory the hub was started against. Wiped in
/// [dispose] so tests leave no state behind on disk.
final Directory tempDir;
/// Pre-connected SDK client. Reuse across the test's
/// assertions — gRPC channel creation is cheap but
/// non-zero, and the tests don't benefit from churning it.
final HubClient client;
HubFixture._({
required Process process,
required this.port,
required this.tempDir,
required this.client,
}) : _process = process;
/// Boots a fresh hub against a temp dir and waits up to
/// `readyTimeout` for it to answer Healthy. Use
/// [skipIfBinaryMissing] = true (default) to let tests
/// `markSkipped` instead of failing when the binary isn't
/// present — that's the desired behaviour in CI environments
/// that don't build the binary first.
static Future<HubFixture?> start({
// A cold hub spends its first 30s building the curated-
// model database + initialising SQLite. 60s gives that
// honest headroom without making a hung hub look like
// a slow one.
Duration readyTimeout = const Duration(seconds: 60),
bool skipIfBinaryMissing = true,
}) async {
final binary = await _resolveBinary();
if (binary == null) {
if (skipIfBinaryMissing) {
markTestSkipped(
'Hub fixture skipped: no `chain` binary found via \$CHAIN_BIN, '
'PATH, or ../fai_chain/target/{release,debug}/chain. Build it '
'with `cargo build` in the platform repo and re-run.',
);
return null;
}
throw StateError('chain binary not found');
}
final tempDir = await Directory.systemTemp.createTemp('chain_studio_test_');
final port = await _pickFreePort();
final addr = '127.0.0.1:$port';
// Run as a child process. `serve --bind` lets us avoid the
// default 50051 (and any local daemon the developer happens
// to be running). CHAIN_DATA_DIR keeps the temp footprint
// contained — no state leaks into ~/.fai.
final process = await Process.start(
binary,
['serve', '--bind', addr],
environment: {
...Platform.environment,
'CHAIN_DATA_DIR': tempDir.path,
'CHAIN_MODULES_DIR': '${tempDir.path}/modules',
// Suppress info logs without a CLI flag — the binary
// respects RUST_LOG for tracing-subscriber.
'RUST_LOG': 'warn',
},
);
// Drain stdout/stderr to keep the OS pipe buffers from
// blocking the hub. Test failures still surface the last
// ~64 KiB of output via the dispose path.
process.stdout.transform(SystemEncoding().decoder).listen(_noop);
process.stderr.transform(SystemEncoding().decoder).listen(_noop);
// Poll the hub until it's Healthy. Bound by readyTimeout —
// a hung hub is a test failure, not a hang.
final client = HubClient(
endpoint: HubEndpoint(host: '127.0.0.1', port: port),
);
final deadline = DateTime.now().add(readyTimeout);
while (DateTime.now().isBefore(deadline)) {
try {
final ok = await client.healthy();
if (ok) {
return HubFixture._(
process: process,
port: port,
tempDir: tempDir,
client: client,
);
}
} catch (_) {
// Hub still booting; try again.
}
await Future.delayed(const Duration(milliseconds: 100));
}
// Boot didn't complete in time. Tear down + fail with a
// useful message.
process.kill(ProcessSignal.sigterm);
await tempDir.delete(recursive: true);
throw StateError(
'Hub did not become healthy within $readyTimeout. '
'Check `fai serve` works manually with CHAIN_DATA_DIR=$tempDir.',
);
}
/// Shuts down the hub and removes the temp dir. Idempotent
/// so a teardown that runs after a test-failure exit still
/// cleans up.
Future<void> dispose() async {
await client.close();
_process.kill(ProcessSignal.sigterm);
// Give the hub up to 3 seconds to flush its event log.
try {
await _process.exitCode.timeout(const Duration(seconds: 3));
} on TimeoutException {
_process.kill(ProcessSignal.sigkill);
}
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
}
/// Resolved hub binary path, for tests that also drive the CLI
/// against the fixture's data dir (e.g. seeding projects).
static Future<String?> binaryPath() => _resolveBinary();
/// Look for the hub binary: $CHAIN_BIN override first, then
/// `chain` on PATH (production-ish), then the release/debug
/// build outputs relative to the platform checkout (developer
/// flow, repo dir `fai_chain`). Returns null when nothing
/// resolves.
static Future<String?> _resolveBinary() async {
final fromEnv = Platform.environment['CHAIN_BIN'];
if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) {
return fromEnv;
}
final pathResult = await Process.run(
Platform.isWindows ? 'where' : 'which',
[Platform.isWindows ? 'chain.exe' : 'chain'],
);
if (pathResult.exitCode == 0) {
final s = (pathResult.stdout as String).trim();
if (s.isNotEmpty) return s.split('\n').first.trim();
}
final exe = Platform.isWindows ? 'chain.exe' : 'chain';
for (final dir in ['release', 'debug']) {
final f = File('../fai_chain/target/$dir/$exe');
if (await f.exists()) {
return f.absolute.path;
}
}
return null;
}
static Future<int> _pickFreePort() async {
final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final port = s.port;
await s.close();
return port;
}
}
void _noop(Object _) {}