Route smoke tests and Maestro flows
Two ways to prove that every route opens: fsp test writes a widget test per route and runs in flutter test; fsp maestro writes flows that drive the real app. Both share the samples: they open the routes with. The other fsp commands are in the CLI reference.
Maestro flows (fsp maestro)
Since 0.7.0. Maestro drives an app from the outside, through the platform's accessibility tree, so it cannot see a Flutter Key. It finds text, a Semantics label and a Semantics(identifier:), which its id: selector matches. fsp gives every page one, and writes a smoke flow for each route that opens the route's URL and waits for that page.
semantics_ids: true in the fespalier: section of pubspec.yaml wraps each page's own widget in
Semantics(identifier: 'route:/products/:id', container: true, explicitChildNodes: true, child: ProductPage(id: v.id))The identifier is route: and the pattern fsp routes prints (route:/, route:/products/:id, route:/docs/*rest, route:/files/*path?). It depends only on the folder path, so it is the same for every localized spelling and every mount prefix, and it does not change when you rename a class. It is in the widget tree if and only if the route's own page is built:
- The wrapper sits on the innermost call, inside
DataView,DeferredViewandSectionView, soloading.dart,error.dartandnot_found.dartdo not carry it. A flow cannot pass while a spinner or an error shows, nor while a deferred page's code is loading. - go_router builds the whole matched stack, but the pages underneath the top one are off screen and out of the semantics tree:
/products/1hasroute:/products/:idand notroute:/products. - Only a
page.dartgets one: not a layout, a shell, a redirect or a not-found view. Semanticshas noconstconstructor, so the wrapper is neverconst; a page that wasconstkeeps its ownconstinside it, and the generated code passes theconstlints.- The identifier's node is empty:
container: truegives it a node of its own, andexplicitChildNodes: true(since 0.9.1) keeps every descendant out of it, so a screen reader reads each of the page'sTextwidgets as its own node. A page that wants one announcement groups its own text withMergeSemantics. - With the key off (the default), nothing is generated for it.
On the web, this is not free. Flutter builds no semantics tree until a screen reader asks for one, so a driver that reads the page from outside finds nothing. With semantics_ids: true the generated AppRoutes.mount() (which router() calls, so an app that embeds the routes is covered too) first calls ensureWebSemantics() from package:fespalier. On the web it calls SemanticsBinding.instance.ensureSemantics() once and keeps the handle for the life of the app; anywhere else, and in every widget test, it does nothing. The tree then stays on in the web build for every user of it, which costs frame time and DOM nodes, and no key narrows it to a test build. Weigh it before you turn the key on in an app whose web build you ship.
maestro: says what the flows open. These are all the keys:
fespalier:
semantics_ids: true # required by `fsp maestro`
maestro:
app_id: com.example.shop # Android and iOS: each flow's `appId:` } exactly one
url: http://localhost:8080 # the web: each flow's `url:` } of the two
link: myshop://shop.example.com # what a route's path is appended to; default below
out: .maestro/routes # default; a folder inside the project, no `..`
guard_flow: .maestro/sign-in.yaml # optional: runs before the link of a guarded route
timeout: 20000 # default; how long a flow waits for the page, 1000 to 600000 ms
samples: # the value of each dynamic folder, inherited by the routes below it
products/$id: 2
greet/$name: Ada
docs/$$rest: [guides, intro] # a catch-all takes a list of parts (a lone value is one part)app_id,urlandlinkmay be a Maestro variable written whole, such asapp_id: ${APP_ID}; it is copied into the flow as written, andmaestro test -e APP_ID=com.example.shopfills it in.linkis what a route's path goes after:myshop://shop.example.complus/products/2. It defaults to theurlfor the web. For an app it comes fromlinks:(<scheme>://<first domain>with ascheme,<scheme>://withscheme_host: falseso a flow opensmyshop:///products/2, elsehttps://<first domain>), and with neither it is an error. A route thatlinks: paths:leaves out still gets a flow, and with a hosted link it can't open on a device. A Flutter web app on the default hash URL strategy needslink: http://localhost:8080/#, because its routes live after the#; withusePathUrlStrategy()the default is right.sampleskeys are folders asfsp routesprints them without/page.dart(products/$id,(members)/notes/$id), and each must be a$x,$$xor$$$xfolder.- A value is text, a number, a boolean or, for a catch-all, a list of them. It is percent-encoded and checked against the segment's type (
int,double,num,bool, andListof them); aString, aDateTimeor an enum is taken as written, because the generator doesn't know an enum's values. - The sample is for the folder, so every route below
products/$idopens/products/2/.... - An optional catch-all (
$$$path) with no sample is the bare path. - Quote a value that must stay text (
'1.10').
- A value is text, a number, a boolean or, for a catch-all, a list of them. It is percent-encoded and checked against the segment's type (
fsp maestro writes one flow per route into out (commit it, like app.g.dart). This is the shop example's, examples/shop/.maestro/routes/product_route.yaml:
# Written by `fsp maestro` from lib/app/products/$id/page.dart: don't edit it, run `fsp maestro` again.
url: "http://localhost:8080"
name: "/products/:id"
tags:
- "fespalier"
---
- launchApp
- openLink: "http://localhost:8080/#/products/1"
- extendedWaitUntil:
visible:
id: "route:/products/:id"
timeout: 20000launchAppstarts the app afresh, so every flow begins from the same state.openLinkopens the route's sample URL (the long form withautoVerify: truefor an Android app whose link ishttps, which skips Android's "Open with" dialog).extendedWaitUntilreturns the moment the identifier is on the screen, or fails aftertimeout. When it returns, the route's guards ran, its data loaded and its page was built.- A guarded route's flow also has
- runFlow: "../sign-in.yaml"betweenlaunchAppandopenLink(the path is relative to the flow) and a comment naming theguard.dartfiles.
Which routes get a flow. The rows are checked in this order, and the first that applies wins. Every skip is printed, on every run, and none of them fails --check.
| Route | Result | Printed |
|---|---|---|
A redirect.dart route | skipped | skipped /old: a redirect, with no page to see |
app_id, and const linkable = false; | skipped | skipped /secret: `const linkable = false;`, so `fsp links` does not open the app at it |
A $x or $$x segment with no sample | skipped | skipped /products/:id: no sample for products/$id in `fespalier.maestro.samples` |
A guard.dart at or above it, no guard_flow | skipped | skipped /checkout: guarded by checkout/guard.dart; set `fespalier.maestro.guard_flow` to a flow that gets past it |
A (group) folder's guard covers the routes in it. Samples come from the pubspec only. Layouts, not-found views, query parameters and the localized spellings get no flow: a route is opened at its canonical path.
Files and ownership. A flow is named after its typed route class in snake case: ProductRoute is product_route.yaml, ProPlanRoute is pro_plan_route.yaml, the root HomeRoute is home_route.yaml (the _route ending keeps a file from being Maestro's config.yaml).
- Every file
fsp maestrowrites starts with# Written by `fsp maestro`. Inout, a*.yamlfile with that first line isfsp's:fsp maestrodeletes it when no route needs it any more, and--checkreports it. Any other file there (a flow you wrote, aconfig.yaml) is never read or touched, so hand-written journeys live beside the generated ones. - The output is a function of the tree and the pubspec (a fixed order, no dates), so
fsp maestro --checkwrites nothing and exits non-zero when a flow is missing, out of date or no longer a route's, and names it. Run it next tofsp check: it does not check thatlib/app.g.dartis current. - The values of
maestro:are checked only byfsp maestro, so a mistake there never stopsfsp gen.
Running them. maestro test .maestro/routes. Pass the folder, not .maestro: Maestro runs only the top-level flows of the folder it is given, and skips subfolders unless a config.yaml there lists them (flows: ["routes/*"]). maestro test -e APP_ID=... -e URL=... fills in variables.
- The web. Maestro's web support is in beta. Serve the app at the
url(flutter run -d web-server --web-port 8080, or a static server forflutter build web; with the path strategy it must serveindex.htmlfor unknown paths) and run the flows.openLinknavigates the browser, which reloads a Flutter web app. Maestro 2.7.0 or later readsid:fromflt-semantics-identifier, the attribute Flutter's web engine writes forSemantics(identifier:). - Android and iOS. The app must open the link: Android needs the intent filters, iOS the associated domains or the URL scheme, which
fsp linkswrites (and, withandroid_manifest:andios_entitlements:set, puts into the platform files itself; otherwise paste them in, as it says). iOS may ask "Open in ...?" before a custom scheme opens the app; the generated flows do not answer it, so prefer anhttpslink or start the run with a flow of your own. On iOS the flows have not been verified: if one waits and times out on a page you can see, check that first. - A guard flow runs after
launchAppand beforeopenLink: write the sign-in once (.maestro/sign-in.yaml), give it asguard_flow, and every guarded route's flow runs it first. On the webopenLinkreloads the app, so the sign-in has to survive a reload (a stored token, not in-memory state), or the guard will send the flow back to the login page.
In CI. To gate a pull request without Maestro, replay the flows in a browser: Development describes the Playwright replay of the shop's flows (scripts/check-web-routes.sh <example> with ci/web-routes/ is a template for an app's own CI). To run Maestro itself, build and serve the web app, then run the flows and fsp maestro --check. Maestro's web driver follows Chrome and has broken on Chrome upgrades before, so pin the Maestro version, check the download's sha256, and do not make it the only gate.
- run: fsp maestro --check
- run: curl -fsSL "https://get.maestro.mobile.dev" | bash
- run: flutter build web --release --no-web-resources-cdn
- run: python3 -m http.server 8080 --directory build/web &
- run: maestro test --headless .maestro/routesRoute smoke tests (fsp test)
Since 0.8.1. fsp test writes a widget smoke test for every route, into one file, test/routes/routes_test.dart. Each test opens the route at a sample URL with pumpRouter, waits until its page is on screen, and expects exactly one. It proves what a Maestro flow proves (the route exists, its guards let it through, its data loaded, its page was built) in flutter test, on the VM, with no device and no browser. fsp test does not run Flutter: it writes the file, or with --check compares it, as fsp maestro does. examples/shop/test/routes/routes_test.dart is what it wrote there; this is its first test:
testWidgets(
'/products/:id at /products/1',
(tester) => smokeTestRoute(
tester,
'/products/:id',
AppRoutes.router(
initialLocation: '/products/1',
),
overrides: setup.overrides(
'/products/:id',
),
),
);What a test does. smokeTestRoute (in package:fespalier/testing.dart) runs these steps:
pumpRouter(..., settle: false), which also loads the code of every deferred route first.- It pumps 100 ms of the test's fake clock at a time until the page is on screen. A
data.dartfake that answers after a delay is waited out, and nothing waits on the real clock. - It fails after
timeout(30 s of fake time by default) withThe page of /items is not on screen after 30000 ms of fake time: the router is at /sign-in. A guard that redirects, a data.dart that fails or never completes, or an exception while building (above) keeps it away.Those are what keeps the page away (Flutter prints a build exception above); the location says where the router ended. - It expects exactly one page, takes the tree down, and runs the clock
timeouton, so a fake's pending one-shot timer fires with no widget left to react and the test does not end with "A Timer is still pending". A periodic timer in a fake still fails the test, which is the right signal.
How the page is found. With semantics_ids: true the test looks for the page's Semantics(identifier: 'route:<pattern>') with findRoutePage(pattern); it needs no semantics tree, and a page underneath another is off screen and not found. Without it, a class page is found by its type (find.byType), and the test file imports the page's library; a function page has no type to find, so it is skipped (printed below). To find a page some other way in a hand-written test, pass page: to smokeTestRoute.
The file and who owns it.
- The file's first line is
// Written by `fsp test` from lib/app/: don't edit it, run `fsp test` again.andfsp testwrites only that file. It never overwrites a file of that name that does not start with the marker: it fails and says so (move your file, or setout). - The output is a function of the tree and the pubspec (route-table order, no dates), so
fsp test --checkwrites nothing and exits non-zero when the file is missing or out of date. - The second line is
// dart format off. The file is laid out to need no formatting: a call is split one argument to a line, each with a trailing comma, which is whatdart formatleaves alone under an SDK older than 3.7; under 3.7 and later the marker holds the formatter off. Either waydart format --set-exit-if-changedis clean on it.
test: has these keys, all optional. fsp test works with no test: section at all:
fespalier:
test:
out: test/routes # default; `test`, `integration_test` or a folder below one
setup: test/routes/setup.dart # default: <out>/setup.dart, used when it exists
timeout: 30000 # default; milliseconds of the fake clock a test waits for its page, 1000 to 600000
samples: # default: `maestro.samples`; same format
products/$id: 1
skip: [/admin] # patterns as `fsp routes` prints them
a11y: true # since 0.16.0; default false. `true` | `false` | a list of `tap_targets`, `labels`, `contrast`Accessibility (a11y). Since 0.16.0. With a11y: true each test also checks Flutter's own accessibility guidelines once its page is on screen, with the semantics tree on, so the smoke test that proves a route exists proves it can be used with a screen reader and a thumb:
fespalier:
test:
a11y: true # every guideline
# a11y: [tap_targets, labels] # or only some; `false` and `[]` are none (the default)| Value | Checks |
|---|---|
tap_targets | androidTapTargetGuideline (48×48) and iOSTapTargetGuideline (44×44): a tappable widget is big enough |
labels | labeledTapTargetGuideline: a tappable widget has a semantic label (an IconButton needs a tooltip) |
contrast | textContrastGuideline: text against its background meets the WCAG ratio (4.5, or 3 for large text), read from the pixels the test renders |
- What the test gets.
a11y: trueaddsguidelines: routeA11yGuidelines,to eachsmokeTestRoute(the runtime's list of all four constants, inpackage:fespalier/testing.dart). A list of only some declaresconst _a11y = [androidTapTargetGuideline, ...];once in the file and passesguidelines: _a11y,. The same set gives the same file, whatever the order or repeats in the pubspec, and a list of all three istrue. The header of the file says which are on (// Each test also checks Flutter's accessibility guidelines (tap targets, labels, contrast) once its page is on screen.). - A hand-written test passes the same:
smokeTestRoute(tester, '/', router, guidelines: routeA11yGuidelines).guidelines:is empty by default and then nothing changes. The semantics handle is taken for the check and released after it, also when the test fails. - The whole screen is checked, not just the page: the layout's app bar, the tab bar, a floating button. A failure in shared chrome therefore fails every route under it, and one fix clears them all. The first guideline that fails ends a test, so a screen with several problems shows them one kind at a time.
- A failure is Flutter's own, naming the node:
Expected: Tappable widgets should have a semantic label,expected tap target size of at least Size(48.0, 48.0), but found Size(88.0, 28.0), orExpected contrast ratio of at least 4.5 but found 1.10 for a font size of 14.0.Fix the widget: atooltipon anIconButton, aTextButtonfor anInkWellaround aText, a darker colour. A guideline switched off in the list is for a cause in Flutter or a design you cannot change; say why in a comment next to it. - Contrast reads pixels and can move with a Flutter upgrade;
tap_targetsandlabelsread the semantics tree and do not. An app with a known contrast problem can still check the other two. - The values are checked only by
fsp test:`fespalier.test.a11y` is `true` (every guideline), `false`, or a list of `tap_targets`, `labels` and `contrast`, got `sometimes`for a value of another kind, and`fespalier.test.a11y`: `colour` is not a guideline; use `tap_targets` (48×48 on Android, 44×44 on iOS), `labels` or `contrast`for a name in the list.examples/shopturns it on.
Samples are the values of the dynamic folders, in the format of maestro.samples. When test.samples is not there, maestro.samples is used (only that key of maestro: is read, so a maestro: section that fsp maestro would refuse does not stop fsp test). With neither, a route with a dynamic segment is skipped. A sample is percent-encoded and checked against the segment's type, with the same messages as Maestro's, naming fespalier.test.samples or fespalier.maestro.samples, whichever is in use.
The setup file is yours: test/routes/setup.dart by default, or test.setup. fsp test only reads which of two top-level functions it exports, and imports it as setup into the test file:
// test/routes/setup.dart
import 'package:fespalier/testing.dart';
/// Called once per test, so every test gets fresh fakes.
List<Override> overrides(String pattern) => [
apiProvider.overrideWithValue(FakeApi()),
// checkout/guard.dart sends an empty cart back to /cart: this one has a line.
if (pattern == '/checkout') cartProvider.overrideWith(_FullCart.new),
];
/// Optional: the app around the router, for an app that needs its theme or localizations.
Widget app(GoRouter router) => MaterialApp.router(routerConfig: router, theme: appTheme);List<Override> overrides(String pattern)is called once per test with the route's pattern. It returns the providers that test boots with (package:fespalier/testing.dartexports riverpod'sOverridesince 0.8.1), and it can vary by route: a signed-in user for a guarded route.Widget app(GoRouter router)builds the app around the router; the default isMaterialApp.router(routerConfig: router).pumpRoutertakes the sameapp:since 0.8.1.- Each takes exactly one required positional parameter. A setup file with neither, or with one that takes another shape, is an error that says what to write.
- A route with a
guard.dartat or above it is skipped when there is nooverrides, because the guard would most likely redirect. Withoverridesit gets a test, and a guard that still redirects fails it, naming where the router ended. fsp createwrites one when theimagefeature is chosen (since 0.21.0): itstest/routes/setup.darthas anoverridesthat boots/photoover a fake CDN (FakeImagesover aFakeTransportthat sends nothing), so the route is smoke-tested. A setup file makesfsp testrun the guarded routes too, sofsp createthen lists the ones it cannot open on their own (/accountand/sign-inofauth,/labsofflags) undertest: skip:of the pubspec, each with a comment saying which test of the feature opens it. Without theimagefeature there is no setup file, andfsp testskips a guarded route by itself.
Which routes get a test. Each is checked in this order, and the first that applies wins. Every skip is printed on every run (and listed in the test file's header), and none of them fails --check.
| Route | Result | Printed |
|---|---|---|
A redirect.dart route | skipped | skipped /old: a redirect, with no page to see |
Listed in test.skip | skipped | skipped /admin: listed in `fespalier.test.skip` |
A $x or $$x segment with no sample | skipped | skipped /products/:id: no sample for products/$id in `fespalier.test.samples` |
A guard.dart at or above it, and no overrides | skipped | skipped /checkout: guarded by checkout/guard.dart; give test/routes/setup.dart an `overrides(String pattern)` that gets past it |
A function page, and no semantics_ids | skipped | skipped /fn: a function page; set `semantics_ids: true` so its test can find it |
A route with const linkable = false; is tested: the test runs in the process, not through a link. Each route is opened at its canonical path (query parameters and localized spellings are not tried). The success lines are ✓ test: 6 routes in test/routes/routes_test.dart (with ; 1 route skipped when there are skips, and (unchanged) when nothing was written) and, for --check, ✓ test: test/routes/routes_test.dart is up to date (6 routes). The values of test: are checked only by fsp test, so a mistake there never stops fsp gen.
In CI, next to fsp check:
- run: fsp test --check
- run: flutter test