Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bde6211
feat(test_runner): support live Firebase and HTTP under flutter test
sharjeelyunus Jul 9, 2026
8d6c6ba
refactor(test_runner): clean up HTTP overrides and improve code reada…
sharjeelyunus Jul 9, 2026
927f9bb
refactor(mock_api_provider): streamline mock API response handling
sharjeelyunus Jul 9, 2026
ffb9905
refactor(api_provider): replace mock API provider with test API provi…
sharjeelyunus Jul 9, 2026
37058ed
refactor(api_tests): remove deprecated API mock steps and streamline …
sharjeelyunus Jul 9, 2026
cb4d4ad
refactor(test_runner): update logStorage handling and schema for impr…
sharjeelyunus Jul 9, 2026
049c928
refactor(test_runner): remove state-related steps and streamline test…
sharjeelyunus Jul 9, 2026
47fd487
refactor(test_runner): enhance wait step and schema definitions for c…
sharjeelyunus Jul 10, 2026
b65837d
refactor(test_runner): enhance logging and screenshot functionality
sharjeelyunus Jul 10, 2026
9e899ee
feat(test_runner): add font loading functionality to ensure app fonts…
sharjeelyunus Jul 10, 2026
9398c6b
feat(test_runner): enhance screenshot functionality with device frame…
sharjeelyunus Jul 10, 2026
7939bde
refactor(test_runner): remove deviceFrame property from schema and re…
sharjeelyunus Jul 10, 2026
6860cb8
refactor(test_runner): improve logging structure and output formats
sharjeelyunus Jul 10, 2026
56f3a13
feat(test_runner): introduce screenshot options in ensemble tests schema
sharjeelyunus Jul 10, 2026
86c0fad
feat(test_runner): add performance logging capabilities to ensemble t…
sharjeelyunus Jul 13, 2026
9021039
feat(test_runner): introduce suite-wide configuration for ensemble tests
sharjeelyunus Jul 13, 2026
b564ceb
feat(test_runner): enhance performance logging with detailed metrics …
sharjeelyunus Jul 13, 2026
b7dc4eb
feat(view_util): add support for testId in widget model construction
sharjeelyunus Jul 13, 2026
6adf5bf
feat(test_runner): add support for CLI inputs in ensemble tests
sharjeelyunus Jul 13, 2026
f7bade8
feat(test_runner): enhance mock handling and scenario support in ense…
sharjeelyunus Jul 14, 2026
416d4ce
feat(test_runner): add delayMs support for mocked APIs in ensemble tests
sharjeelyunus Jul 14, 2026
578e562
feat(test_runner): add recording and screenshot contact sheet features
sharjeelyunus Jul 14, 2026
58d2762
feat(test_runner): enhance screenshot functionality with highlighting…
sharjeelyunus Jul 14, 2026
65318a6
refactor(test_runner): update screenshot handling and layout adjustments
sharjeelyunus Jul 14, 2026
1d44c13
refactor(test_runner): remove recording feature and update schema
sharjeelyunus Jul 14, 2026
09d8be5
feat(test_runner): enhance font handling and package info resolution
sharjeelyunus Jul 14, 2026
e292dd9
feat(test_runner): enhance live output filtering and report extraction
sharjeelyunus Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class APIProviders extends InheritedWidget {
if (provider == null) {
return httpProvider;
} else if (provider == 'firebaseFunction') {
return FirebaseFunctionsAPIProvider();
return providers[provider] ?? FirebaseFunctionsAPIProvider();
} else if (provider == 'sse') {
return providers[provider] ?? SSEAPIProvider();
} else {
Expand Down
3 changes: 3 additions & 0 deletions modules/ensemble/lib/framework/widget/view_util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ class ViewUtil {
if (callerPayload?['id'] != null) {
props["id"] = callerPayload?['id'];
}
if (callerPayload?['testId'] != null) {
props["testId"] = callerPayload?['testId'];
}

Map<String, dynamic> inputPayload = {};
if (callerPayload?['inputs'] is Map) {
Expand Down
6 changes: 5 additions & 1 deletion modules/ensemble/lib/layout/ensemble_page_route.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,12 @@ extension PageTransitionTypeX on PageTransitionType {
/// Page route builder that presents a screen without transition animation.
class EnsemblePageRouteNoTransitionBuilder extends PageRouteBuilder {
/// Creates a [EnsemblePageRouteNoTransitionBuilder] object.
EnsemblePageRouteNoTransitionBuilder({required Widget screenWidget})
EnsemblePageRouteNoTransitionBuilder({
required Widget screenWidget,
RouteSettings? settings,
})
: super(
settings: settings,
pageBuilder: (context, animation, secondaryAnimation) => screenWidget,
);
}
Expand Down
25 changes: 21 additions & 4 deletions modules/ensemble/lib/screen_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ class ScreenController {
(isRepeat ? repeatInterval! : 0);

// we always execute at least once, delayed by startAfter and fallback to repeatInterval (or immediate if startAfter is 0)
Timer(Duration(seconds: delay), () {
final timer = Timer(Duration(seconds: delay), () {
// execute the action
executeActionWithScope(context, scopeManager, action.onTimer);

Expand All @@ -298,7 +298,7 @@ class ScreenController {
int? repeatCount = maxTimes != null ? maxTimes - 1 : null;
if (repeatCount != 0) {
int counter = 0;
final timer =
final periodicTimer =
Timer.periodic(Duration(seconds: repeatInterval), (timer) {
// execute the action
executeActionWithScope(context, scopeManager, action.onTimer);
Expand All @@ -317,10 +317,11 @@ class ScreenController {

// save our timer to our PageData since user may want to cancel at anytime
// and also when we navigate away from the page
scopeManager.addTimer(action, timer);
scopeManager.addTimer(action, periodicTimer);
}
}
});
scopeManager.addTimer(action, timer);
} else if (action is StopTimerAction) {
try {
scopeManager.removeTimer(action.id);
Expand Down Expand Up @@ -584,12 +585,24 @@ class ScreenController {
defaultTransitionOptions[_pageType]?['duration'],
fallback: 250);

final routeSettings = RouteSettings(
name: screenName ?? screenId,
arguments: ScreenPayload(
screenId: screenId,
screenName: screenName,
pageType: pageType,
arguments: pageArgs,
isExternal: isExternal,
),
);

PageRouteBuilder route = getScreenBuilder(
screenWidget,
pageType: pageType,
transitionType: transitionType,
alignment: alignment,
duration: duration,
settings: routeSettings,
);
// push the new route and remove all existing screens. This is suitable for logging out.
if (routeOption == RouteOption.clearAllScreens) {
Expand Down Expand Up @@ -651,12 +664,14 @@ class ScreenController {
PageTransitionType? transitionType,
Alignment? alignment,
int? duration,
RouteSettings? settings,
}) {
const enableTransition =
bool.fromEnvironment('transitions', defaultValue: true);

if (!enableTransition) {
return EnsemblePageRouteNoTransitionBuilder(screenWidget: screenWidget);
return EnsemblePageRouteNoTransitionBuilder(
screenWidget: screenWidget, settings: settings);
}

if (pageType == PageType.modal) {
Expand All @@ -669,13 +684,15 @@ class ScreenController {
duration: Duration(milliseconds: duration ?? 250),
barrierDismissible: true,
barrierColor: Colors.black54,
settings: settings,
);
} else {
return EnsemblePageRouteBuilder(
child: screenWidget,
transitionType: transitionType ?? PageTransitionType.fade,
alignment: alignment ?? Alignment.center,
duration: Duration(milliseconds: duration ?? 250),
settings: settings,
);
}
}
Expand Down
27 changes: 27 additions & 0 deletions modules/ensemble/test/custom_widget_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:ensemble/framework/widget/view_util.dart';
import 'package:ensemble/widget/custom_widget/custom_widget_model.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yaml/yaml.dart';

void main() {
test('custom widget call preserves explicit testId without requiring id', () {
final customWidgets = {
'MiniCard': loadYaml('''
body:
Text:
text: Devices
''') as YamlMap,
};

final model = ViewUtil.buildModel(
loadYaml('''
MiniCard:
testId: devices_mini_card
'''),
customWidgets,
) as CustomWidgetModel;

expect(model.props['testId'], 'devices_mini_card');
expect(model.props.containsKey('id'), isFalse);
});
}
52 changes: 44 additions & 8 deletions tools/ensemble_test_runner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Widget YAML must set `testId` (or `id`, which maps to the same `ValueKey`).

### Step vocabulary

The full official catalog (lifecycle, gestures, API mocks, fixtures, debug, etc.) is in **[STEP_VOCABULARY.md](STEP_VOCABULARY.md)**.
The full official catalog (lifecycle, gestures, API assertions, debug, etc.) is in **[STEP_VOCABULARY.md](STEP_VOCABULARY.md)**.

Machine-readable registry (single source): `lib/vocabulary/test_step_registry.dart`.

Expand All @@ -47,6 +47,28 @@ Or per file at the top of a test:
# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json
```

Suite-wide runner config lives in `tests/config.yaml`. The schema is hosted at
`https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json`:

```yaml
# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json
screenshots:
enabled: true
platform: ios
model: iPhone 15 Pro
includeSteps: []
excludeSteps: []

performance:
enabled: true
dumpTree:
enabled: true
logApiCalls:
enabled: true
logStorage:
enabled: true
```

## App setup

1. Add `*.test.yaml` files under `definitions.local.path/tests/`, for example
Expand Down Expand Up @@ -75,6 +97,24 @@ By default, output is quiet: no `pub get` package list, no Flutter test progress

Optional: `--app-dir=<path>` when not running from the app root.

Pass test-runner inputs with repeatable `--input key=value` flags. Tests can
reference them as `${inputs.key}` in `initialState`, mocks, and steps:

```bash
dart run ensemble_test_runner:ensemble_test \
--input adminPassword='s4C>M7U6t~' \
--input expectedDeviceCount=2
```

```yaml
initialState:
keychain:
adminPassword: ${inputs.adminPassword}
steps:
- expectText:
text: ${inputs.expectedDeviceCount}
```

The default suite timeout is 10 minutes. Override it when a flow should fail
faster or when a long chain needs more time:

Expand Down Expand Up @@ -117,7 +157,7 @@ Create a starter test under `definitions.local.path/tests/`:
dart run ensemble_test_runner:ensemble_test --scaffold-test=login_valid --feature=login --tag=smoke --screen=Login
```

See [`docs/TEST_AUTHORING.md`](docs/TEST_AUTHORING.md) for the test authoring workflow, fixture conventions, validation rules, and repair-loop output.
See [`docs/TEST_AUTHORING.md`](docs/TEST_AUTHORING.md) for the test authoring workflow, mock file conventions, validation rules, and repair-loop output.

### CI output

Expand Down Expand Up @@ -157,13 +197,9 @@ On success the console prints one consolidated boxed report for the suite: each
# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json
id: login_flow
startScreen: Login
mocks:
- mocks/login_success.mock.json
steps:
- mockApi:
name: login
response:
statusCode: 200
body:
token: test-token
- enterText:
id: email_field
value: user@test.com
Expand Down
41 changes: 28 additions & 13 deletions tools/ensemble_test_runner/STEP_VOCABULARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Official step catalog for app-local `tests/*.test.yaml` files, for example `ense
`scroll`, `scrollUntilVisible`, `swipe`, `drag`, `pullToRefresh`

### Wait / sync
`wait` (alias `pump`), `pump`, `settle`, `waitFor`, `waitForText`, `waitForGone`, `waitForApi`, `waitForNavigation`, `waitUntil`
`wait` (alias `pump`), `pump`, `settle`, `waitFor`, `waitForText`, `waitForGone`, `waitForApi`, `waitForNavigation`

### UI assertions
`expectVisible`, `expectNotVisible`, `expectExists`, `expectNotExists`, `expectText`, `expectNoText`, `expectTextContains`, `expectEnabled`, `expectDisabled`
Expand All @@ -33,14 +33,14 @@ Official step catalog for app-local `tests/*.test.yaml` files, for example `ense
### Navigation
`expectScreen` (alias), `expectNavigateTo`, `expectVisited`, `expectNotVisited`, `expectBackStack`, `expectCanGoBack`, `goBack`

### API mock / assert
`mockApi`, `mockApiError`, `mockApiFromFixture`, `mockApiException`, `mockTimeout`, `mockNetworkOffline`, `mockNetworkOnline`, `resetApiCalls`, `clearApiMocks`, `expectApiCalled`, `expectApiNotCalled`, `expectApiRequest`, `expectApiRequestContains`, `expectApiHeader`, `expectApiCallOrder`, `expectLastApiCall`, `logApiCalls`
### API assert / logs
`resetApiCalls`, `expectApiCalled`, `expectApiNotCalled`, `expectApiCallOrder`, `expectLastApiCall`, `logApiCalls`

### State / storage / runtime
`setState`, `expectState`, `expectStateContains`, `expectStateExists`, `expectStateNotExists`, `resetState`, `setStorage`, `expectStorage`, `removeStorage`, `clearStorage`, `setEnv`, `setAuth`, `clearAuth`, `setPermission`, `setDevice`, `setLocale`, `setTheme`
### Storage / runtime
`setStorage`, `expectStorage`, `removeStorage`, `clearStorage`, `setEnv`, `setAuth`, `clearAuth`, `setPermission`, `setDevice`, `setLocale`, `setTheme`

### Scripts / fixtures / debug / quality
`runScript`, `expectScript`, `expectScriptResult`, `expectConsoleLog`, `loadFixture`, `setStateFromFixture`, `expectMatchesFixture`, `logState`, `logStorage`, `screenshot`, `dumpTree`, `expectNoConsoleErrors`, `expectNoRenderErrors`, `expectError`, `expectNoErrors`, `expectAccessible`, `expectSemanticsLabel`, `expectNoOverflow`
### Scripts / debug / quality
`runScript`, `expectScript`, `expectScriptResult`, `expectConsoleLog`, `expectNoConsoleErrors`, `expectNoRenderErrors`, `expectError`, `expectNoErrors`, `expectAccessible`, `expectSemanticsLabel`, `expectNoOverflow`

### Control flow
`group`, `repeat`, `optional`, `ifVisible`
Expand All @@ -51,12 +51,6 @@ Official step catalog for app-local `tests/*.test.yaml` files, for example `ense
id: login_flow
startScreen: Login
steps:
- mockApi:
name: login
response:
statusCode: 200
body:
token: test
- enterText:
id: email_field
value: user@test.com
Expand All @@ -72,6 +66,27 @@ steps:

Editor validation: `https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json` (committed copy at [`assets/schema/ensemble_tests_schema.json`](assets/schema/ensemble_tests_schema.json), regenerate with `dart run tool/generate_schema.dart`). Arg shapes come from [`TestStepArgKind`](lib/vocabulary/test_step_arg_kind.dart) on each [`TestStepRegistryEntry`](lib/vocabulary/test_step_registry.dart).

Performance logging writes Flutter app frame timing metrics to
`build/ensemble_test_runner/logs/app_performance.json`. Enable it once for the
full suite in `tests/config.yaml`:

```yaml
performance:
enabled: true
```

`dumpTree`, `logApiCalls`, and `logStorage` are also suite-level config
artifacts:

```yaml
dumpTree:
enabled: true
logApiCalls:
enabled: true
logStorage:
enabled: true
```

Before running a new suite, use `dart run ensemble_test_runner:ensemble_test --doctor`
from the Flutter wrapper app root to validate config, test discovery, duplicate
IDs, prerequisites, schema comments, and obvious widget IDs. CI can request JSON
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json",
"title": "Ensemble declarative test config",
"description": "Suite-wide config for app-local tests/config.yaml",
"type": "object",
"additionalProperties": false,
"properties": {
"screenshots": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"platform": {
"type": "string"
},
"model": {
"type": "string"
},
"includeSteps": {
"type": "array",
"items": {
"type": "string"
}
},
"excludeSteps": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"performance": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"dumpTree": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"logApiCalls": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
}
}
},
"logStorage": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"key": {
"type": "string"
}
}
}
}
}
Loading
Loading