chore(studio): flutter_markdown_plus migration + integration-test scaffold
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 <sf@flemming.it> Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
c11461b0f9
commit
69b54629d3
9 changed files with 349 additions and 9 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
72
test/integration/README.md
Normal file
72
test/integration/README.md
Normal file
|
|
@ -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.
|
||||
73
test/integration/capabilities_test.dart
Normal file
73
test/integration/capabilities_test.dart
Normal file
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
192
test/integration/hub_fixture.dart
Normal file
192
test/integration/hub_fixture.dart
Normal file
|
|
@ -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<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 `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<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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String?> _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<int> _pickFreePort() async {
|
||||
final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final port = s.port;
|
||||
await s.close();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
void _noop(Object _) {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue