Skip to content
Merged
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
17 changes: 16 additions & 1 deletion integration_test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,29 @@ patrol test --target integration_test/app_e2e_test.dart

Do not rely on translated button labels for critical steps. Use `ValueKey`s from [`lib/core/constants/ui_keys.dart`](../lib/core/constants/ui_keys.dart) (e.g. `e2e_login_submit`, `e2e_home_content`).

`app_e2e_test.dart` covers **auth β†’ home** and nothing else, in the full starter as well as after a strip. It asserts only `e2e_login_submit` and `e2e_home_content`.
Each file here holds **two** tests:

1. **A smoke test that always runs** β€” the app boots to a usable login screen. No backend needed. It is not a token test: reaching a rendered login screen exercises the native Patrol harness, app bootstrap, the Riverpod scope, the router, and localization.
2. **The authenticated flow, skipped by default** β€” everything past login. The sample auth flow POSTs to `BASE_URL`; with no server the login call fails, the app stays on the login screen, and those assertions are unreachable. Rather than ship a suite that is red by construction, they are gated:

```bash
# skipped (default) β€” the suite is green on a fresh clone
patrol test --target integration_test/app_e2e_test.dart

# run the authenticated flow, once you have an API reachable from the device
patrol test --target integration_test/app_e2e_test.dart --dart-define=E2E_BACKEND=true
```

A skipped test still appears in the summary (`⏩ Skipped: 1`), so it cannot be quietly forgotten.

**There is no tasks coverage, on purpose.** `UiKeys.openTasks`, `UiKeys.tasksFab`, and `UiKeys.addTaskSubmit` are declared but attached to no widget in `lib/`: `HomeScreen` is a deliberately minimal shell with no entry point into the sample `tasks` feature. Patrol matches on the widget tree, so a selector written against an unattached key finds nothing β€” attach the key first if your fork adds that entry point. `tool/golden/no_feature_flags/` shows the wiring, and its own `app_e2e_test.dart` does drive the full tasks flow.

**After** `dart run tool/strip_sample_features.dart --apply`, golden files replace these E2E files outright, so the post-strip variant is whatever `tool/golden/<variant>/integration_test/` contains β€” editing the copies here does not affect it.

## CI

The workflow fails the job when the summary reports `Total: 0` or a non-zero `Failed:` count. Both were real false-green paths: `patrol test` exits 0 on an empty test set, and piping its output without `pipefail` swallowed a genuine failure. A green run here is now meaningful.

Patrol does **not** run on every PR by default. Use **GitHub Actions β†’ E2E Android (Patrol) β†’ Run workflow** (see [`.github/workflows/e2e-android.yml`](../.github/workflows/e2e-android.yml)). You may need a reachable API if login hits the network.

## More context
Expand Down
69 changes: 55 additions & 14 deletions integration_test/app_e2e_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,61 @@ import 'package:flutter_starter/main.dart' as app;
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';

/// Whether a reachable backend is configured for this run.
///
/// Pass `--dart-define=E2E_BACKEND=true` when you have an API. The sample auth
/// flow POSTs to `BASE_URL`; with no server the login call fails, the app stays
/// on the login screen, and every assertion past login is unreachable. Those
/// assertions are skipped rather than shipped permanently red - a starter whose
/// E2E suite is red by construction teaches people to ignore it.
const hasBackend = bool.fromEnvironment('E2E_BACKEND');

/// Boots the app and waits for the first frame.
///
/// Deliberately does NOT use `pumpAndSettle`. On a real device it times out
/// whenever the tree never reaches an idle frame, which is what happened here:
/// `pumpAndSettle timed out` after 131s with the app running fine. Patrol's
/// `waitUntilVisible` polls instead of demanding quiescence, so it tolerates
/// any ongoing platform or animation activity.
Future<void> _boot(PatrolIntegrationTester $) async {
await app.main();
await $.pump(const Duration(seconds: 1));
}

