dusk:perf_campaign
Run every scenario of a perf campaign on one platform, each from a cold start of the app, and summarise which passed. One command replaces the shell harness that used to boot services, start the app, log in and loop over dusk:perf_run: the app declares that work in a campaign YAML, and dusk runs it.
Table of contents
Synopsis
dart run fluttersdk_dusk dusk:perf_campaign --platform=chrome|android|ios
[--label=] [--out=] [--only=] [--device=]
[--cdp-port=] [--timing] [--semantics-pass] [--json]
dusk:perf_campaign needs no running app (CommandBoot.none): it starts and stops the app itself through artisan's start and stop, run in-process. It is CLI only; there is no MCP tool for it.
Rebuild a stale dispatcher before invoking it: the command runs inside the compiled dispatcher, whose staleness stamp keys only on pubspec.lock and the SDK, so a dusk change reached through a path override never reaches it. Run rm -f .artisan/build.stamp first.
| Flag | Default | Meaning |
|---|---|---|
/ --campaign |
required | The campaign file. |
--platform |
required | chrome, android or ios. Only scenarios whose platforms list it run. |
--label |
run |
Names the run in every file name. [a-z0-9_-] only. |
--out |
build/perf |
Directory the run files and .err files go to. |
--only |
none | Runs only the scenarios whose name (the run file stem, for a variant) contains this substring. |
--device |
see below | Chrome: chrome. Android: the emulator running android.avd, else the first emulator-* serial adb devices lists as ready. iOS: required, the id of a USB-connected device. |
--cdp-port |
9222 |
The Chrome DevTools port artisan start --cdp-port opens. Chrome only. Checked before anything runs: a value that is not an integer from 1 to 65535 exits 1. |
--timing |
false |
Passed to every dusk:perf_run. |
--semantics-pass |
false |
Passed to every dusk:perf_run. |
--json |
false |
Print only the envelope described in files and output. |
The campaign file
scenarios: # relative to this file; `*` in the file name only
- scenarios/*.yaml
hooks: # shell strings, run verbatim, never interpolated
before_campaign: ./tool/perf/services.sh up
before_scenario: ./tool/perf/services.sh reset
after_campaign: ./tool/perf/services.sh down
android:
avd: my_pixel_api35 # matched by name, launched when not running
reverse: [8001, 8080] # adb reverse tcp:P tcp:P each
grant: [android.permission.POST_NOTIFICATIONS]
after_start: # the scenario `setup` grammar, includes and all
- include: fragments/login.yaml
with: {email: "${env.PERF_EMAIL}", password: "${env.PERF_PASSWORD}"}
when: {text: Sign in, unless_text: Monitors}
retries: 1 # extra attempts per scenario, default 1
scenariosexpands a*in the file name against its directory, sorted; a*in a directory or**is refused. A file withvariantsbecomes one scenario per variant. Two scenarios with one name are refused, since each name is a run file.hooksrun through/bin/sh -cin the directory the command was invoked from.${in a hook is refused: read a variable in the shell as$NAME. Each hook gets the inherited environment minus the credentials the campaign itself consumes (see secrets), plusDUSK_PERF_PLATFORM,DUSK_PERF_LABELandDUSK_PERF_OUT;before_scenarioalso getsDUSK_PERF_SCENARIO, the scenario name, andafter_campaigngetsDUSK_PERF_STATUS,okorfailed.androidis read only with--platform=android. Nothing in it has a default: the AVD, the ports and the permissions are the app's. Eachgrantentry must be letters, digits,_and.only (android.grant[i]names one that is not):adb shell pm granthands it to the device'ssh, which would read anything else as syntax.after_startruns once per cold start, beforedusk:perf_runand so before the scenario's ownsetup. It uses the setup grammar of dusk:perf_run, fragments,whenguards and secrets included.
The whole file is validated before anything runs, and every problem is listed at once, each scenario file's prefixed with the file.
What one campaign does
-
Filter. The scenarios whose
platformslist--platformand whose name contains--only. When none is left the command printsNo scenario matched --only=(oron . No scenario lists) and exits 1 before any hook or process runs.. --platform=ioswithout--device, an unsafe--labeland a--cdp-portthat is not a port are refused here too. -
hooks.before_campaign. A non-zero exit stops the campaign. The hook is done when its shell exits: a server it starts in the background should redirect all three streams ((exec nohup server) >log 2>&1 ), since output still arriving 5 s after the exit is cut and the.errsays so. -
flutter pub get. An edit topubspec_overrides.yamldoes not reach.dart_tool/package_config.jsonon its own. -
Android preparation (
--platform=android). adb is$ANDROID_HOME/platform-tools/adbwhen it exists, elseadbonPATH: flutter drives the SDK's adb, and a second adb of another version restarts the shared server whenever either runs. Withandroid.avd(and no--device): the serial is the readyemulator-*whoseadb emu avd nameis that AVD, so another emulator already running is never the one measured; when none runs it,flutter emulators --launchand the same match every 2 s until it appears (up to 180 s). Thengetprop sys.boot_completedevery 2 s until it reads1(up to 180 s). Withoutandroid.avdthe serial is--deviceor the first readyemulator-*. Thenadb -sperreverse tcp:P tcp:P android.reverse. Withandroid.grant:flutter build apk --profile,adb install -rof the profile APK andpm granteach, theapplicationIdread fromandroid/app/build.gradle(.kts). ThatapplicationIdmust be letters, digits,_and.only, for the same device-shell reason as the grants: one that is not (a Gradle expression such as"com.example.${flavor}", which dusk does not resolve) stops the campaign before the device is touched, with a message saying the Gradle value could not be used. A permission prompt on first launch sits on top of the task and swallows every later launch intent, so the grant comes before the first start. Any step that exits non-zero stops the campaign, and so does one that cannot start at all (flutteroradbnot found, or not executable): the command printsand exits 1.could not start: -
Each scenario, up to
retries + 1attempts:hooks.before_scenario. A non-zero exit, or a shell that cannot start, stops the campaign at this scenario.- The session is read (artisan's stop deletes it), artisan
stopruns, and the command waits until the old app's pid is gone and the ports artisan start refuses to start on can be bound, polling every 250 ms for up to 30 s: the web port and the CDP port of a browser session, and nothing on a device. The VM Service port is not waited for: on Androidadb forwardkeeps it listening after the app is gone, for as long as the adb server lives, and flutter starts over it regardless. artisan's stop signals and returns, and its start fails at once on a port still held. - artisan
start:--device,--cdp-porton Chrome,--profile-staticon a device (a profile build). ext.dusk.boot_idis polled every 500 ms, for up to 180 s, until it answers: the VM Service is up beforemain()has installed dusk.after_start, once the app has mounted a Router (boot_idanswers before it has one, and a login screen needs it). The Router is read every 100 ms for as long as the largesttimeout_msamongafter_start'swhenguards (60 s, the guard default, when it has none), never less than the 10 sdusk:perf_runwaits: a login guard written to wait out a slow cold start does not lose to a router wait that gave up first.dusk:perf_run, in-process on the same connection. Once it exits 0 the command reads the run file it wrote and masks it again for the campaign's secrets (see secrets).--variant --label --out --platform [--timing] [--semantics-pass]
Every scenario runs from its own cold start: on Chrome, DWDS answers
ext.dusk.*with0/1 responsestimeouts that compound over a long session, and a retry in the same session does not clear them. Whatever an attempt throws, or a non-zero exit from stop, start or perf_run, fails that attempt; the next attempt, and the next scenario, still run. -
The end. The app is stopped once the last scenario is done, whatever happened. A stop that exits non-zero makes the campaign exit 1 even when every scenario passed, and says
artisan stop exitedafter the campaign; the app may still be running. -
hooks.after_campaign. Runs once, after that stop, however the campaign ended: every scenario passed, some failed,before_scenariostopped it, or the preparation did, a failedbefore_campaignincluded, since a hook that failed halfway may have started half of what this one tears down. It does not run when the command refused its input or selected nothing (step 1), since nothing was started then.DUSK_PERF_STATUSisokwhen every selected scenario passed and the final stop succeeded, elsefailed. A non-zero exit, or a shell that cannot start, makes the campaign exit 1 and is reported after the scenario results, never instead of them; its output is appended tocampaign-.
Files and output
: the run file/ - dusk:perf_runwrites, masked again for the campaign's secrets.: written when an attempt fails, one section per failed attempt: the failure, the stack trace of anything that is not a perf-run failure, what stop, start and perf_run printed, and, when the attempt failed while starting, a copy of the session's/ - flutter-dev.log. A scenario that passes on its first attempt has none; a stale one from an earlier run is deleted when the scenario starts.: the output of a hook,/campaign- flutter pub getor Android step that stopped the campaign, then that of a failedafter_campaign.
An .err that cannot be written (an --out under a file, a full disk) is reported as dusk:perf_campaign could not write and the campaign goes on: the scenarios after it still run and the app is still stopped.
One line per scenario, as each finishes:
monitors-list-scroll-1440 ok
monitors-list-scroll-390 ok (attempt 2, see /abs/build/perf/monitors-list-scroll-390-base.err)
monitor-detail-390 FAILED (see /abs/build/perf/monitor-detail-390-base.err)
A scenario that passed only on a later attempt says which attempt, and names the .err holding the attempts that failed before it.
With --json the lines are replaced by one envelope, printed however the campaign ended, a stop before the first scenario included:
{"results": [
{"scenario": "monitors-list-scroll-1440", "status": "ok", "attempts": 1,
"runFile": "/abs/build/perf/monitors-list-scroll-1440-base.json", "errFile": null},
{"scenario": "monitor-detail-390", "status": "failed", "attempts": 2,
"runFile": null, "errFile": "/abs/build/perf/monitor-detail-390-base.err"},
{"scenario": "monitor-detail-1440", "status": "not_run", "attempts": 0,
"runFile": null, "errFile": null}
],
"stopped": "hooks.before_scenario exited 2 before monitor-detail-390",
"errors": ["artisan stop exited 1 after the campaign; the app may still be running."]}
resultslists every selected scenario.not_runis one the campaign stopped before: every scenario when a hook,flutter pub getor an Android step stopped it, the ones after it whenbefore_scenariodid.stoppedis present when the campaign stopped, with the sentence it printed (and thecampaign-it wrote, when the failed step printed anything).errorsis present when something failed after the scenarios: the final artisan stop,hooks.after_campaign.errFileis also set on anokscenario whose first attempt failed.
Secrets
A value read through ${env.*} or a secret: true param, in after_start or any scenario, is a secret (see dusk:perf_run). The campaign masks every secret, raw and JSON-encoded, as *** in every line it prints, every .err it writes (hook output and the copied flutter-dev.log included), every run file and the --json envelope.
- Run files.
dusk:perf_runmasks its run file for its own scenario's secrets only, so anafter_startcredential an app exception quotes (insemanticsPassReason, say) would pass through it. After each passing attempt the campaign reads the run file back, masks every string in it for all of its secrets and writes it again: still valid JSON, with the same keys and numbers. - Diagnostics. A setup or
after_startfailure quotes the app's newest exceptions, each message cut to 200 characters. The message is masked before it is cut, so a secret the cut would split leaves no prefix behind. - The
--jsonenvelope is masked as a tree (string values only) and printed once, unwrapped. A text pass over the encoded envelope could rewrite a key or a number a secret happens to match: a secretattemptswould turn"attempts":2into"***":2. No secret is put on a command line: hooks, adb and flutter get only their literal arguments, and a hook reads what it needs from the environment it inherits. - The environment. Every variable the campaign reads through
${env.NAME}, inafter_startor any scenario, is kept out of the processes it starts: hooks and every preparation process (flutter pub get, adb,flutter emulators,flutter build apk) get the invoking environment minus those variables, and nothing else is inherited on top. The filter is by name, never by value, soCI=1survives a secret that happens to read1. A hook that backgrounds a server therefore cannot leave the password running with it; a hook that needs a variable the campaign does not read still gets it. The exceptions are artisanstartandstop, which run in-process and have no way to set the environment of what they spawn:flutter runand the app build it drives, the Chrome a CDP start launches, and stop'sadb shell am force-stopon Android inherit the dispatcher's environment whole, and so does adusk:perf_runrelaunch, which chains the two. An adb of another version than the SDK's restarting the shared adb server from there keeps the variable for as long as that server lives. - Short secrets. A secret shorter than 4 characters is refused when the campaign loads, naming the variable or the param: its mask would hit every number and word that holds it.
Exit codes
| Exit code | Meaning |
|---|---|
0 |
Every selected scenario passed. |
1 |
A bad input or campaign file, nothing selected, a hook, flutter pub get or Android step that failed or could not start, an applicationId that could not be used, any scenario that failed every attempt, an artisan stop after the campaign that exited non-zero, or a hooks.after_campaign that failed or could not start. |
See also
- dusk:perf_run: the scenario file, fragments and the run file.
- dusk:perf_compare: judge one campaign's run files against another's.