From 69b54629d35fd1bde41608acd5c85fcd01c50506 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 25 May 2026 13:22:04 +0200 Subject: [PATCH] chore(studio): flutter_markdown_plus migration + integration-test scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the May-2026 trust pass. flutter_markdown_plus migration: - pubspec swaps `flutter_markdown ^0.7.7` (discontinued upstream) for `flutter_markdown_plus ^1.0.3`, the actively-maintained fork. API surface (MarkdownStyleSheet, Markdown, MarkdownBody) is unchanged — the four import sites in welcome, store, flow_output, and theme.dart get an updated package string and that's it. - All Studio analyzer + unit-test suites stay green. Integration test scaffold: - New `test/integration/hub_fixture.dart` boots a real `fai serve` subprocess on a free port against a temp FAI_DATA_DIR, polls until Healthy, exposes a ready HubClient. Idempotent teardown wipes the temp dir. - Resolves the `fai` binary from PATH first, then from `../fai_platform/target/release/fai`. When neither exists, the fixture calls `markTestSkipped` with a clear message — fresh checkouts don't fail. - One canonical test in `capabilities_test.dart` asserts on the bug class the May trust pass surfaced: that `system.approval` appears in `list_capabilities` with `kind=builtin` so Studio's missing-deps check never tries to install it. Plus a contract-shape test that every cap's `kind` is one of the three known wire values. - README documents the cold-start gotcha (first `fai serve` per machine takes ~30s to build the curated-model DB) plus the manual warmup recipe. Not in CI yet — wiring needs the platform build job to publish `fai` as a CI artifact for downstream consumption. Deferred until enough integration tests exist to justify the CI minutes. Signed-off-by: flemming-it Signed-off-by: flemming-it --- lib/pages/store.dart | 2 +- lib/pages/welcome.dart | 2 +- lib/theme/theme.dart | 2 +- lib/widgets/fai_flow_output.dart | 2 +- pubspec.lock | 8 +- pubspec.yaml | 5 +- test/integration/README.md | 72 +++++++++ test/integration/capabilities_test.dart | 73 +++++++++ test/integration/hub_fixture.dart | 192 ++++++++++++++++++++++++ 9 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 test/integration/README.md create mode 100644 test/integration/capabilities_test.dart create mode 100644 test/integration/hub_fixture.dart diff --git a/lib/pages/store.dart b/lib/pages/store.dart index dae419e..98416c1 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -10,7 +10,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../data/hub.dart'; import '../data/system_actions.dart'; diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 94c34f5..ad9df06 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -10,7 +10,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; -import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../data/hub.dart'; diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 3546db4..7b2dd9f 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -1,7 +1,7 @@ // Theme assembly. ColorScheme + typography + component themes. import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:google_fonts/google_fonts.dart'; import 'tokens.dart'; diff --git a/lib/widgets/fai_flow_output.dart b/lib/widgets/fai_flow_output.dart index 1f15666..59fd0df 100644 --- a/lib/widgets/fai_flow_output.dart +++ b/lib/widgets/fai_flow_output.dart @@ -17,7 +17,7 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import '../data/flow_output.dart'; import '../data/format.dart'; diff --git a/pubspec.lock b/pubspec.lock index 53b5424..ff3334b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -146,14 +146,14 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_markdown: + flutter_markdown_plus: dependency: "direct main" description: - name: flutter_markdown - sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + name: flutter_markdown_plus + sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de" url: "https://pub.dev" source: hosted - version: "0.7.7+1" + version: "1.0.7" flutter_plugin_android_lifecycle: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ceb302e..166e2b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,10 @@ dependencies: google_fonts: ^8.1.0 shared_preferences: ^2.5.5 # Inline README rendering inside the Store detail sheet. - flutter_markdown: ^0.7.7 + # `flutter_markdown` is discontinued; `flutter_markdown_plus` + # is the actively-maintained drop-in fork (same + # `MarkdownStyleSheet` / `Markdown` / `MarkdownBody` API). + flutter_markdown_plus: ^1.0.3 # Today-Hero pipeline reads accepted stories from # ~/.fai/today/active.yaml — see docs/today-pipeline.md. yaml: ^3.1.2 diff --git a/test/integration/README.md b/test/integration/README.md new file mode 100644 index 0000000..bf45b96 --- /dev/null +++ b/test/integration/README.md @@ -0,0 +1,72 @@ +# Integration tests + +These tests spin up a real `fai serve` subprocess against a +fresh temp dir and exercise Studio's Hub-facing data layer +against it. They cover the "kind of regression that survives +unit tests because the system-shape only manifests across the +gRPC boundary". + +## Status + +**Scaffold.** One canonical test (capabilities). The harness is +production-quality (proper teardown, idempotent, port-clean) so +new tests slot in by importing `hub_fixture.dart` and calling +`HubFixture.start()` / `dispose()` in `setUp` / `tearDown`. + +What's NOT here yet: + +- A full "install dep → run echo → assert title" scenario. The + harness can do it (install_module is a real RPC), but + building the fixture for module-bundle downloads against a + hermetic local registry is its own scaffold. Tracked as + follow-up to S-21. +- Studio-side widget testing against the live hub. The + flutter `integration_test` package supports it + (`testWidgets` + `IntegrationTestWidgetsFlutterBinding`), + but pumping a full `StudioApp` against a real gRPC channel + needs `binding.enableSurfaceBindingHack()` ceremony we + haven't designed yet. + +## Running + +```bash +cd fai_studio +flutter test test/integration/ +``` + +Prereq: a `fai` binary on PATH or at +`../fai_platform/target/release/fai`. The harness skips with a +clear message when neither exists, so this command does not +fail on a fresh checkout — it just reports skipped tests. + +To get the binary: + +```bash +cd ../fai_platform +cargo build --release --bin fai +``` + +### First-time-start gotcha + +A cold `fai serve` spends its first ~30s building the +curated-model database and initialising SQLite migrations. +`HubFixture.start()` waits up to 60s by default; if your local +hub takes longer the first time, run `fai serve` once by hand +against any temp `FAI_DATA_DIR` to warm the per-user cargo / +SBOM caches: + +```bash +FAI_DATA_DIR=/tmp/fai_warmup fai serve --bind 127.0.0.1:0 +# wait for "hub started", Ctrl-C +``` + +Subsequent integration-test runs are quick. + +## Not yet wired into CI + +`.forgejo/workflows/ci.yml` doesn't run these yet. Adding them +needs an artifact-passing pattern: the platform build job +publishes `target/release/fai` as a CI artifact; the studio +test job consumes it. Pattern is straightforward once we want +it; it's deferred because we don't yet have enough integration +tests to justify the CI runtime. diff --git a/test/integration/capabilities_test.dart b/test/integration/capabilities_test.dart new file mode 100644 index 0000000..0063548 --- /dev/null +++ b/test/integration/capabilities_test.dart @@ -0,0 +1,73 @@ +// First integration test against a real `fai serve` subprocess. +// +// What this test covers (the regression bait): +// +// When the operator opens a flow that references `system.approval` +// or any other built-in / federated capability, the missing-deps +// check in Studio's Flows page must consider those caps already +// available — otherwise the Run button stays disabled forever +// and the operator's only recourse is to install a module that +// doesn't exist. +// +// Why this test as the canonical first scaffold: +// +// It's the exact bug class that survived three commits of the +// May-2026 trust pass before someone noticed. A real-Hub test +// for it makes regressions impossible to ship. +// +// Cost: ~2 seconds (Hub start) + ~0.1 seconds (gRPC). Acceptable. +// +// Run manually with: +// +// flutter test test/integration/ +// +// CI does not yet run these by default — they need a built `fai` +// binary in PATH or in ../fai_platform/target/release/. Wire that +// in via .forgejo/workflows/ci.yml once the platform CI publishes +// a Linux binary as an artifact for downstream jobs to consume. + +import 'package:flutter_test/flutter_test.dart'; + +import 'hub_fixture.dart'; + +void main() { + group('Hub capability listing', () { + late HubFixture? fixture; + + setUp(() async { + fixture = await HubFixture.start(); + }); + + tearDown(() async { + await fixture?.dispose(); + }); + + test('system.approval shows up as a builtin capability', () async { + final f = fixture; + if (f == null) return; // skipped — no binary + final caps = await f.client.listCapabilities(); + final approval = caps.where( + (c) => c.capability == 'system.approval', + ).toList(); + expect(approval, isNotEmpty, + reason: 'system.approval must appear in list_capabilities ' + 'so Studio does not try to install it through the store'); + expect(approval.first.kind, 'builtin', + reason: 'kind=builtin lets Studio gate the Install button correctly'); + }); + + test('every capability carries a kind tag', () async { + final f = fixture; + if (f == null) return; + final caps = await f.client.listCapabilities(); + expect(caps, isNotEmpty, + reason: 'a fresh hub has at least the system.approval builtin'); + for (final c in caps) { + expect(['wasm', 'builtin', 'federated'], contains(c.kind), + reason: 'cap ${c.capability} has unrecognised kind="${c.kind}" — ' + 'Studio gates the Install button on this string, so the wire ' + 'value must stay in the known set'); + } + }); + }); +} diff --git a/test/integration/hub_fixture.dart b/test/integration/hub_fixture.dart new file mode 100644 index 0000000..75fa2f4 --- /dev/null +++ b/test/integration/hub_fixture.dart @@ -0,0 +1,192 @@ +// 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:fai_dart_sdk/fai_dart_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('fai_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 _) {}