diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 1a461cb..a0a7567 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -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 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> listMcpClients() async { final r = await _client.listMcpClients(); diff --git a/test/integration/plugin_theme_test.dart b/test/integration/plugin_theme_test.dart new file mode 100644 index 0000000..3ec9c92 --- /dev/null +++ b/test/integration/plugin_theme_test.dart @@ -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)), + ); +}