Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 10 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ starter/ios/Runner.xcodeproj/project.pbxproj
starter/node_modules
starter/package-lock.json

# IntelliJ / Android Studio module files (generated by Melos / IDE)
*.iml

# FVM Version Cache
.fvm/

Expand All @@ -30,4 +33,10 @@ starter/package-lock.json
# Starter app generated files
starter/android/app/src/main/java/io/
starter/ios/Runner/GeneratedPluginRegistrant.h
starter/ios/Runner/GeneratedPluginRegistrant.m
starter/ios/Runner/GeneratedPluginRegistrant.m

# GetStorage local persistence (runtime-generated when running starter)
starter/GetStorage.gs
starter/GetStorage.bak
starter/system.gs
starter/system.bak
15 changes: 15 additions & 0 deletions melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ ignore:
- modules/ensemble_ts_interpreter
- "**/build/**"

scripts:
ensemble:test:
description: Run declarative Ensemble YAML tests in starter.
run: cd starter && dart run ensemble_test_runner:ensemble_test

ensemble:generate-test-schema:
description: Regenerate ensemble_test.schema.json from the step vocabulary.
run: |
cd packages/ensemble_test_runner && dart run tool/generate_schema.dart

ensemble:generate-step-registry:
description: Regenerate test_step_registry.dart from tool/generate_step_registry.dart.
run: |
cd packages/ensemble_test_runner && dart run tool/generate_step_registry.dart

command:
version:
updateGitTagRefs: true
5 changes: 5 additions & 0 deletions modules/ensemble/lib/ensemble.dart
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ class Ensemble extends WithEnsemble with EnsembleRouteObserver {
late FirebaseApp ensembleFirebaseApp;
static final Map<String, dynamic> externalDataContext = {};

/// Clears [initManagers] singleton state between Flutter widget tests.
static void resetInitManagersForTest() {
_instance._completer = null;
}

/// initialize all the singleton/managers. Note that this function can be
/// called multiple times since it's being called inside a widget.
/// The actual code block to initialize the managers is guaranteed to run
Expand Down
4 changes: 3 additions & 1 deletion modules/ensemble/lib/ensemble_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ class EnsembleAppState extends State<EnsembleApp> with WidgetsBindingObserver {
bool _hasInternet = true;
late final StreamSubscription<List<ConnectivityResult>>
_connectivitySubscription;
SemanticsHandle? _testSemanticsHandle;

@override
void initState() {
Expand Down Expand Up @@ -194,7 +195,7 @@ class EnsembleAppState extends State<EnsembleApp> with WidgetsBindingObserver {
}
});
if (EnvConfig().isTestMode) {
SemanticsBinding.instance.ensureSemantics();
_testSemanticsHandle = SemanticsBinding.instance.ensureSemantics();
}
}

Expand Down Expand Up @@ -244,6 +245,7 @@ class EnsembleAppState extends State<EnsembleApp> with WidgetsBindingObserver {
@override
void dispose() {
_connectivitySubscription.cancel();
_testSemanticsHandle?.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Expand Down
100 changes: 100 additions & 0 deletions packages/ensemble_test_runner/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# ensemble_test_runner

Standalone declarative YAML test runner for Ensemble apps. Wraps the **real** Ensemble runtime (`EnsembleApp`), injects mocks via runtime override hooks, and asserts on rendered UI, navigation, APIs, and storage.

This package is **not** a dependency of `modules/ensemble`. It is a **dev-only** dependency — not shipped in release builds.

## Write tests (YAML only)

Add `*.test.yaml` files under `ensemble/tests/` in your app:

```yaml
id: hello_home_renders
startScreen: Hello Home
initialState:
storage:
helloApp:
name:
first: John
last: Doe
steps:
- expectVisible:
id: greeting_text
```

Each `*.test.yaml` file is **one** test — `id`, `steps`, and **either** `startScreen` **or** `prerequisite` are at the root (no `tests:` array). A test with `prerequisite: <other_test_id>` runs after that test on the **same** app session, applying only `initialState`/`mocks` in-place before executing its steps.

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)**.

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

### JSON Schema (editor validation)

A JSON Schema for `*.test.yaml` lives at [`assets/schema/ensemble_test.schema.json`](assets/schema/ensemble_test.schema.json). It is generated from the step registry:

```bash
cd packages/ensemble_test_runner && dart run tool/generate_schema.dart
```

Or per file at the top of a test:

```yaml
# yaml-language-server: $schema=...
```

## App setup

1. Add `*.test.yaml` files under `ensemble/tests/`.
2. Configure `definitions.local` in `ensemble/ensemble-config.yaml` (`path`, `appHome`, `i18n.path`).
3. Add `ensemble_test_runner` to `dev_dependencies` (same git `url`/`ref` as your `ensemble:` dependency).
4. Run `flutter pub get`.

## Run

From your app directory (e.g. `starter/`):

```bash
dart run ensemble_test_runner:ensemble_test
```

The CLI temporarily bundles `ensemble/tests/` as an asset (if needed), writes `test/ensemble_tests.dart`, runs `flutter test`, then restores your `pubspec.yaml` and removes the generated test file.

By default, output is quiet: no `pub get` package list, no Flutter test progress lines — `SCREEN TRACKER` navigation logs plus the boxed suite report. Use `--verbose` for full subprocess output (useful when debugging).

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

On success the console prints one consolidated boxed report for the suite: each test id (with YAML path), timing, **start screen** or **prerequisite**, **navigation flow**, and a numbered **step outline**.

## Package layout

```
lib/
entry/ Flutter test entry (`runEnsembleYamlTests`)
cli/ `dart run ensemble_test_runner:ensemble_test` subprocess runner
runner/ Runtime boot, orchestration, session state
actions/ Step execution
assertions/ expect* handlers
discovery/ Find and plan `*.test.yaml` files
parser/ YAML → models
reporters/ Console report formatting
vocabulary/ Step registry + JSON Schema shapes
models/ Shared data types
mocks/ Mock HTTP provider + test logger
bin/ensemble_test.dart CLI executable
tool/ Schema/registry generators
```

## Runtime hooks (in `ensemble` core)

The runner uses small, optional hooks in the core module — not a package dependency:

- Test harness applies `EnsembleTestSetup` (storage seeds, env overrides) before `EnsembleApp` mounts
- Test harness installs `MockAPIProvider` on `EnsembleConfig.apiProviders['http']`
- Test mode via `--dart-define=testmode=true` (added automatically by the CLI)
- Navigation flow for `expectVisited` is recorded in the test runner via `ScreenTracker.onScreenChange`

`EnsembleTestHarness` runs storage init inside `tester.runAsync()` so `GetStorage` can finish under the widget test binding.
87 changes: 87 additions & 0 deletions packages/ensemble_test_runner/STEP_VOCABULARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Ensemble declarative test step vocabulary

Official step catalog for `ensemble/tests/*.test.yaml`. Every step in [`TestStepRegistry.entries`](lib/vocabulary/test_step_registry.dart) is **implemented** (tier `core` or `extended`).

| Tier | Meaning |
|------|---------|
| **core** | Primary step name |
| **extended** | Alias or secondary API (`wait` → `pump`, `expectScreen` → `expectNavigateTo`) |

## Quick reference

### Lifecycle
`openScreen`, `reloadScreen`, `restartApp`, `resetAppState`, `trigger`, `launchApp`

### Interactions
`tap`, `doubleTap`, `longPress`, `enterText`, `clearText`, `replaceText`, `submitText`, `focus`, `unfocus`

### Form controls
`select`, `selectIndex`, `check`, `uncheck`, `toggle`, `setSlider`, `chooseDate`, `chooseTime`

### Gestures
`scroll`, `scrollUntilVisible`, `swipe`, `drag`, `pullToRefresh`

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

### UI assertions
`expectVisible`, `expectNotVisible`, `expectExists`, `expectNotExists`, `expectText`, `expectNoText`, `expectTextContains`, `expectEnabled`, `expectDisabled`

### Value / list
`expectValue`, `expectChecked`, `expectSelected`, `expectProperty`, `expectStyle`, `expectCount`, `expectListCount`, `expectListContains`, `expectListItem`, `expectEmpty`, `expectNotEmpty`

### 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`

### State / storage / runtime
`setState`, `expectState`, `expectStateContains`, `expectStateExists`, `expectStateNotExists`, `resetState`, `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`

### Control flow
`group`, `repeat`, `optional`, `ifVisible`

## Example

```yaml
id: login_flow
startScreen: Login
steps:
- mockApi:
name: login
response:
statusCode: 200
body:
token: test
- enterText:
id: email_field
value: user@test.com
- tap:
id: login_button
- waitForNavigation:
screen: Home
- expectVisible:
id: welcome_text
```

## JSON Schema

Editor validation: [`assets/schema/ensemble_test.schema.json`](assets/schema/ensemble_test.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).

Each `*.test.yaml` file is a single test case and must provide **exactly one** of:

- `startScreen` — cold-starts the app on the given screen and runs steps
- `prerequisite` — ID of another test that must run first; the runner reuses the same app session, applies `initialState`/`mocks` in-place, and then runs this test's steps only

When multiple tests declare `prerequisite` chains, the runner discovers all YAML files, builds a dependency graph by `id`/`prerequisite`, and executes tests once each in topological order.

## Adding a step

1. Add one row to [`tool/generate_step_registry.dart`](tool/generate_step_registry.dart) (`desc` + optional `example` override; defaults come from `defaultExampleForArg`) and run `dart run tool/generate_step_registry.dart`.
2. If needed, add a variant to [`TestStepArgKind`](lib/vocabulary/test_step_arg_kind.dart) and its `jsonSchema` switch.
3. Implement in [`TestStepExecutor`](lib/actions/test_step_executor.dart) and/or [`ExtendedStepHandlers`](lib/actions/extended_step_handlers.dart).
4. Run `dart run tool/generate_schema.dart` and document here.
Loading
Loading