/// Enters the sample credentials and submits the login form.
Future<void> _submitLogin(PatrolIntegrationTester $) async {
await $(TextField).at(0).enterText('test@example.com');
await $(TextField).at(1).enterText('password123');
await $(#e2e_login_submit).tap();
await $(#e2e_home_content).waitUntilVisible();
}

void main() {
patrolTest('E2E: auth -> home (stable ValueKeys)', (
$,
) async {
app.main();
await $.pumpAndSettle();

if ($(#e2e_login_submit).exists) {
await $(TextField).at(0).enterText('test@example.com');
await $(TextField).at(1).enterText('password123');
await $(#e2e_login_submit).tap();
await $.pumpAndSettle();
}

expect($(#e2e_home_content), findsOneWidget);
// Runs everywhere, including with no backend. Not a token test: reaching a
// rendered login screen exercises the native Patrol harness, app bootstrap,
// the Riverpod scope, the router, and localization.
patrolTest('E2E: app boots to a usable login screen', ($) async {
await _boot($);

await $(#e2e_login_submit).waitUntilVisible();
expect($(#e2e_login_submit), findsOneWidget);

await $(TextField).at(0).enterText('test@example.com');
await $(TextField).at(1).enterText('password123');
expect($('test@example.com'), findsOneWidget);
});

patrolTest(
'E2E: auth -> home (requires a reachable backend)',
($) async {
await _boot($);

if ($(#e2e_login_submit).exists) {
await _submitLogin($);
}

expect($(#e2e_home_content), findsOneWidget);
},
skip: !hasBackend,
);
}
64 changes: 52 additions & 12 deletions integration_test/auth_flow_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,61 @@ import 'package:flutter_starter/main.dart' as app;
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';

void main() {
patrolTest('auth flow: enters credentials and reaches home', ($) async {
app.main();
await $.pumpAndSettle();
/// Whether a reachable backend is configured for this run.
///
/// Pass `--dart-define=E2E_BACKEND=true` when you have an API. The sample auth
/// flow POSTs to `BASE_URL`; with no server the login call fails, the app stays
/// on the login screen, and every assertion past login is unreachable. Those
/// assertions are skipped rather than shipped permanently red - a starter whose
/// E2E suite is red by construction teaches people to ignore it.
const hasBackend = bool.fromEnvironment('E2E_BACKEND');

/// Boots the app and waits for the first frame.
///
/// Deliberately does NOT use `pumpAndSettle`. On a real device it times out
/// whenever the tree never reaches an idle frame, which is what happened here:
/// `pumpAndSettle timed out` after 131s with the app running fine. Patrol's
/// `waitUntilVisible` polls instead of demanding quiescence, so it tolerates
/// any ongoing platform or animation activity.
Future<void> _boot(PatrolIntegrationTester $) async {
await app.main();
await $.pump(const Duration(seconds: 1));
}

if ($(#e2e_login_submit).exists) {
expect($(#e2e_login_submit), findsOneWidget);
/// Enters the sample credentials and submits the login form.
Future<void> _submitLogin(PatrolIntegrationTester $) async {
await $(TextField).at(0).enterText('test@example.com');
await $(TextField).at(1).enterText('password123');
await $(#e2e_login_submit).tap();
await $(#e2e_home_content).waitUntilVisible();
}

await $(TextField).at(0).enterText('test@example.com');
await $(TextField).at(1).enterText('password123');
void main() {
// Runs everywhere, including with no backend. Not a token test: reaching a
// rendered login screen exercises the native Patrol harness, app bootstrap,
// the Riverpod scope, the router, and localization.
patrolTest('E2E: app boots to a usable login screen', ($) async {
await _boot($);

await $(#e2e_login_submit).tap();
await $.pumpAndSettle();
}
await $(#e2e_login_submit).waitUntilVisible();
expect($(#e2e_login_submit), findsOneWidget);

expect($(#e2e_home_content), findsOneWidget);
await $(TextField).at(0).enterText('test@example.com');
await $(TextField).at(1).enterText('password123');
expect($('test@example.com'), findsOneWidget);
});

patrolTest(
'auth flow: credentials reach home (requires a reachable backend)',
($) async {
await _boot($);

if ($(#e2e_login_submit).exists) {
await _submitLogin($);
}

expect($(#e2e_home_content), findsOneWidget);
},
skip: !hasBackend,
);
}
6 changes: 5 additions & 1 deletion lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import 'package:flutter_starter/core/routing/app_router.dart';
import 'package:flutter_starter/l10n/app_localizations.dart';
import 'package:flutter_starter/shared/theme/app_theme.dart';

void main() async {
// Returns a Future so callers can await startup. `void main() async` would
// discard it: integration tests could not wait for runApp, and any error
// thrown by the awaits below would surface as an unhandled async error
// instead of propagating.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

await Future.wait([EnvConfig.load(), _initializeImageCache()]);
Expand Down
89 changes: 65 additions & 24 deletions tool/golden/no_feature_flags/integration_test/app_e2e_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,71 @@ import 'package:flutter_starter/main.dart' as app;
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';

/// Whether a reachable backend is configured for this run.
///
/// Pass `--dart-define=E2E_BACKEND=true` when you have an API. The sample auth
/// flow POSTs to `BASE_URL`; with no server the login call fails, the app stays
/// on the login screen, and every assertion past login is unreachable. Those
/// assertions are skipped rather than shipped permanently red - a starter whose
/// E2E suite is red by construction teaches people to ignore it.
const hasBackend = bool.fromEnvironment('E2E_BACKEND');

/// Boots the app and waits for the first frame.
///
/// Deliberately does NOT use `pumpAndSettle`. On a real device it times out
/// whenever the tree never reaches an idle frame, which is what happened here:
/// `pumpAndSettle timed out` after 131s with the app running fine. Patrol's
/// `waitUntilVisible` polls instead of demanding quiescence, so it tolerates
/// any ongoing platform or animation activity.
Future<void> _boot(PatrolIntegrationTester $) async {
await app.main();
await $.pump(const Duration(seconds: 1));
}

/// Enters the sample credentials and submits the login form.
Future<void> _submitLogin(PatrolIntegrationTester $) async {
await $(TextField).at(0).enterText('test@example.com');
await $(TextField).at(1).enterText('password123');
await $(#e2e_login_submit).tap();
await $(#e2e_home_content).waitUntilVisible();
}

void main() {
patrolTest('E2E: Auth -> open tasks -> create task (uses stable ValueKeys)', (
$,
) async {
app.main();
await $.pumpAndSettle();

if ($(#e2e_login_submit).exists) {
await $(TextField).at(0).enterText('test@example.com');
await $(TextField).at(1).enterText('password123');
await $(#e2e_login_submit).tap();
await $.pumpAndSettle();
}

await $(#e2e_open_tasks).tap();
await $.pumpAndSettle();

await $(#e2e_tasks_fab).tap();
await $.pumpAndSettle();

await $(TextField).first.enterText('Patrol Automated Task');
await $(#e2e_add_task_submit).tap();
await $.pumpAndSettle();

expect($('Patrol Automated Task'), findsWidgets);
// Runs everywhere, including with no backend. Not a token test: reaching a
// rendered login screen exercises the native Patrol harness, app bootstrap,
// the Riverpod scope, the router, and localization.
patrolTest('E2E: app boots to a usable login screen', ($) async {
await _boot($);

await $(#e2e_login_submit).waitUntilVisible();
expect($(#e2e_login_submit), findsOneWidget);

await $(TextField).at(0).enterText('test@example.com');
await $(TextField).at(1).enterText('password123');
expect($('test@example.com'), findsOneWidget);
});

patrolTest(
'E2E: auth -> open tasks -> create task (requires a reachable backend)',
($) async {
await _boot($);

if ($(#e2e_login_submit).exists) {
await _submitLogin($);
}

await $(#e2e_open_tasks).tap();
await $(#e2e_tasks_fab).waitUntilVisible();

await $(#e2e_tasks_fab).tap();
await $(#e2e_add_task_submit).waitUntilVisible();

await $(TextField).first.enterText('Patrol Automated Task');
await $(#e2e_add_task_submit).tap();
await $('Patrol Automated Task').waitUntilVisible();

expect($('Patrol Automated Task'), findsWidgets);
},
skip: !hasBackend,
);
}
63 changes: 57 additions & 6 deletions tool/golden/no_feature_flags/integration_test/auth_flow_test.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,63 @@
// Import main app package
import 'package:flutter/material.dart';
import 'package:flutter_starter/main.dart' as app;
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';

/// Whether a reachable backend is configured for this run.
///
/// Pass `--dart-define=E2E_BACKEND=true` when you have an API. The sample auth
/// flow POSTs to `BASE_URL`; with no server the login call fails, the app stays
/// on the login screen, and every assertion past login is unreachable. Those
/// assertions are skipped rather than shipped permanently red - a starter whose
/// E2E suite is red by construction teaches people to ignore it.
const hasBackend = bool.fromEnvironment('E2E_BACKEND');

/// Boots the app and waits for the first frame.
///
/// Deliberately does NOT use `pumpAndSettle`. On a real device it times out
/// whenever the tree never reaches an idle frame, which is what happened here:
/// `pumpAndSettle timed out` after 131s with the app running fine. Patrol's
/// `waitUntilVisible` polls instead of demanding quiescence, so it tolerates
/// any ongoing platform or animation activity.
Future<void> _boot(PatrolIntegrationTester $) async {
await app.main();
await $.pump(const Duration(seconds: 1));
}

/// Enters the sample credentials and submits the login form.
Future<void> _submitLogin(PatrolIntegrationTester $) async {
await $(TextField).at(0).enterText('test@example.com');
await $(TextField).at(1).enterText('password123');
await $(#e2e_login_submit).tap();
await $(#e2e_home_content).waitUntilVisible();
}

void main() {
patrolTest('auth flow: enters credentials and navigates to dashboard', (
$,
) async {
app.main();
await $.pumpAndSettle();
// Runs everywhere, including with no backend. Not a token test: reaching a
// rendered login screen exercises the native Patrol harness, app bootstrap,
// the Riverpod scope, the router, and localization.
patrolTest('E2E: app boots to a usable login screen', ($) async {
await _boot($);

await $(#e2e_login_submit).waitUntilVisible();
expect($(#e2e_login_submit), findsOneWidget);

await $(TextField).at(0).enterText('test@example.com');
await $(TextField).at(1).enterText('password123');
expect($('test@example.com'), findsOneWidget);
});

patrolTest(
'auth flow: credentials reach home (requires a reachable backend)',
($) async {
await _boot($);

if ($(#e2e_login_submit).exists) {
await _submitLogin($);
}

expect($(#e2e_home_content), findsOneWidget);
},
skip: !hasBackend,
);
}
6 changes: 5 additions & 1 deletion tool/golden/no_feature_flags/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import 'package:flutter_starter/core/routing/app_router.dart';
import 'package:flutter_starter/l10n/app_localizations.dart';
import 'package:flutter_starter/shared/theme/app_theme.dart';

void main() async {
// Returns a Future so callers can await startup. `void main() async` would
// discard it: integration tests could not wait for runApp, and any error
// thrown by the awaits below would surface as an unhandled async error
// instead of propagating.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();

await Future.wait([EnvConfig.load(), _initializeImageCache()]);
Expand Down
Loading
Loading