// 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 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 `fai` binary found on PATH or in ' '../fai_platform/target/release/fai. Build it with ' '`cargo build --release` and re-run.', ); return null; } throw StateError('fai 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). FAI_DATA_DIR keeps the temp footprint // contained — no state leaks into ~/.fai. final process = await Process.start( binary, ['serve', '--bind', addr], environment: { ...Platform.environment, 'FAI_DATA_DIR': tempDir.path, 'FAI_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 FAI_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 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); } } /// Look for `fai` first on PATH (production-ish), then in /// the standard `target/release/` build output relative to /// the platform checkout (developer flow). Returns null when /// neither path resolves. static Future _resolveBinary() async { final pathResult = await Process.run( Platform.isWindows ? 'where' : 'which', ['fai'], ); if (pathResult.exitCode == 0) { final s = (pathResult.stdout as String).trim(); if (s.isNotEmpty) return s.split('\n').first.trim(); } final candidate = Platform.isWindows ? '../fai_platform/target/release/fai.exe' : '../fai_platform/target/release/fai'; final f = File(candidate); if (await f.exists()) { return f.absolute.path; } return null; } static Future _pickFreePort() async { final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); final port = s.port; await s.close(); return port; } } void _noop(Object _) {}