feat(studio): HubService.invokePluginTheme + integration test

Wraps the new InvokePluginTheme RPC and ships an end-to-end
test that loads studio-theme-solarized into a fresh
HubFixture and asserts the light/dark schemes round-trip.

Signed-off-by: flemming-it <sf@flemming.it>
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-25 21:49:40 +02:00
parent efa9871a75
commit a5bcde6446
2 changed files with 112 additions and 0 deletions

View file

@ -396,6 +396,30 @@ class HubService {
return (state: state, text: r.text, sourcePath: r.sourcePath);
}
/// Invoke a Studio theme plugin's `theme` hook and unwrap
/// the result into Flutter-friendly `ColorScheme` slots.
///
/// Returns a record:
/// - `brightness`: echo of the input.
/// - `tokens`: 14 32-bit ARGB values in Material 3
/// order. Caller passes them to
/// `ColorScheme(...)` after deciding the
/// `brightness` enum.
///
/// Throws on hub-side errors (plugin not installed, plugin
/// declined the brightness, etc.) caller routes the
/// exception through `friendlyError` like every other RPC.
Future<({String brightness, List<int> tokens})> invokePluginTheme({
required String capability,
required String brightness,
}) async {
final r = await _client.invokePluginTheme(
capability: capability,
brightness: brightness,
);
return (brightness: r.brightness, tokens: r.tokens.toList());
}
/// Snapshot of every configured MCP server.
Future<List<McpClientInfo>> listMcpClients() async {
final r = await _client.listMcpClients();

View file

@ -0,0 +1,88 @@
// End-to-end integration test for the Studio-plugin theme
// hook: spins a real `fai serve`, installs the in-tree
// `studio-theme-solarized` plugin via direct module-dir
// staging (mirror-install would also work but takes longer
// and pulls a network egress), then calls `invokePluginTheme`
// and asserts the round-trip.
//
// Run manually with:
//
// flutter test test/integration/plugin_theme_test.dart
//
// Pre-reqs (handled by HubFixture's skip-when-missing path):
// - fai binary on PATH or at ../fai_platform/target/release/fai
// - studio-theme-solarized plugin built to module.wasm in its
// fai_modules dir.
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'hub_fixture.dart';
void main() {
test(
'invokePluginTheme returns Solarized Light + Dark schemes',
() async {
final fixture = await HubFixture.start();
if (fixture == null) return;
try {
// Stage the plugin into the fixture's modules dir
// ourselves. Going through `installModule` would
// require the plugin to be on the mirror, which would
// add network + bundle-fetch latency that doesn't
// belong in an in-process round-trip test.
final pluginSrc = Directory(
'../fai_modules/studio-theme-solarized',
);
if (!await pluginSrc.exists()) {
markTestSkipped('studio-theme-solarized source not available');
return;
}
final wasm = File('${pluginSrc.path}/module.wasm');
if (!await wasm.exists()) {
markTestSkipped(
'studio-theme-solarized has no built module.wasm — '
'run `cargo build --release --target wasm32-wasip2` first',
);
return;
}
final dest = Directory(
'${fixture.tempDir.path}/modules/studio-theme-solarized',
);
await dest.create(recursive: true);
await File('${pluginSrc.path}/module.yaml')
.copy('${dest.path}/module.yaml');
await wasm.copy('${dest.path}/module.wasm');
// Call the hook for both brightnesses.
final light = await fixture.client.invokePluginTheme(
capability: 'studio.theme.solarized',
brightness: 'light',
);
expect(light.brightness, 'light');
expect(light.tokens.length, 14);
// Solarized blue is the pinned primary in both schemes.
expect(light.tokens[0], 0xFF268BD2);
final dark = await fixture.client.invokePluginTheme(
capability: 'studio.theme.solarized',
brightness: 'dark',
);
expect(dark.brightness, 'dark');
expect(dark.tokens.length, 14);
expect(dark.tokens[0], 0xFF268BD2);
// Different surface tokens between Light and Dark
// regression guard against a plugin that returns the
// same scheme for both.
expect(light.tokens[8], isNot(equals(dark.tokens[8])));
} finally {
await fixture.dispose();
}
},
// Component-Model instantiation can take a few seconds on
// first run as wasmtime caches the compiled artefact.
timeout: const Timeout(Duration(minutes: 1)),
);
}