diff --git a/coverage_baseline.yaml b/coverage_baseline.yaml index e31fa51df..515fbe85d 100644 --- a/coverage_baseline.yaml +++ b/coverage_baseline.yaml @@ -3,11 +3,11 @@ # found in the LICENSE file. # Monorepo Coverage High-Water Marks (Auto-generated by test_and_fix --update-baseline) -dev_tools/catalog_gallery: 51.66 -dev_tools/composer: 20.28 -packages/a2ui_core: 76.30 +dev_tools/catalog_gallery: 51.43 +dev_tools/composer: 20.29 +packages/a2ui_core: 76.54 packages/genai_primitives: 100.00 -packages/genui: 79.38 +packages/genui: 79.66 packages/genui_a2a: 91.37 packages/json_schema_builder: 79.09 tool/e2e: 100.00 diff --git a/dev_tools/catalog_gallery/integration_test/app_test.dart b/dev_tools/catalog_gallery/integration_test/app_test.dart index 790696213..0be2c442c 100644 --- a/dev_tools/catalog_gallery/integration_test/app_test.dart +++ b/dev_tools/catalog_gallery/integration_test/app_test.dart @@ -58,9 +58,7 @@ void main() { testWidgets('catalog_gallery smoke test - verify initial state', ( tester, ) async { - await tester.pumpWidget( - CatalogGalleryApp(sampleSource: sampleSource), - ); + await tester.pumpWidget(CatalogGalleryApp(sampleSource: sampleSource)); await tester.pumpAndSettle(); expect(find.text('Catalog Gallery'), findsOneWidget); diff --git a/dev_tools/catalog_gallery/lib/sample_source.dart b/dev_tools/catalog_gallery/lib/sample_source.dart index a1aaba8f0..0861dc706 100644 --- a/dev_tools/catalog_gallery/lib/sample_source.dart +++ b/dev_tools/catalog_gallery/lib/sample_source.dart @@ -86,9 +86,7 @@ class DirectorySampleSource implements SampleSource { final List refs = files .map( (file) => SampleRef( - name: directory.fileSystem.path.basenameWithoutExtension( - file.path, - ), + name: directory.fileSystem.path.basenameWithoutExtension(file.path), load: file.readAsString, ), ) diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 6b1b33213..49a2fa869 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.10.0 (in progress) +- **BREAKING**: Changed `SurfaceDefinition.validate` to be asynchronous to support resolving `$ref` schemas via `SchemaRegistry`. - **Refactor**: genui now runs on `package:a2ui_core`. See [the migration guide](../../docs/usage/migration/migration_0.9.1_to_0.10.0.md). - **BREAKING**: A2UI message types are now `package:a2ui_core` types. The GenUI diff --git a/packages/genui/build.yaml b/packages/genui/build.yaml new file mode 100644 index 000000000..981088287 --- /dev/null +++ b/packages/genui/build.yaml @@ -0,0 +1,17 @@ +# Copyright 2025 The Flutter Authors. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +targets: + $default: + builders: + genui|schema_builder: + enabled: true + +builders: + schema_builder: + import: "tool/schema_builder.dart" + builder_factories: ["schemaBuilder"] + build_extensions: {"pubspec.yaml": ["lib/src/primitives/embedded_schemas.g.dart"]} + auto_apply: none + build_to: source diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart index 9c50c80b2..052dee4ee 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; @@ -230,10 +232,13 @@ Future _handlePress( final Stream resultStream = itemContext.dataContext.resolve( funcMap, ); + final iterator = StreamIterator(resultStream); try { - await resultStream.first; + await iterator.moveNext(); } catch (exception, stackTrace) { itemContext.reportError(exception, stackTrace); + } finally { + await iterator.cancel(); } } else { genUiLogger.warning( diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 0f1ebb28d..594222605 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -8,6 +8,7 @@ import 'dart:convert'; import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; import '../interfaces/a2ui_message_sink.dart'; import '../interfaces/surface_context.dart'; @@ -19,6 +20,8 @@ import '../model/data_model.dart'; import '../model/schema_validation.dart' as schema_validation; import '../model/ui_models.dart'; import '../primitives/a2ui_validation_exception.dart'; +import '../primitives/constants.dart'; +import '../primitives/embedded_schemas.g.dart'; import '../primitives/logging.dart'; import 'surface_registry.dart' as surface_reg; @@ -54,6 +57,18 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { late final surface_reg.SurfaceRegistry _registry = surface_reg.SurfaceRegistry(); final Map _liveDataModels = {}; + SchemaRegistry? _schemaRegistry; + + SchemaRegistry _getSchemaRegistry() { + if (_schemaRegistry != null) return _schemaRegistry!; + final registry = SchemaRegistry(); + registry.addSchema( + Uri.parse(commonTypesSchemaId), + Schema.fromMap(jsonDecode(commonTypesSchemaJson) as Map), + ); + _schemaRegistry = registry; + return registry; + } final _onSubmit = StreamController.broadcast(); final _pendingUpdates = >{}; @@ -207,18 +222,30 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { _registry.notifyUpdated(surface); // Validation does not roll back the mutation; we surface the error // and let the caller decide. - try { - final Catalog? genuiCatalog = catalogs.firstWhereOrNull( - (c) => c.effectiveCatalogId == surface.catalog.id, - ); - if (genuiCatalog != null) { - _validateComponents(coreMessage.surfaceId, surface, genuiCatalog); - } - } on A2uiValidationException catch (e) { - genUiLogger.warning( - 'Schema validation failed for surface ${e.surfaceId}: $e', - ); - reportError(e, StackTrace.current); + final Catalog? genuiCatalog = catalogs.firstWhereOrNull( + (c) => c.effectiveCatalogId == surface.catalog.id, + ); + if (genuiCatalog != null) { + _validateComponents( + coreMessage.surfaceId, + surface, + genuiCatalog, + ).catchError((Object error, StackTrace stack) { + if (_onSubmit.isClosed) return; + if (error is A2uiValidationException) { + genUiLogger.warning( + 'Schema validation failed for surface ' + '${error.surfaceId}: $error', + ); + reportError(error, stack); + } else { + genUiLogger.severe( + 'Unexpected error during surface validation', + error, + stack, + ); + } + }); } } } @@ -331,17 +358,19 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { /// Validates the components currently in [surface] against [catalog]'s /// schema. Throws [A2uiValidationException] on the first failing component. - void _validateComponents( + Future _validateComponents( String surfaceId, core.SurfaceModel surface, Catalog catalog, - ) { - schema_validation.validateComponents( + ) async { + final SchemaRegistry registry = _getSchemaRegistry(); + await schema_validation.validateComponents( surfaceId: surfaceId, components: surface.componentsModel.all.map( (c) => (id: c.id, type: c.type, json: c.toJson()), ), schema: catalog.definition, + registry: registry, ); } diff --git a/packages/genui/lib/src/model/schema_validation.dart b/packages/genui/lib/src/model/schema_validation.dart index cc742931d..b14ec268d 100644 --- a/packages/genui/lib/src/model/schema_validation.dart +++ b/packages/genui/lib/src/model/schema_validation.dart @@ -11,11 +11,12 @@ import '../primitives/simple_items.dart'; /// /// Throws [A2uiValidationException] on the first component that fails. State /// is not rolled back. -void validateComponents({ +Future validateComponents({ required String surfaceId, required Iterable<({String id, String type, JsonMap json})> components, required Schema schema, -}) { + required SchemaRegistry registry, +}) async { final List> allowedSchemas = _extractAllowedSchemas( schema.value, ); @@ -28,12 +29,16 @@ void validateComponents({ for (final s in allowedSchemas) { if (_schemaMatchesType(s, component.type)) { try { - _validateInstance( - component.json, + final List validationErrors = await Schema.fromMap( s, - '/components/${component.id}', - surfaceId, - ); + ).validate(component.json, schemaRegistry: registry); + if (validationErrors.isNotEmpty) { + throw A2uiValidationException( + validationErrors.join('; '), + surfaceId: surfaceId, + path: '/components/${component.id}', + ); + } matched = true; break; } catch (e) { @@ -98,75 +103,3 @@ bool _schemaMatchesType(Map schema, String type) { } return false; } - -void _validateInstance( - Object? instance, - Map schema, - String path, - String surfaceId, -) { - if (instance == null) return; - - if (schema case {'const': Object? constVal} when instance != constVal) { - throw A2uiValidationException( - 'Value mismatch. Expected $constVal, got $instance', - surfaceId: surfaceId, - path: path, - ); - } - if (schema case { - 'enum': List enums, - } when !enums.contains(instance)) { - throw A2uiValidationException( - 'Value not in enum: $instance', - surfaceId: surfaceId, - path: path, - ); - } - if (schema case {'required': List required} when instance is Map) { - for (final String key in required.cast()) { - if (!instance.containsKey(key)) { - throw A2uiValidationException( - 'Missing required property: $key', - surfaceId: surfaceId, - path: path, - ); - } - } - } - if (schema case { - 'properties': Map props, - } when instance is Map) { - for (final MapEntry entry in props.entries) { - final String key = entry.key; - final propSchema = entry.value as Map; - if (instance.containsKey(key)) { - _validateInstance(instance[key], propSchema, '$path/$key', surfaceId); - } - } - } - if (schema case { - 'items': Map itemsSchema, - } when instance is List) { - for (var i = 0; i < instance.length; i++) { - _validateInstance(instance[i], itemsSchema, '$path/$i', surfaceId); - } - } - if (schema case {'oneOf': List oneOfs}) { - var oneMatched = false; - for (final Map s in oneOfs.cast>()) { - try { - _validateInstance(instance, s, path, surfaceId); - oneMatched = true; - break; - } catch (_) {} - } - if (!oneMatched) { - throw A2uiValidationException( - 'Value did not match any oneOf schema', - surfaceId: surfaceId, - path: path, - ); - } - } -} diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 8f9a2fa4e..5a5b58849 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -168,13 +168,14 @@ class SurfaceDefinition { } /// Validates the UI definition against a schema. - void validate(Schema schema) { - schema_validation.validateComponents( + Future validate(Schema schema, {SchemaRegistry? registry}) async { + await schema_validation.validateComponents( surfaceId: surfaceId, components: components.values.map( (c) => (id: c.id, type: c.type, json: c.toJson()), ), schema: schema, + registry: registry ?? SchemaRegistry(), ); } } diff --git a/packages/genui/lib/src/primitives/constants.dart b/packages/genui/lib/src/primitives/constants.dart index 9203a0046..8bea68e33 100644 --- a/packages/genui/lib/src/primitives/constants.dart +++ b/packages/genui/lib/src/primitives/constants.dart @@ -5,3 +5,7 @@ /// The catalog ID for the basic catalog. const String basicCatalogId = 'https://a2ui.org/specification/v0_9/basic_catalog.json'; + +/// The schema URI for common A2UI types. +const String commonTypesSchemaId = + 'https://a2ui.org/specification/v0_9/common_types.json'; diff --git a/packages/genui/lib/src/primitives/embedded_schemas.g.dart b/packages/genui/lib/src/primitives/embedded_schemas.g.dart new file mode 100644 index 000000000..00f331a4d --- /dev/null +++ b/packages/genui/lib/src/primitives/embedded_schemas.g.dart @@ -0,0 +1,451 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// GENERATED FILE. DO NOT EDIT MANUALLY. +// To regenerate, run: dart run build_runner build + +/// Embedded schema contents of 'common_types.json'. +const String commonTypesSchemaJson = r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/common_types.json", + "title": "A2UI Common Types", + "description": "Common type definitions used across A2UI schemas.", + "$defs": { + "ComponentId": { + "type": "string", + "description": "The unique identifier for a component, used for both definitions and references within the same surface." + }, + "AccessibilityAttributes": { + "type": "object", + "description": "Attributes to enhance accessibility when using assistive technologies like screen readers.", + "properties": { + "label": { + "$ref": "#/$defs/DynamicString", + "description": "A short string, typically 1 to 3 words, used by assistive technologies to convey the purpose or intent of an element. For example, an input field might have an accessible label of 'User ID' or a button might be labeled 'Submit'." + }, + "description": { + "$ref": "#/$defs/DynamicString", + "description": "Additional information provided by assistive technologies about an element such as instructions, format requirements, or result of an action. For example, a mute button might have a label of 'Mute' and a description of 'Silences notifications about this conversation'." + } + } + }, + "ComponentCommon": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/ComponentId" + }, + "accessibility": { + "$ref": "#/$defs/AccessibilityAttributes" + } + }, + "required": ["id"] + }, + "ChildList": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/ComponentId" + }, + "description": "A static list of child component IDs." + }, + { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. The `componentId` is the component to use as a template.", + "properties": { + "componentId": { + "$ref": "#/$defs/ComponentId" + }, + "path": { + "type": "string", + "description": "The path to the list of component property objects in the data model." + } + }, + "required": ["componentId", "path"], + "additionalProperties": false + } + ] + }, + "DataBinding": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "A JSON Pointer path to a value in the data model." + } + }, + "required": ["path"], + "additionalProperties": false + }, + "DynamicValue": { + "description": "A value that can be a literal, a path, or a function call returning any type.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "$ref": "#/$defs/FunctionCall" + } + ] + }, + "DynamicString": { + "description": "Represents a string", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "string" + } + } + } + ] + } + ] + }, + "DynamicNumber": { + "description": "Represents a value that can be either a literal number, a path to a number in the data model, or a function call returning a number.", + "oneOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "number" + } + } + } + ] + } + ] + }, + "DynamicBoolean": { + "description": "A boolean value that can be a literal, a path, or a function call returning a boolean.", + "oneOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "boolean" + } + } + } + ] + } + ] + }, + "DynamicStringList": { + "description": "Represents a value that can be either a literal array of strings, a path to a string array in the data model, or a function call returning a string array.", + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "array" + } + } + } + ] + } + ] + }, + "FunctionCall": { + "type": "object", + "description": "Invokes a named function on the client.", + "properties": { + "call": { + "type": "string", + "description": "The name of the function to call." + }, + "args": { + "type": "object", + "description": "Arguments passed to the function.", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/DynamicValue" + }, + { + "type": "object", + "description": "A literal object argument (e.g. configuration)." + } + ] + } + }, + "returnType": { + "type": "string", + "description": "The expected return type of the function call.", + "enum": ["string", "number", "boolean", "array", "object", "any", "void"], + "default": "boolean" + } + }, + "required": ["call"], + "oneOf": [{"$ref": "catalog.json#/$defs/anyFunction"}] + }, + "CheckRule": { + "type": "object", + "description": "A single validation rule applied to an input component.", + "properties": { + "condition": { + "$ref": "#/$defs/DynamicBoolean" + }, + "message": { + "type": "string", + "description": "The error message to display if the check fails." + } + }, + "required": ["condition", "message"], + "additionalProperties": false + }, + "Checkable": { + "description": "Properties for components that support client-side checks.", + "type": "object", + "properties": { + "checks": { + "type": "array", + "description": "A list of checks to perform. These are function calls that must return a boolean indicating validity.", + "items": { + "$ref": "#/$defs/CheckRule" + } + } + } + }, + "Action": { + "description": "Defines an interaction handler that can either trigger a server-side event or execute a local client-side function.", + "oneOf": [ + { + "type": "object", + "description": "Triggers a server-side event.", + "properties": { + "event": { + "type": "object", + "description": "The event to dispatch to the server.", + "properties": { + "name": { + "type": "string", + "description": "The name of the action to be dispatched to the server." + }, + "context": { + "type": "object", + "description": "A JSON object containing the key-value pairs for the action context. Values can be literals or paths. Use literal values unless the value must be dynamically bound to the data model. Do NOT use paths for static IDs.", + "additionalProperties": { + "$ref": "#/$defs/DynamicValue" + } + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["event"], + "additionalProperties": false + }, + { + "type": "object", + "description": "Executes a local client-side function.", + "properties": { + "functionCall": { + "$ref": "#/$defs/FunctionCall" + } + }, + "required": ["functionCall"], + "additionalProperties": false + } + ] + } + } +} +'''; + +/// Embedded schema contents of 'server_to_client.json'. +const String serverToClientSchemaJson = r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/server_to_client.json", + "title": "A2UI Message Schema", + "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces.", + "type": "object", + "oneOf": [ + {"$ref": "#/$defs/CreateSurfaceMessage"}, + {"$ref": "#/$defs/UpdateComponentsMessage"}, + {"$ref": "#/$defs/UpdateDataModelMessage"}, + {"$ref": "#/$defs/DeleteSurfaceMessage"} + ], + "$defs": { + "CreateSurfaceMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "createSurface": { + "type": "object", + "description": "Signals the client to create a new surface and begin rendering it. It is an error to send 'createSurface' for a surfaceId that already exists without first deleting it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be rendered." + }, + "catalogId": { + "description": "A string that uniquely identifies this catalog. It is recommended to prefix this with an internet domain that you own, to avoid conflicts e.g. mycompany.com:somecatalog'.", + "type": "string" + }, + "theme": { + "$ref": "catalog.json#/$defs/theme", + "description": "Theme parameters for the surface (e.g., {'primaryColor': '#FF0000'}). These must validate against the 'theme' schema defined in the catalog." + }, + "sendDataModel": { + "type": "boolean", + "description": "If true, the client will send the full data model of this surface in the metadata of every A2A message sent to the server that created the surface. Defaults to false." + } + }, + "required": ["surfaceId", "catalogId"], + "additionalProperties": false + } + }, + "required": ["createSurface", "version"], + "additionalProperties": false + }, + "UpdateComponentsMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "updateComponents": { + "type": "object", + "description": "Updates a surface with a new set of components. This message can be sent multiple times to update the component tree of an existing surface. One of the components in one of the components lists MUST have an 'id' of 'root' to serve as the root of the component tree. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be updated." + }, + + "components": { + "type": "array", + "description": "A list containing all UI components for the surface.", + "minItems": 1, + "items": { + "$ref": "catalog.json#/$defs/anyComponent" + } + } + }, + "required": ["surfaceId", "components"], + "additionalProperties": false + } + }, + "required": ["updateComponents", "version"], + "additionalProperties": false + }, + "UpdateDataModelMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "updateDataModel": { + "type": "object", + "description": "Updates the data model for an existing surface. This message can be sent multiple times to update the data model. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." + }, + "path": { + "type": "string", + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', refers to the entire data model." + }, + "value": { + "description": "The data to be updated in the data model. If present, the value at 'path' is replaced (or created). If omitted, the key at 'path' is removed.", + "additionalProperties": true + } + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["updateDataModel", "version"], + "additionalProperties": false + }, + "DeleteSurfaceMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "deleteSurface": { + "type": "object", + "description": "Signals the client to delete the surface identified by 'surfaceId'. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." + } + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["deleteSurface", "version"], + "additionalProperties": false + } + } +} +'''; diff --git a/packages/genui/pubspec.yaml b/packages/genui/pubspec.yaml index 71506f532..dbd0e54d0 100644 --- a/packages/genui/pubspec.yaml +++ b/packages/genui/pubspec.yaml @@ -35,7 +35,11 @@ dependencies: dev_dependencies: async: ^2.13.0 + build: ^4.0.7 + build_runner: ^2.15.1 flutter_test: sdk: flutter network_image_mock: ^2.1.1 test: ^1.26.2 + + diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index 8743a9691..b5845649b 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -76,13 +76,10 @@ void main() { testWidgets('Button widget handles stream errors gracefully', ( WidgetTester tester, ) async { - ChatMessage? message; - // Create a stream controller that we can use to emit errors - final streamController = StreamController.broadcast(); - final mockFunction = MockFunction( name: 'throwError', - onExecute: (args, context) => streamController.stream, + onExecute: (args, context) => + Stream.error(Exception('Stream error')), ); final surfaceController = SurfaceController( @@ -94,6 +91,7 @@ void main() { ), ], ); + ChatMessage? message; surfaceController.onSubmit.listen((event) => message = event); const surfaceId = 'testSurface'; @@ -103,7 +101,9 @@ void main() { type: 'Button', properties: { 'child': 'button_text', - 'action': {'call': 'throwError'}, + 'action': { + 'functionCall': {'call': 'throwError'}, + }, }, ), component( @@ -132,26 +132,16 @@ void main() { await tester.pumpAndSettle(); // Tap the button to trigger the function call - await tester.tap(find.byType(ElevatedButton)); - - // Emit an error from the stream - streamController.addError(Exception('Stream error')); - - // Pump to process the error + await tester.runAsync(() async { + final Future onSubmitFuture = + surfaceController.onSubmit.first; + await tester.tap(find.byType(ElevatedButton)); + await onSubmitFuture; + }); await tester.pump(); - // Wait for the message to be received, pumping the widget tree - var retries = 0; - while (message == null && retries < 50) { - await tester.pump(const Duration(milliseconds: 10)); - retries++; - } - // Verify error was reported expect(message, isNotNull); - - // The test passes if no unhandled exception crashes the test. - await streamController.close(); surfaceController.dispose(); }); diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index cac77d9b2..81ed665fd 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -454,7 +454,7 @@ void main() { final errorObj = errorJson['error'] as Map; expect(errorObj['code'], 'VALIDATION_FAILED'); - expect(errorObj['message'], contains('Missing required property')); + expect(errorObj['message'], contains('Required property')); }, ); }); diff --git a/packages/genui/test/model/ui_models_test.dart b/packages/genui/test/model/ui_models_test.dart index 3d3a963f4..33f6529b9 100644 --- a/packages/genui/test/model/ui_models_test.dart +++ b/packages/genui/test/model/ui_models_test.dart @@ -65,7 +65,7 @@ void main() { }); group('SurfaceDefinition', () { - test('validate throws exception on mismatch', () { + test('validate throws exception on mismatch', () async { final component = const Component( id: 'test', type: 'Text', @@ -87,13 +87,13 @@ void main() { }, ); - expect( - () => surfaceDefinition.validate(schema), + await expectLater( + surfaceDefinition.validate(schema), throwsA(isA()), ); }); - test('validate passes on correct match', () { + test('validate passes on correct match', () async { final component = const Component( id: 'test', type: 'Text', @@ -117,7 +117,7 @@ void main() { }, ); - surfaceDefinition.validate(schema); // Should not throw + await surfaceDefinition.validate(schema); // Should not throw }); }); diff --git a/packages/genui/tool/schema_builder.dart b/packages/genui/tool/schema_builder.dart new file mode 100644 index 000000000..d7bf12fe8 --- /dev/null +++ b/packages/genui/tool/schema_builder.dart @@ -0,0 +1,90 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: specify_nonobvious_local_variable_types + +import 'dart:io'; +import 'package:build/build.dart'; + +Builder schemaBuilder(BuilderOptions options) => SchemaBuilder(); + +class SchemaBuilder implements Builder { + @override + Map> get buildExtensions => const { + 'pubspec.yaml': ['lib/src/primitives/embedded_schemas.g.dart'], + }; + + @override + Future build(BuildStep buildStep) async { + // Find repository root by traversing upwards until we find .gitmodules. + var foundGitmodules = false; + Directory dir = Directory.current; + while (dir.path != dir.parent.path) { + if (File('${dir.path}/.gitmodules').existsSync()) { + foundGitmodules = true; + break; + } + dir = dir.parent; + } + if (!foundGitmodules) { + throw StateError( + 'Could not find repository root containing .gitmodules starting from ' + '${Directory.current.path}. Please ensure you are running from within ' + 'the git repository and that submodules are initialized.', + ); + } + final repoRoot = dir; + final sourceDir = Directory( + '${repoRoot.path}/submodules/a2ui/specification/v0_9/json', + ); + + if (!sourceDir.existsSync()) { + throw StateError( + 'A2UI specification submodule not found at ${sourceDir.path}.\n' + 'Please initialize git submodules by running:\n' + ' git submodule update --init --recursive\n' + 'and then rebuild.', + ); + } + + final files = { + 'common_types.json': 'commonTypesSchemaJson', + 'server_to_client.json': 'serverToClientSchemaJson', + }; + + final buffer = StringBuffer(); + buffer.writeln('// Copyright 2025 The Flutter Authors.'); + buffer.writeln( + '// Use of this source code is governed by a BSD-style license that can be', + ); + buffer.writeln('// found in the LICENSE file.'); + buffer.writeln(); + buffer.writeln('// GENERATED FILE. DO NOT EDIT MANUALLY.'); + buffer.writeln('// To regenerate, run: dart run build_runner build'); + buffer.writeln(); + + for (final entry in files.entries) { + final filename = entry.key; + final variableName = entry.value; + final sourceFile = File('${sourceDir.path}/$filename'); + + if (!sourceFile.existsSync()) { + throw StateError('Source file ${sourceFile.path} not found.'); + } + + final content = sourceFile.readAsStringSync().trim(); + buffer.writeln('/// Embedded schema contents of \'$filename\'.'); + buffer.writeln('const String $variableName = r\'\'\''); + buffer.writeln(content); + buffer.writeln('\'\'\';'); + buffer.writeln(); + } + + final outputId = AssetId( + buildStep.inputId.package, + 'lib/src/primitives/embedded_schemas.g.dart', + ); + await buildStep.writeAsString(outputId, buffer.toString()); + } +} diff --git a/packages/json_schema_builder/CHANGELOG.md b/packages/json_schema_builder/CHANGELOG.md index 4ff81ea36..0970b4547 100644 --- a/packages/json_schema_builder/CHANGELOG.md +++ b/packages/json_schema_builder/CHANGELOG.md @@ -1,5 +1,9 @@ # `json_schema_builder` Change Log +## 0.1.6 (in progress) + +- **Feature**: Export `SchemaRegistry` to support managing schema references during component validation. + ## 0.1.5 - **Internal**: publishing workflow automation. diff --git a/packages/json_schema_builder/lib/json_schema_builder.dart b/packages/json_schema_builder/lib/json_schema_builder.dart index efff3323e..f5ee31640 100644 --- a/packages/json_schema_builder/lib/json_schema_builder.dart +++ b/packages/json_schema_builder/lib/json_schema_builder.dart @@ -11,6 +11,7 @@ export 'src/schema/number_schema.dart'; export 'src/schema/object_schema.dart'; export 'src/schema/schema.dart'; export 'src/schema/string_schema.dart'; +export 'src/schema_registry.dart'; export 'src/schema_validation.dart'; export 'src/validation_error.dart'; export 'src/validation_result.dart'; diff --git a/packages/json_schema_builder/pubspec.yaml b/packages/json_schema_builder/pubspec.yaml index a910d9bc6..d273bd9ed 100644 --- a/packages/json_schema_builder/pubspec.yaml +++ b/packages/json_schema_builder/pubspec.yaml @@ -4,7 +4,7 @@ name: json_schema_builder description: A full-featured package used to build and validate JSON schemas in Dart. -version: 0.1.5 +version: 0.1.6 homepage: https://github.com/flutter/genui/tree/main/packages/json_schema_builder license: BSD-3-Clause issue_tracker: https://github.com/flutter/genui/issues diff --git a/packages/json_schema_builder/test/test_suite_test.dart b/packages/json_schema_builder/test/test_suite_test.dart index a534737fa..63c399028 100644 --- a/packages/json_schema_builder/test/test_suite_test.dart +++ b/packages/json_schema_builder/test/test_suite_test.dart @@ -7,7 +7,6 @@ import 'dart:io'; import 'package:json_schema_builder/json_schema_builder.dart'; import 'package:json_schema_builder/src/logging_context.dart'; -import 'package:json_schema_builder/src/schema_registry.dart'; import 'package:test/test.dart'; void main() {