From e51fd0132da4ecd0583747ba6ee63fccd7d7ec94 Mon Sep 17 00:00:00 2001 From: Sharjeel Yunus Date: Thu, 22 Oct 2026 03:35:28 +0500 Subject: [PATCH 1/3] feat(tabs): add testId property for tab items and improve asset loading logic - Introduced a `testId` property for tab items to facilitate easier testing and identification of tabs. - Enhanced asset loading logic to ignore non-string configuration values, ensuring only valid asset URLs are processed. - Updated tests to validate the new `testId` functionality and asset loading behavior. --- modules/ensemble/doc/layout-widgets.md | 2 + modules/ensemble/lib/ensemble.dart | 142 +++++++++++++----- .../apiproviders/http_api_provider.dart | 5 +- .../lib/framework/assets_service.dart | 11 +- .../ensemble_provider.dart | 9 +- .../definition_providers/provider.dart | 9 +- modules/ensemble/lib/framework/secrets.dart | 29 ++-- .../lib/framework/storage_manager.dart | 65 +++++++- .../ensemble/lib/layout/tab/base_tab_bar.dart | 11 +- .../lib/layout/tab/tab_bar_controller.dart | 3 + .../ensemble/test/assets_service_test.dart | 24 +++ modules/ensemble/test/cdn_provider_test.dart | 27 +++- .../ensemble/test/storage_manager_test.dart | 54 ++++++- .../test/widget/tab_bar_index_test.dart | 64 ++++++-- .../assets/schema/ensemble_tests_schema.json | 32 +++- 15 files changed, 400 insertions(+), 87 deletions(-) diff --git a/modules/ensemble/doc/layout-widgets.md b/modules/ensemble/doc/layout-widgets.md index 6ef92e140..c663a7242 100644 --- a/modules/ensemble/doc/layout-widgets.md +++ b/modules/ensemble/doc/layout-widgets.md @@ -14,6 +14,7 @@ renders only the tab navigation. Both are backed by `BaseTabBar`, | EDL field or method | Behavior | | --- | --- | | `items` | List of tab definitions. Each item must provide at least one of `label`, `icon`, or `tabWidget`. | +| `testId` | Optional stable test key on a tab item. Test actions can target it without wrapping the label in a custom widget. | | `body`, `widget`, `bodyWidget` | Aliases for the body rendered when the tab is selected. | | `tabWidget`, `tabItem` | Aliases for a custom tab label widget. | | `selectedIndex` | Initial and current selected tab index. Out-of-range values are reset to `0` when tabs are rebuilt. | @@ -44,6 +45,7 @@ View: Text: text: Account overview - label: Activity + testId: activityTab visible: ${account.showActivity == true} body: ListView: diff --git a/modules/ensemble/lib/ensemble.dart b/modules/ensemble/lib/ensemble.dart index 95ea5d10e..88205ecb1 100644 --- a/modules/ensemble/lib/ensemble.dart +++ b/modules/ensemble/lib/ensemble.dart @@ -187,42 +187,8 @@ class Ensemble extends WithEnsemble with EnsembleRouteObserver { ); } - // environment variable overrides - // Load all sources, then merge with precedence: - // 1) environmentVariables in ensemble-config.yaml - // 2) .env.config - // 3) appConfig.json envVariables - final Map envOverrides = {}; - try { - dynamic path = yamlMap["definitions"]?['local']?["path"]; - final configString = - await rootBundle.loadString('${path}/config/appConfig.json'); - final Map configMap = json.decode(configString); - if (configMap["envVariables"] != null) { - configMap["envVariables"].forEach((key, value) { - envOverrides[key] = value; - }); - } - } catch (e) { - // ignore missing/invalid appConfig.json - } - - // 2) .env.config (middle priority) - dynamic localPath = yamlMap["definitions"]?['local']?["path"]; - if (localPath is String && localPath.isNotEmpty) { - _mergeWithOverwrite( - envOverrides, await _loadDotEnvConfigFile('$localPath/.env.config')); - } - _mergeWithOverwrite( - envOverrides, await _loadDotEnvConfigFile('.env.config')); - _mergeWithOverwrite( - envOverrides, await _loadDotEnvConfigFile('ensemble/.env.config')); - - // 1) environmentVariables from ensemble-config.yaml (highest priority) - dynamic env = yamlMap['environmentVariables']; - if (env is YamlMap) { - env.forEach((key, value) => envOverrides[key.toString()] = value); - } + final Map envOverrides = + await loadEnvironmentOverrides(yamlMap); DefinitionProvider definitionProvider = DefinitionProvider.from(yamlMap); _config = EnsembleConfig( @@ -232,6 +198,13 @@ class Ensemble extends WithEnsemble with EnsembleRouteObserver { services: Services.fromYaml(yamlMap['services']), signInServices: SignInServices.fromYaml(yamlMap['services']), envOverrides: envOverrides); + // Keep the resolved environment available to runtime services too. EnvConfig + // already resolves these values from envOverrides, but API provider + // initialization reads UserAppConfig.envVariables directly. Without this + // merge, values supplied only in .env.config (or the app-level YAML) work + // in bindings while provider configuration silently falls back to the + // possibly stale appConfig.json copy. + applyEnvironmentOverrides(_config!, envOverrides); // Initializing Local Assets Service to store available local assets names if (!LocalAssetsService.isInitialized) { await LocalAssetsService.initialize( @@ -251,6 +224,58 @@ class Ensemble extends WithEnsemble with EnsembleRouteObserver { return yamlMap['definitions']?['ensemble']?['appId']; } + /// Resolves environment values for runtime startup and host test harnesses. + /// Precedence is appConfig.json, app-scoped/root .env.config, then the + /// environmentVariables map in ensemble-config.yaml. + Future> loadEnvironmentOverrides( + YamlMap yamlMap) async { + final Map envOverrides = {}; + try { + final path = yamlMap['definitions']?['local']?['path']; + if (path is String && path.isNotEmpty) { + final configString = + await rootBundle.loadString('$path/config/appConfig.json'); + final Map configMap = json.decode(configString); + final variables = configMap['envVariables']; + if (variables is Map) { + variables.forEach((key, value) { + envOverrides[key.toString()] = value; + }); + } + } + } catch (_) { + // appConfig.json is optional; continue with the other env sources. + } + + final localPath = yamlMap['definitions']?['local']?['path']; + if (localPath is String && localPath.isNotEmpty) { + _mergeWithOverwrite(envOverrides, + await _loadLocalDotEnvConfigFile(localPath, yamlMap)); + } + _mergeWithOverwrite( + envOverrides, await _loadDotEnvConfigFile('.env.config')); + _mergeWithOverwrite( + envOverrides, await _loadDotEnvConfigFile('ensemble/.env.config')); + + final env = yamlMap['environmentVariables']; + if (env is YamlMap) { + env.forEach((key, value) => envOverrides[key.toString()] = value); + } + return envOverrides; + } + + /// Makes resolved environment values available to both bindings and runtime + /// services that read directly from [UserAppConfig]. + void applyEnvironmentOverrides( + EnsembleConfig config, Map envOverrides) { + config.updateEnvOverrides(envOverrides); + final appConfig = config.definitionProvider.getAppConfig(); + if (appConfig != null && envOverrides.isNotEmpty) { + appConfig.envVariables ??= {}; + appConfig.envVariables!.addAll(envOverrides); + } + } + Future> _loadDotEnvConfigFile(String fileName) async { try { final String content = await rootBundle.loadString(fileName); @@ -260,6 +285,51 @@ class Ensemble extends WithEnsemble with EnsembleRouteObserver { } } + /// Loads the dotenv file for locally bundled definitions. + /// + /// `definitions.local.path` may point at a directory containing multiple + /// apps (for example `ensemble/apps/`) rather than the active app directory. + /// In that case, select the app-specific file from `local.appId` when it is + /// bundled, or use a nested file only when there is exactly one candidate. + /// This avoids silently combining environment values from unrelated apps. + Future> _loadLocalDotEnvConfigFile( + String localPath, YamlMap yamlMap) async { + final root = localPath.endsWith('/') ? localPath : '$localPath/'; + final directPath = '${root}.env.config'; + final directValues = await _loadDotEnvConfigFile(directPath); + if (directValues.isNotEmpty) { + return directValues; + } + + try { + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + final assets = manifest.listAssets(); + final localConfig = yamlMap['definitions']?['local']; + final appId = localConfig is YamlMap ? localConfig['appId'] : null; + if (appId is String && appId.isNotEmpty) { + final appPath = '$root$appId/.env.config'; + if (assets.contains(appPath)) { + return _loadDotEnvConfigFile(appPath); + } + } + + final candidates = assets + .where((asset) => + asset.startsWith(root) && + asset.endsWith('/.env.config') && + asset != directPath) + .toList(); + if (candidates.length == 1) { + return _loadDotEnvConfigFile(candidates.single); + } + } catch (_) { + // Asset-manifest discovery is best-effort; preserve the other config + // sources if the manifest is unavailable on a platform. + } + + return {}; + } + void _mergeWithOverwrite( Map target, Map source) { source.forEach((key, value) { diff --git a/modules/ensemble/lib/framework/apiproviders/http_api_provider.dart b/modules/ensemble/lib/framework/apiproviders/http_api_provider.dart index 07d997d52..9e8ac5d8a 100644 --- a/modules/ensemble/lib/framework/apiproviders/http_api_provider.dart +++ b/modules/ensemble/lib/framework/apiproviders/http_api_provider.dart @@ -471,6 +471,9 @@ class HttpResponse extends Response { try { body = json.decode(response.body); } on FormatException catch (_, e) { + // Keep non-JSON payloads available to callers and diagnostics instead + // of discarding the actual server response. + body = response.body; log('Warning - Only JSON response is supported'); } super.apiState = apiState; @@ -501,4 +504,4 @@ class HttpResponse extends Response { bool get isOkay => statusCode != null && statusCode! >= 200 && statusCode! <= 299; // bool get isError => !isSuccess; -} \ No newline at end of file +} diff --git a/modules/ensemble/lib/framework/assets_service.dart b/modules/ensemble/lib/framework/assets_service.dart index 6406d7744..1a4b0f513 100644 --- a/modules/ensemble/lib/framework/assets_service.dart +++ b/modules/ensemble/lib/framework/assets_service.dart @@ -40,8 +40,15 @@ class LocalAssetsService { List foundAssets = []; if (assetEnvVariables.isNotEmpty) { for (var entry in assetEnvVariables.entries) { - String assetName = - Utils.getAssetName(entry.value); // Get the asset name + // App configuration may include numeric and boolean settings alongside + // asset URLs. Only string values can name an asset; passing another + // value to getAssetName throws during otherwise normal app startup. + if (entry.value is! String || entry.value.isEmpty) continue; + String assetName = Utils.getAssetName(entry.value as String); + // A base URL such as `https://.../assets/` has no filename. Treating + // its empty last path segment as a bundled asset can mark the assets + // directory itself as an image and later try to decode that directory. + if (assetName.isEmpty) continue; String provider = definations['definitions']?['from']; String path = definations['definitions']?['local']['path']; String assetPath = provider == 'local' diff --git a/modules/ensemble/lib/framework/definition_providers/ensemble_provider.dart b/modules/ensemble/lib/framework/definition_providers/ensemble_provider.dart index 298833730..aa22b3f3c 100644 --- a/modules/ensemble/lib/framework/definition_providers/ensemble_provider.dart +++ b/modules/ensemble/lib/framework/definition_providers/ensemble_provider.dart @@ -269,11 +269,10 @@ class AppModel { // Check if artifact refresh is enabled via environment variable bool get _isArtifactRefreshEnabled { - String? enableRefresh = appConfig?.envVariables?['ENABLE_ARTIFACT_REFRESH']; - if (enableRefresh != null) { - return enableRefresh.toLowerCase() == 'true'; - } - return false; // Default: disabled + final value = appConfig?.envVariables?['ENABLE_ARTIFACT_REFRESH']; + if (value is bool) return value; + if (value is String) return value.toLowerCase() == 'true'; + return false; } // Get the last checked time from persistent storage diff --git a/modules/ensemble/lib/framework/definition_providers/provider.dart b/modules/ensemble/lib/framework/definition_providers/provider.dart index 8d45d5d21..5c1b6b648 100644 --- a/modules/ensemble/lib/framework/definition_providers/provider.dart +++ b/modules/ensemble/lib/framework/definition_providers/provider.dart @@ -61,11 +61,10 @@ abstract class DefinitionProvider { // Check if artifact refresh is enabled via environment variable // Default: false (disabled) - apps must explicitly enable it bool isArtifactRefreshEnabled() { - String? enableRefresh = getAppConfig()?.envVariables?['ENABLE_ARTIFACT_REFRESH']; - if (enableRefresh != null) { - return enableRefresh.toLowerCase() == 'true'; - } - return false; // Default: disabled + final value = getAppConfig()?.envVariables?['ENABLE_ARTIFACT_REFRESH']; + if (value is bool) return value; + if (value is String) return value.toLowerCase() == 'true'; + return false; } // build the definition model from YAML diff --git a/modules/ensemble/lib/framework/secrets.dart b/modules/ensemble/lib/framework/secrets.dart index 616c00a04..b36a79979 100644 --- a/modules/ensemble/lib/framework/secrets.dart +++ b/modules/ensemble/lib/framework/secrets.dart @@ -35,16 +35,25 @@ class SecretsStore with Invokable { if (provider == 'local') { String path = EnsembleConfigService.config["definitions"]?['local']?["path"]; - final secretsString = - await rootBundle.loadString('${path}/config/secrets.json'); - final Map appSecretsMap = json.decode(secretsString); - appSecretsMap["secrets"].forEach((key, value) { - secrets![key] = value; - }); - - // add secrets from ${path}/.env.secrets file - await dotenv.load(fileName: '${path}/.env.secrets'); - secrets.addAll(dotenv.env); + // The legacy JSON file is optional. Keep its failure isolated so a + // missing config/secrets.json does not prevent loading .env.secrets. + try { + final secretsString = + await rootBundle.loadString('${path}/config/secrets.json'); + final Map appSecretsMap = + json.decode(secretsString); + final appSecrets = appSecretsMap['secrets']; + if (appSecrets is Map) { + appSecrets.forEach((key, value) { + secrets[key.toString()] = value; + }); + } + } catch (_) {} + + try { + await dotenv.load(fileName: '${path}/.env.secrets'); + secrets.addAll(dotenv.env); + } catch (_) {} } } catch (_) {} // add secrets from .env.secrets file in ensemble folder diff --git a/modules/ensemble/lib/framework/storage_manager.dart b/modules/ensemble/lib/framework/storage_manager.dart index 33c868371..97f1890ab 100644 --- a/modules/ensemble/lib/framework/storage_manager.dart +++ b/modules/ensemble/lib/framework/storage_manager.dart @@ -1,6 +1,8 @@ import 'dart:convert'; import 'dart:developer'; import "package:universal_html/html.dart"; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:ensemble/framework/apiproviders/firestore/firestore_types.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:get_storage/get_storage.dart'; @@ -68,11 +70,12 @@ mixin PublicStorage { } /// read from public storage - T? read(String key) => GetStorage().read(key); + T? read(String key) => + restorePublicStorageValue(GetStorage().read(key)) as T?; /// write to public storage Future write(String key, dynamic value) => - GetStorage().write(key, value); + GetStorage().write(key, encodePublicStorageValue(value)); Future remove(String key) => GetStorage().remove(key); @@ -90,6 +93,64 @@ mixin PublicStorage { } } +const String _storageValueTypeKey = '__ensemble_storage_value_type__'; +const String _firestoreTimestampStorageType = 'firestore_timestamp_v1'; + +/// Encodes Firestore timestamps as tagged JSON maps so persistent storage can +/// serialize them without losing their type. Maps and lists are copied +/// recursively because Firestore values are commonly nested in cached data. +dynamic encodePublicStorageValue(dynamic value) { + if (value is FirestoreTimestamp) { + return _encodeFirestoreTimestamp(value.timestamp); + } + if (value is Timestamp) { + return _encodeFirestoreTimestamp(value); + } + if (value is Map) { + return value.map( + (key, nestedValue) => MapEntry( + key, + encodePublicStorageValue(nestedValue), + ), + ); + } + if (value is List) { + return value.map(encodePublicStorageValue).toList(); + } + return value; +} + +/// Restores tagged Firestore timestamps whenever data is read from public +/// storage, including timestamps nested in maps and lists. +dynamic restorePublicStorageValue(dynamic value) { + if (value is Map) { + if (value.length == 3 && + value[_storageValueTypeKey] == _firestoreTimestampStorageType && + value['seconds'] is int && + value['nanoseconds'] is int) { + return FirestoreTimestamp( + Timestamp(value['seconds'] as int, value['nanoseconds'] as int), + ); + } + return value.map( + (key, nestedValue) => MapEntry( + key, + restorePublicStorageValue(nestedValue), + ), + ); + } + if (value is List) { + return value.map(restorePublicStorageValue).toList(); + } + return value; +} + +Map _encodeFirestoreTimestamp(Timestamp timestamp) => { + _storageValueTypeKey: _firestoreTimestampStorageType, + 'seconds': timestamp.seconds, + 'nanoseconds': timestamp.nanoseconds, + }; + /// secure storage. These are async so really only use-able /// at the system-level (all our Javascript are synchronous) /// Note that secure storage operates on String, but we automatically diff --git a/modules/ensemble/lib/layout/tab/base_tab_bar.dart b/modules/ensemble/lib/layout/tab/base_tab_bar.dart index 3e2c085e0..88c30471f 100644 --- a/modules/ensemble/lib/layout/tab/base_tab_bar.dart +++ b/modules/ensemble/lib/layout/tab/base_tab_bar.dart @@ -114,15 +114,20 @@ abstract class BaseTabBarState extends EWidgetState } Widget _buildTabWidget(ScopeManager? scopeManager, TabItem tabItem) { + final activeScope = scopeManager!; final tabWidget = tabItem.tabWidget; - final label = scopeManager!.dataContext.eval(tabItem.label); - if (scopeManager != null && tabWidget != null) { - final customWidget = scopeManager.buildWidgetFromDefinition(tabWidget); + final label = activeScope.dataContext.eval(tabItem.label); + final key = + tabItem.testId?.isNotEmpty == true ? ValueKey(tabItem.testId!) : null; + if (tabWidget != null) { + final customWidget = activeScope.buildWidgetFromDefinition(tabWidget); return Tab( + key: key, child: customWidget, ); } return Tab( + key: key, text: label, icon: tabItem.icon != null ? ensemble.Icon.fromModel(tabItem.icon!) : null, diff --git a/modules/ensemble/lib/layout/tab/tab_bar_controller.dart b/modules/ensemble/lib/layout/tab/tab_bar_controller.dart index 38e448c1b..418849bf3 100644 --- a/modules/ensemble/lib/layout/tab/tab_bar_controller.dart +++ b/modules/ensemble/lib/layout/tab/tab_bar_controller.dart @@ -43,6 +43,7 @@ class TabBarController extends BoxController { _originalItems.add(TabItem( icon: Utils.getIcon(item['icon']), label: Utils.optionalString(item['label']), + testId: Utils.optionalString(item['testId']), tabWidget: item['tabWidget'] ?? item['tabItem'], bodyWidget: item['bodyWidget'] ?? item['widget'] ?? item['body'], isVisible: _setVisible(item['visible']), @@ -83,6 +84,7 @@ class TabItem { TabItem( {this.icon, this.label, + this.testId, this.tabWidget, this.bodyWidget, this.isVisible}) { @@ -94,6 +96,7 @@ class TabItem { IconModel? icon; String? label; + String? testId; dynamic tabWidget; // custom tab widget dynamic bodyWidget; // tab's body widget Visible? isVisible; diff --git a/modules/ensemble/test/assets_service_test.dart b/modules/ensemble/test/assets_service_test.dart index 98c851be0..01ea97a26 100644 --- a/modules/ensemble/test/assets_service_test.dart +++ b/modules/ensemble/test/assets_service_test.dart @@ -143,5 +143,29 @@ definitions: expect(envVariables, {'apiURL': 'https://dummyjson.com'}); expect(LocalAssetsService.localAssets, isEmpty); }); + + test('ignores non-string configuration values while scanning assets', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (_) async => null); + + final Map envVariables = { + 'remoteLogo': 'https://example.com/logo.png', + 'remote_config_minimum_fetch_interval': 0, + 'featureEnabled': true, + }; + final YamlMap definitions = loadYaml(''' +definitions: + from: local + local: + path: ensemble/apps/configuredAssetsApp +'''); + + await LocalAssetsService.initialize(envVariables, definitions); + + expect(envVariables['remote_config_minimum_fetch_interval'], 0); + expect(envVariables['featureEnabled'], isTrue); + expect(LocalAssetsService.localAssets, isEmpty); + }); }); } diff --git a/modules/ensemble/test/cdn_provider_test.dart b/modules/ensemble/test/cdn_provider_test.dart index 3b855f166..b1a89908a 100644 --- a/modules/ensemble/test/cdn_provider_test.dart +++ b/modules/ensemble/test/cdn_provider_test.dart @@ -123,6 +123,26 @@ void main() { }); }); + group('CDN artifact refresh config', () { + test('accepts a JSON boolean value', () async { + final provider = CdnDefinitionProvider('artifact-refresh-bool-app'); + await provider.applyRuntimeManifestForTesting( + _manifestWithArtifactRefresh(_manifestWithoutNewKey(), value: true), + ); + + expect(provider.isArtifactRefreshEnabled(), isTrue); + }); + + test('continues to accept string values', () async { + final provider = CdnDefinitionProvider('artifact-refresh-string-app'); + await provider.applyRuntimeManifestForTesting( + _manifestWithArtifactRefresh(_manifestWithoutNewKey(), value: 'true'), + ); + + expect(provider.isArtifactRefreshEnabled(), isTrue); + }); + }); + group('CDN translation runtime refresh', () { testWidgets('picks up newly added translation keys without app restart', (tester) async { @@ -371,14 +391,17 @@ void main() { }); } -Map _manifestWithArtifactRefresh(Map manifest) { +Map _manifestWithArtifactRefresh( + Map manifest, { + Object value = 'true', +}) { final artifacts = Map.from(manifest['artifacts'] as Map); final config = Map.from(artifacts['config'] as Map? ?? {}); final envVariables = Map.from( config['envVariables'] as Map? ?? {}); - envVariables['ENABLE_ARTIFACT_REFRESH'] = 'true'; + envVariables['ENABLE_ARTIFACT_REFRESH'] = value; config['envVariables'] = envVariables; artifacts['config'] = config; return {'artifacts': artifacts}; diff --git a/modules/ensemble/test/storage_manager_test.dart b/modules/ensemble/test/storage_manager_test.dart index aa261c6aa..1071f7f1e 100644 --- a/modules/ensemble/test/storage_manager_test.dart +++ b/modules/ensemble/test/storage_manager_test.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:ensemble/framework/apiproviders/firestore/firestore_types.dart'; import 'package:ensemble/framework/storage_manager.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -8,7 +10,37 @@ import 'package:get_storage/get_storage.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('clear removes public keys but retains encrypted keys', () async { + test('public storage normalizes Firestore timestamps recursively', () { + final timestamp = FirestoreTimestamp(Timestamp(123, 456789000)); + final nativeTimestamp = Timestamp(456, 789123000); + + final encoded = encodePublicStorageValue({ + 'lastUpdated': timestamp, + 'items': [ + {'createdAt': nativeTimestamp}, + ], + }); + final restored = restorePublicStorageValue(encoded) as Map; + + expect(restored['lastUpdated'], isA()); + expect( + (restored['lastUpdated'] as FirestoreTimestamp).timestamp, + timestamp.timestamp, + ); + expect( + ((restored['items'] as List).single as Map)['createdAt'], + isA(), + ); + expect( + (((restored['items'] as List).single as Map)['createdAt'] + as FirestoreTimestamp) + .timestamp, + nativeTimestamp, + ); + }); + + test('public storage serializes Firestore timestamps and clears correctly', + () async { final directory = await Directory.systemTemp.createTemp('ensemble-storage-'); const channel = MethodChannel('plugins.flutter.io/path_provider'); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -19,6 +51,26 @@ void main() { await storage.write('clear_test_a', 'a'); await storage.write('clear_test_b', 'b'); await storage.write('enc_clear_test', 'secret'); + await storage.write('timestamp_test', { + 'lastUpdated': FirestoreTimestamp(Timestamp(123, 456789000)), + 'items': [ + {'createdAt': Timestamp(456, 789123000)}, + ], + }); + + final storedTimestamp = storage.read('timestamp_test')!; + expect(storedTimestamp['lastUpdated'], isA()); + expect( + (storedTimestamp['lastUpdated'] as FirestoreTimestamp) + .timestamp + .millisecondsSinceEpoch, + 123456, + ); + final storedItems = storedTimestamp['items'] as List; + expect( + (storedItems.single as Map)['createdAt'], + isA(), + ); await storage.clearPublicStorage(); diff --git a/modules/ensemble/test/widget/tab_bar_index_test.dart b/modules/ensemble/test/widget/tab_bar_index_test.dart index 69fd29178..25679b761 100644 --- a/modules/ensemble/test/widget/tab_bar_index_test.dart +++ b/modules/ensemble/test/widget/tab_bar_index_test.dart @@ -1,5 +1,6 @@ import 'package:ensemble/layout/tab/tab_bar_controller.dart'; import 'package:ensemble/layout/tab_bar.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:yaml/yaml.dart'; @@ -23,6 +24,37 @@ YamlList twoTabItems() { } void main() { + testWidgets('TabBar item testId identifies and taps the tab', (tester) async { + final tabBar = TabBarContainer(); + tabBar.setProperty( + 'items', + YamlList.wrap([ + { + 'label': 'One', + 'body': { + 'Text': {'text': 'Tab One'}, + }, + }, + { + 'label': 'Two', + 'testId': 'secondTab', + 'body': { + 'Text': {'text': 'Tab Two'}, + }, + }, + ])); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(tabBar)); + await tester.pumpAndSettle(); + + final tabFinder = find.byKey(const ValueKey('secondTab')); + expect(tabFinder, findsOneWidget); + await tester.tap(tabFinder); + await tester.pumpAndSettle(); + expect(find.text('Tab Two'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('useIndexedTab renders the selected tab body', (tester) async { final tabBar = TabBarContainer(); tabBar.setProperty('items', twoTabItems()); @@ -39,21 +71,23 @@ void main() { testWidgets('resets selectedIndex when visible items shrink below it', (tester) async { final tabBar = TabBarContainer(); - tabBar.setProperty('items', YamlList.wrap([ - { - 'label': 'Always', - 'body': { - 'Text': {'text': 'Always visible'}, - }, - }, - { - 'label': 'Hidden', - 'visible': false, - 'body': { - 'Text': {'text': 'Hidden tab'}, - }, - }, - ])); + tabBar.setProperty( + 'items', + YamlList.wrap([ + { + 'label': 'Always', + 'body': { + 'Text': {'text': 'Always visible'}, + }, + }, + { + 'label': 'Hidden', + 'visible': false, + 'body': { + 'Text': {'text': 'Hidden tab'}, + }, + }, + ])); tabBar.setProperty('selectedIndex', 1); await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(tabBar)); diff --git a/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json b/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json index cff2fb05c..f3517e46a 100644 --- a/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json +++ b/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json @@ -173,7 +173,18 @@ "minLength": 1 }, "text": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + ] }, "label": { "type": "string" @@ -1387,7 +1398,18 @@ "type": "object", "properties": { "name": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + ] }, "times": { "type": "integer" @@ -1401,7 +1423,7 @@ ], "additionalProperties": false, "title": "waitForApi", - "description": "Wait until N calls to the named API complete", + "description": "Wait until N calls to the named API complete successfully", "examples": [ { "name": "login", @@ -3952,7 +3974,7 @@ { "type": "object", "title": "waitForApi", - "description": "Wait until N calls to the named API complete", + "description": "Wait until N calls to the named API complete successfully", "examples": [ { "waitForApi": { @@ -3968,7 +3990,7 @@ "properties": { "waitForApi": { "$ref": "#/$defs/args_waitForApi", - "description": "Wait until N calls to the named API complete", + "description": "Wait until N calls to the named API complete successfully", "examples": [ { "name": "login", From 2b3483ce8fa6153e22823fa063fcdd88dba242df Mon Sep 17 00:00:00 2001 From: Sharjeel Yunus Date: Thu, 22 Oct 2026 03:40:10 +0500 Subject: [PATCH 2/3] revert schema --- .../assets/schema/ensemble_tests_schema.json | 32 +++---------------- 1 file changed, 5 insertions(+), 27 deletions(-) diff --git a/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json b/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json index f3517e46a..cff2fb05c 100644 --- a/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json +++ b/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json @@ -173,18 +173,7 @@ "minLength": 1 }, "text": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - } - ] + "type": "string" }, "label": { "type": "string" @@ -1398,18 +1387,7 @@ "type": "object", "properties": { "name": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - } - ] + "type": "string" }, "times": { "type": "integer" @@ -1423,7 +1401,7 @@ ], "additionalProperties": false, "title": "waitForApi", - "description": "Wait until N calls to the named API complete successfully", + "description": "Wait until N calls to the named API complete", "examples": [ { "name": "login", @@ -3974,7 +3952,7 @@ { "type": "object", "title": "waitForApi", - "description": "Wait until N calls to the named API complete successfully", + "description": "Wait until N calls to the named API complete", "examples": [ { "waitForApi": { @@ -3990,7 +3968,7 @@ "properties": { "waitForApi": { "$ref": "#/$defs/args_waitForApi", - "description": "Wait until N calls to the named API complete successfully", + "description": "Wait until N calls to the named API complete", "examples": [ { "name": "login", From 3b6ab68e64280e0d10d689e33fb33b326ade9ae8 Mon Sep 17 00:00:00 2001 From: Sharjeel Yunus Date: Fri, 23 Oct 2026 17:43:18 +0500 Subject: [PATCH 3/3] feat(storage): enhance Firestore timestamp handling and add tests - Improved the encoding and decoding of Firestore timestamps in public storage to preserve mutable container identity. - Updated `encodePublicStorageValue` and `restorePublicStorageValue` functions to ensure containers without timestamps retain their original references. - Added a `toJson` method to `FirestoreTimestamp` for better serialization. - Introduced tests to validate the preservation of mutable containers and correct timestamp restoration behavior. --- .../firestore/firestore_types.dart | 12 +++- .../lib/framework/storage_manager.dart | 59 +++++++++++++------ .../test/local_environment_loading_test.dart | 50 ++++++++++++++++ .../ensemble/test/storage_manager_test.dart | 41 +++++++++++++ 4 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 modules/ensemble/test/local_environment_loading_test.dart diff --git a/modules/ensemble/lib/framework/apiproviders/firestore/firestore_types.dart b/modules/ensemble/lib/framework/apiproviders/firestore/firestore_types.dart index ecb09bb25..3ba68b78c 100644 --- a/modules/ensemble/lib/framework/apiproviders/firestore/firestore_types.dart +++ b/modules/ensemble/lib/framework/apiproviders/firestore/firestore_types.dart @@ -209,6 +209,16 @@ class FirestoreTimestamp FirestoreTimestamp.fromDate(Date date) : timestamp = Timestamp.fromDate(date.dateTime); + /// Lets JSON-backed storage serialize timestamps even when an app adds one + /// through a nested mutable storage reference (which does not call the + /// top-level storage setter). StorageManager recognizes this tagged shape + /// and restores the FirestoreTimestamp on reads. + Map toJson() => { + '__ensemble_storage_value_type__': 'firestore_timestamp_v1', + 'seconds': timestamp.seconds, + 'nanoseconds': timestamp.nanoseconds, + }; + @override Map getters() { return { @@ -311,4 +321,4 @@ class EnsembleFieldValue with Invokable { }); return data; } -} \ No newline at end of file +} diff --git a/modules/ensemble/lib/framework/storage_manager.dart b/modules/ensemble/lib/framework/storage_manager.dart index 97f1890ab..1fdee0a76 100644 --- a/modules/ensemble/lib/framework/storage_manager.dart +++ b/modules/ensemble/lib/framework/storage_manager.dart @@ -97,8 +97,9 @@ const String _storageValueTypeKey = '__ensemble_storage_value_type__'; const String _firestoreTimestampStorageType = 'firestore_timestamp_v1'; /// Encodes Firestore timestamps as tagged JSON maps so persistent storage can -/// serialize them without losing their type. Maps and lists are copied -/// recursively because Firestore values are commonly nested in cached data. +/// serialize them without losing their type. Containers without timestamps +/// keep their identity so existing mutable public-storage behavior is +/// preserved; only containers on paths containing a timestamp are copied. dynamic encodePublicStorageValue(dynamic value) { if (value is FirestoreTimestamp) { return _encodeFirestoreTimestamp(value.timestamp); @@ -107,21 +108,34 @@ dynamic encodePublicStorageValue(dynamic value) { return _encodeFirestoreTimestamp(value); } if (value is Map) { - return value.map( - (key, nestedValue) => MapEntry( - key, - encodePublicStorageValue(nestedValue), - ), - ); + Map? encoded; + for (final entry in value.entries) { + final next = encodePublicStorageValue(entry.value); + if (!identical(next, entry.value)) { + encoded ??= Map.from(value); + encoded[entry.key] = next; + } + } + return encoded ?? value; } if (value is List) { - return value.map(encodePublicStorageValue).toList(); + List? encoded; + for (var i = 0; i < value.length; i++) { + final next = encodePublicStorageValue(value[i]); + if (!identical(next, value[i])) { + encoded ??= List.from(value); + encoded[i] = next; + } + } + return encoded ?? value; } return value; } /// Restores tagged Firestore timestamps whenever data is read from public -/// storage, including timestamps nested in maps and lists. +/// storage, including timestamps nested in maps and lists. Containers that do +/// not contain encoded timestamps retain their identity to preserve the +/// existing mutable public-storage behavior. dynamic restorePublicStorageValue(dynamic value) { if (value is Map) { if (value.length == 3 && @@ -132,15 +146,26 @@ dynamic restorePublicStorageValue(dynamic value) { Timestamp(value['seconds'] as int, value['nanoseconds'] as int), ); } - return value.map( - (key, nestedValue) => MapEntry( - key, - restorePublicStorageValue(nestedValue), - ), - ); + Map? restored; + for (final entry in value.entries) { + final next = restorePublicStorageValue(entry.value); + if (!identical(next, entry.value)) { + restored ??= Map.from(value); + restored[entry.key] = next; + } + } + return restored ?? value; } if (value is List) { - return value.map(restorePublicStorageValue).toList(); + List? restored; + for (var i = 0; i < value.length; i++) { + final next = restorePublicStorageValue(value[i]); + if (!identical(next, value[i])) { + restored ??= List.from(value); + restored[i] = next; + } + } + return restored ?? value; } return value; } diff --git a/modules/ensemble/test/local_environment_loading_test.dart b/modules/ensemble/test/local_environment_loading_test.dart new file mode 100644 index 000000000..8402e0478 --- /dev/null +++ b/modules/ensemble/test/local_environment_loading_test.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:ensemble/ensemble.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaml/yaml.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const appPath = 'ensemble/apps/sample'; + final assets = {}; + + setUp(() { + assets.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + final key = utf8.decode(message!.buffer.asUint8List()); + final value = assets[key]; + return value == null + ? null + : ByteData.sublistView(Uint8List.fromList(utf8.encode(value))); + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', null); + }); + + test('loads appConfig.json when local path has no trailing slash', () async { + assets['$appPath/config/appConfig.json'] = json.encode({ + 'envVariables': {'assetPrefix': 'from-json'}, + }); + assets['$appPath/.env.config'] = 'apiUrl=https://from-env.invalid'; + + final config = loadYaml(''' +definitions: + from: local + local: + path: $appPath +''') as YamlMap; + + final overrides = await Ensemble().loadEnvironmentOverrides(config); + + expect(overrides['assetPrefix'], 'from-json'); + expect(overrides['apiUrl'], 'https://from-env.invalid'); + }); +} diff --git a/modules/ensemble/test/storage_manager_test.dart b/modules/ensemble/test/storage_manager_test.dart index 1071f7f1e..3793f07ad 100644 --- a/modules/ensemble/test/storage_manager_test.dart +++ b/modules/ensemble/test/storage_manager_test.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:cloud_firestore/cloud_firestore.dart'; @@ -89,6 +90,46 @@ void main() { } }); + test('public storage preserves mutable containers without timestamps', + () async { + final directory = + await Directory.systemTemp.createTemp('ensemble-storage-'); + const channel = MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => directory.path); + try { + final storage = StorageManager(); + await storage.initPublicStorage(); + final nested = {'items': []}; + await storage.write('mutable_containers', nested); + await GetStorage().queue.add(() async {}); + + final readValue = storage.read('mutable_containers')!; + expect(identical(readValue, nested), isTrue); + expect(identical(readValue['items'], nested['items']), isTrue); + (readValue['items'] as List) + ..add('mutated') + ..add(FirestoreTimestamp(Timestamp(789, 123456000))); + // This is the same JSON operation GetStorage uses while flushing. The + // timestamp was inserted through a nested mutable reference, so it did + // not pass through StorageManager.write. + final persistedJson = jsonEncode( + GetStorage().read('mutable_containers'), + ); + expect(persistedJson, contains('firestore_timestamp_v1')); + await GetStorage().queue.add(() async {}); + final restored = + restorePublicStorageValue(jsonDecode(persistedJson)) as Map; + expect((restored['items'] as List).first, 'mutated'); + expect((restored['items'] as List).last, isA()); + } finally { + await GetStorage().queue.add(() async {}); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + await directory.delete(recursive: true); + } + }); + test('clear logic filters out encrypted keys correctly', () { final storage = { 'name': 'Alice',