fluttersdk_dusk 0.0.18
fluttersdk_dusk: ^0.0.18 copied to clipboard
Flutter E2E driver for LLM agents and CI. 41 CLI commands and 39 MCP tools drive a running app over VM Service extensions; no flutter_test harness needed.
Changelog #
All notable changes to this project will be documented in this file.
This project follows Semantic Versioning 2.0.0. Entries follow the Keep a Changelog shape.
Unreleased #
0.0.18 - 2026-10-07 #
Fixed #
dusk:press_key/dusk_press_keyreaches the focused widget. The key was handed toHardwareKeyboard.instance.handleKeyEvent, which updates the pressed-key state and calls the keyboard's global handlers and nothing else, soFocus.onKeyEvent,ShortcutsandCallbackShortcutsnever heard it, whatever the tool description promised: threeArrowDownpresses on an app whose focusedFocusScopezaps channels on the arrows changed nothing. Both halves of the press now enter through the binding'sonKeyData, the handler real key data reaches, marked synthesized so each is dispatched at once, which delivers toHardwareKeyboardand to the focus tree alike. Every key also carries its own physical key now, where every press used to report the Enter key's.dusk:reset_overlayspressed its Escape the same broken way and now goes through the same helper, so aShortcuts-bound dismiss hears it. Two limits are documented on the helper: a real key whose raw message is still pending holds the press back until the next real key, and on an embedder that sends only raw key messages (none of Flutter's own) a press before the first real key fixes the transit mode to key data and real keys stop reaching the focus tree until a restart, so such an embedder must not be driven with it;reset_overlaysstays best-effort there, catching the debug assert as well as a missing handler. (lib/src/utils/key_press.dart,lib/src/extensions/ext_text_input.dart,lib/src/extensions/ext_modal_router.dart)
Added #
dusk:press_key --key=<letter or digit>. A single ASCII letter or digit presses the key that carries it (Gandgboth press the G key with the characterg), so a shortcut bound to a letter can be driven; text still goes throughdusk:type, since a text field takes its text from the text input channel. Anything else outside the named keys is still refused. (lib/src/extensions/ext_text_input.dart,lib/src/commands/dusk_press_key_command.dart,lib/src/dusk_artisan_provider.dart,doc/mcp/tool-reference.md)
0.0.17 - 2026-09-29 #
Fixed #
-
dusk:perf_campaignkeeps the credentials it reads out of the processes it starts. Hooks got the whole inherited environment spread underDUSK_PERF_*, and the preparation processes inherited it too, so a serverhooks.before_campaignbackgrounded kept the password the campaign reads through${env.*}for as long as it lived. The loader now names every variable a${env.NAME}read (PerfLoadResult.envNames,PerfSetupLoadResult.envNames,PerfCampaign.secretEnvNames), and hooks and preparation processes run withincludeParentEnvironment: falseon the invoking environment minus those names. The filter is by name, not by value:CI=1survives a secret that reads1. artisanstartandstoprun in-process with no environment seam, so theflutter run, the Chrome and the Androidadb force-stopthey spawn still inherit the dispatcher's environment; the campaign doc says so. (lib/src/perf/scenario.dart,lib/src/perf/scenario_loader.dart,lib/src/perf/campaign.dart,lib/src/commands/dusk_perf_campaign_command.dart,doc/commands/dusk-perf-campaign.md) -
perf_run's restart wait names the app's last answer. Since the boot wait became
pollDuskBoot, a read could start after the pause that spent the budget, with no time left, and itsTimeoutExceptionreplaced the app's own last error in the failure; no read starts once the budget is spent now. The campaign also survives a stale.errit cannot delete (reported, like a write it cannot make) and aflutter-dev.logwith malformed UTF-8 (decoded leniently), both of which escaped the per-scenario catch and skippedafter_campaignand the envelope. (lib/src/perf/perf_run_driver.dart,lib/src/commands/dusk_perf_campaign_command.dart) -
dusk:perf_campaign --jsonprints its envelope however the campaign ends. A stop in the preparation (a hook,flutter pub get, an Android step) returned before the envelope, so a--jsoncaller read nothing; abefore_scenariostop listed only the scenarios tried. The envelope now lists every selected scenario, those never tried asstatus: "not_run"withattempts: 0, carries the stop sentence asstopped, and names anything that failed after the scenarios inerrors. An artisan stop that fails after the campaign now exits 1 (it kept exit 0), and an.errthat cannot be written inside the per-scenario catch is reported through the output instead of escaping it and ending the campaign before the app stop. (lib/src/commands/dusk_perf_campaign_command.dart,doc/commands/dusk-perf-campaign.md) -
dusk:perf_campaign'safter_startwaits for the Router as long as its guards wait. The Router wait beforeafter_startgave up at perf_run's 10 s, so a cold start that mounted its login screen at 15 s failed before awhenguard written for 60 s ever polled.PerfSetupRunner.awaitRoutertakes an optionalbudget(perf_run keeps its 10 s,kPerfRouterBudget), and the campaign passes the largesttimeout_msamong theafter_startguards, the 60 s guard default when there is none, never less than 10 s. (lib/src/perf/perf_setup_runner.dart,lib/src/commands/dusk_perf_campaign_command.dart,doc/commands/dusk-perf-campaign.md) -
A secret shorter than 4 characters is a load problem. Every output is masked for every secret wherever its text appears, so a secret
1or80masked that number in every log line, run file and envelope.loadPerfScenariosandloadPerfSetupnow refuse a non-empty${env.*}value orsecret: trueparam under 4 characters (kPerfMinSecretLength), naming the variable or the param and its length, never the value. (lib/src/perf/scenario.dart,lib/src/perf/scenario_loader.dart,doc/commands/dusk-perf-run.md) -
ext.dusk.navigate_backleaves a hidden shell branch alone. It popped the first poppable Navigator in tree order, offstage ones included, so in a go_routerStatefulShellRoute(every branch kept alive, the inactive ones underOffstageand a disabledTickerMode) a page stacked on a hidden branch was popped while the visible branch stayed, and the answer saidpopped: true. A subtree underOffstage(offstage: true),TickerMode(enabled: false)orVisibility(visible: false)is no longer walked. (lib/src/extensions/ext_navigation.dart) -
A
perf_runsetup navigate lands on its own path, and a late landing is on the record. The late-landing poll accepted a path under the route, so a dropped navigate to/monitorswhile a web hot restart had left the app on/monitors/<id>passed as landed; it now needs the exact path, query ignored. A unit whose setup relied on a late landing carriessetupLandedLate: truein itsrepeats[]entry. (lib/src/commands/dusk_perf_run_command.dart) -
perf_run's post-idle route check compares paths. It compared whole URIs, so a page that normalises its query after the first fetch (/monitorsto/monitors?page=1) failed setup as moved. (lib/src/commands/dusk_perf_run_command.dart) -
perf_tracecuts its frames byperf_end's rule. Under a clock mismatch (frames carry a timestamp and none lands in the window)perf_endkeeps every frame while the trace dropped them all, so the two artifacts disagreed silently. The trace now sharesperf_end's window cut, keeps the frames, and says so withotherData.sessionClockMismatch: true.buildPerfReportforwardssessionClockMismatchthe wayanalysePerftakes it. (lib/src/extensions/ext_perf.dart,lib/src/extensions/ext_perf_trace.dart,lib/src/utils/perf_insights.dart) -
The CDP emulation tools say how long their override lasts.
dusk_resize_viewportanddusk_device_profilepromised a resize, and neither can passhold: each call opens its own CDP session and closes it on return, and Chrome drops theEmulation.*override with it. Both descriptors, both CLI descriptions anddusk:device's output now say the override lasts only as long as the call's CDP session (onlydusk:device's window size stays), anddusk:resizereports "override sent" rather than "Viewport set to", pointing at--hold. (lib/src/dusk_artisan_provider.dart,lib/src/commands/dusk_resize_command.dart,lib/src/commands/dusk_device_command.dart) -
ext.dusk.navigate_backpops a page stacked inside a shell. It popped the first Navigator in the tree, which in a go_router app is the root one, and that holds the shell alone: for a detail page pushed inside aShellRoute(MagicRouter layouts,.stacked()) the pop did nothing, the answer was stillnavigatedBack: true, andget_routes'surikept naming the detail page because it was still mounted. It pops the outermost Navigator that can pop instead, so a page pushed on the root is left before a shell's.uriitself was not stale: it follows the Router's report within the frames the handler awaits. The answer gains an additivepopped,falsewhen no Navigator could pop, so a no-op no longer reads as a pop. (lib/src/extensions/ext_navigation.dart,lib/src/dusk_artisan_provider.dart,doc/mcp/tool-reference.md,skills/fluttersdk-dusk/references/mcp-tools.md) -
The semantics pass re-acquires after
perf_end, not inside the window.perf_runcalledext.dusk.semantics_hold action=acquirebeforeperf_end, so the acquire's frame, a rebuild of the whole semantics tree, was a frame of the session, andperf_endreadenv.semanticsEnabledafter it: everysemanticsOffrepeat reportedtrue. The session now closes first and the acquire follows, and it still runs when a step orperf_endthrows. (lib/src/commands/dusk_perf_run_command.dart,lib/src/extensions/ext_semantics_hold.dart,doc/commands/dusk-perf-run.md,doc/reference/semantics-hold.md) -
The semantics pass no longer reports a release that left semantics on as measured. On Flutter web the engine turns semantics on at the first semantics tree a real app sends and never turns it off, so once dusk had snapshotted the app, releasing dusk's handle left the tree on: a measured uptizm list scroll showed the
SEMANTICSblock on 58 of 60semanticsOffframes, as many as the attribution series, undersemanticsPass: "measured". The release answer gainsheldByPlatform(platformDispatcher.semanticsEnabled), and a release that answerssemanticsEnabled: trueends the pass:semanticsPass: "unsupported", nosemanticsOffseries, and asemanticsPassReasonnaming the platform and what holds semantics on (the web engine on chrome, an accessibility service on a device, or anotherSemanticsHandle). (lib/src/extensions/ext_semantics_hold.dart,lib/src/commands/dusk_perf_run_command.dart,lib/src/dusk_artisan_provider.dart,doc/commands/dusk-perf-run.md,doc/reference/semantics-hold.md,doc/mcp/tool-reference.md) -
perf_runnavigates once the app can route.ext.dusk.boot_idanswers as soon asDuskPlugin.install()has run, which a host that installs dusk before its own boot (magic_devtools' documented order) reaches beforerunAppmounts its Router, so the first setupnavigateafter every hot restart answerednavigated: falseand every Chrome scenario that navigates failed at its first unit. A setup navigate now waits, up to 10 s, forget_routesto report a mounted Router, and holds anavigated: falseagainst the Router for up to 3 s before failing: a Router that has only just mounted is still applying its first location when the navigate reads it (measured:falseas it mounted,true300 ms later). The route is never dispatched twice. (lib/src/commands/dusk_perf_run_command.dart,doc/commands/dusk-perf-run.md) -
perf_run's post-idle route check can see a redirect. It comparedget_routes'slocationbefore and after network idle, which a Router-based app answers as""on both sides; it comparesurinow, and the setup diagnostics quote it (route none (no Router mounted)when there is none). (lib/src/commands/dusk_perf_run_command.dart) -
perf_enddescribes its own window. A list scroll on the Pixel 8 emulator drew 24 frames and the summary held 31: timings the engine had parked beforeperf_beginarrive after the buffer is cleared, and the idle frameperf_enddraws to flush the tail is a frame of its own. Frames are now kept byvsyncStartUsinside the session window (a record without one is kept), the denominator stays the liveness advance read before the flush, andcoverage.framesOutsideSessioncounts what was read and left out.vsyncStartUsagainstFlutterTimeline.nowis unverified off the web; a disagreement shows as a largeframesOutsideSession. (lib/src/extensions/ext_perf.dart,lib/src/utils/perf_insights.dart,doc/commands/dusk-perf-end.md) -
perf_tracecounts the frames it leaves out. A frame outside the session window was dropped silently;otherData.framesOutsideWindownow says how many. (lib/src/extensions/ext_perf_trace.dart,doc/reference/perf-trace.md) -
ext.dusk.find_by_labelfinds labels in a running app. It walkedrootPipelineOwneralone, so a view mounted under a child pipeline owner (the test harness, a multi-view app) matched nothing; it now walks every owner asobserveandsnapdo. (lib/src/extensions/ext_wait_find.dart) -
dusk:resizesays what it can keep. Chrome dropsEmulation.setDeviceMetricsOverridewhen the DevTools session that sent it detaches, whatever its parameters (measured against Chrome: 390 px inside the session, the page's own width right after), so the command set a viewport and exited, and the success line said it was set. A new--holdkeeps the session open until Ctrl-C or until Chrome exits; without it the command now warns that the override ends with it. (lib/src/commands/dusk_resize_command.dart,doc/commands/index.md,doc/commands/dusk-screenshot.md) -
perf_runsetup failures say which screen the app was on. Await_for_textthat timed out, or a setup gesture that matched nothing, named only the text, so a navigate the router had dropped read the same as a slow page. Every setup failure but a restart's now ends with aDiagnostics:line: the routeext.dusk.get_routesanswers, the last setup navigate's payload and the three newestext.dusk.exceptionsentries. A setupnavigateansweringnavigated: falsefails at once with its payload instead of being ignored, and after each one the runner waits for network idle and reads the route again, failing when the app has moved (an auth redirect after the first fetch). (lib/src/commands/dusk_perf_run_command.dart,doc/commands/dusk-perf-run.md) -
perf_runwaits up to 3 s for a target. A target the screen had not built yet failed the run as "matched nothing" on the first lookup; it is now looked up every 100 ms for up to 3 s (30 lookups at most) first. (lib/src/commands/dusk_perf_run_command.dart) -
perf_runresolves outside the window what it can. Every target was resolved inside the measured window, so each lookup (and a wheel's hover) was session cost. A target onlywaitsteps precede is now resolved beforeperf_begin, a wheel's hover point with it (PerfStepVerb.movesTargets); a target an earlier step can create or move, such as an option in the overlay a tap opens, still resolves just before its step. Each repeat in the run file gainsresolves: [{step, verb, phase: beforeBegin|inWindow, resolveMs}], so the in-window cost is visible. (lib/src/commands/dusk_perf_run_command.dart,lib/src/perf/scenario.dart,doc/commands/dusk-perf-run.md) -
The run file keeps the renderer the app reported.
env.rendererwas always overwritten with the run log scrape, which readsunknownon web, so thecanvaskitorskwasmanswer fromrendererReadernever reached the file. The app's answer now wins, and the scrape is used only when the app answersunknown. (lib/src/commands/dusk_perf_run_command.dart,doc/commands/dusk-perf-run.md) -
perf_runsetup waits survive a slow web restart.wait_for_textnow waits in 5 s slices until its budget is spent: DWDS abandons any service extension call at 10 s, so one 15 s in-app wait failed as a -32603 whenever the app took longer than 10 s to show the text. -
The actionability gate no longer refuses every ref once the soft keyboard has opened. The stable check measured the live rect against the rect the snapshot minted, so any reflow since the snapshot read as motion: on Android, the keyboard that
fillitself opens failed the nextfillandtapwithnot stable (rect changed by 21.0px)and kept failing until a re-snap, however still the page was. Both samples are now live, one frame apart, as the check was documented to do. The off-viewport check now measures against the view minus itsviewInsets, and scrolls a target in when its center is outside that area: a submit button laid out under the keyboard used to pass as on-screen and then fail withobscured by other widget (top=_RenderInkFeatures), the Scaffold's own Material in the inset. With noScrollableto bring it up, such a target is refused asoff-viewport. Check order and reason substrings are unchanged. (lib/src/utils/actionability_gate.dart,doc/reference/actionability-gate.md) -
A scenario
wheelcan scroll like a wheel.ticks: Nsends N events ofdx/dy, one frame apart, at the one point the step resolved. A single 1200 px event jumped the uptizm monitor list in one frame and the session measured five. -
perf_endno longer loses the frames a session drew last. The web engine hands frame timings over only from inside a later frame, 100 ms after the previous hand-over, so the tail of a session stayed parked and a list scroll reported 1 of 5 frames.perf_endnow waits past that interval and draws one idle frame (after the liveness verdict, so a hidden page still refuses) before it reads.
Added #
-
hooks.after_campaign: a campaign's teardown.before_campaigncould start services and nothing stopped them (a server bound to0.0.0.0outlived every campaign).after_campaignruns once after the final app stop, however the campaign ended, a failedbefore_campaignincluded (it may have started half of what the teardown stops), and not when the command refused its input or selected nothing. It gets the hook environment plusDUSK_PERF_STATUS=ok|failed; a failure exits 1, is listed in the envelope'serrorsand appended tocampaign-<label>.err, and never hides the scenario results. (lib/src/perf/campaign.dart,lib/src/commands/dusk_perf_campaign_command.dart,doc/commands/dusk-perf-campaign.md) -
ext.dusk.navigateanswersexactPath. Its verdict stays a prefix match, the documented intent (a navigate to/monitorsthat shows/monitors/7, a default child, navigated), and the payload now says whether the Router's path is the route's own, query aside:exactPath: truefor the route itself,falsefor a page under it and besidenavigated: false. Additive;dusk:perf_run's setup keeps its own exact check. (lib/src/extensions/ext_navigation.dart,lib/src/dusk_artisan_provider.dart,doc/mcp/tool-reference.md,skills/fluttersdk-dusk/) -
The run file's setup echoes its
whenguards.PerfSetupStep.toJsonwrote a guarded step as if it always ran, so nothing in a run file said a guard stood in front of it. Each guard is now echoed once, marking where its group begins, aswhen: {text, unless_text, timeout_ms}, on the first step it governs (PerfSetupStep.toJson(opens:),PerfSetupGuard.toJson); an enclosing guard entered on the same step is itsparent, and a bare verb there becomes{verb: null, when}. Unguarded steps are unchanged, anddusk:perf_comparereads no setup. (lib/src/perf/scenario.dart,doc/commands/dusk-perf-run.md) -
dusk:perf_campaign: a whole perf campaign in one command.dusk:perf_campaign <campaign.yaml> --platform=<chrome|android|ios>filters the campaign's scenarios by platform and--only(nothing selected exits 1 before any hook or process runs), runshooks.before_campaignthrough/bin/sh -cwithDUSK_PERF_PLATFORM,DUSK_PERF_LABELandDUSK_PERF_OUT,flutter pub get, and on Android bootsandroid.avd, waits forsys.boot_completed,adb reverses each port and installs and grants a profile APK. Each scenario then gets up toretries + 1attempts, each from a cold start:hooks.before_scenario(plusDUSK_PERF_SCENARIO), artisan stop, a wait until the old pid is gone and its ports are free, artisan start, a poll ofext.dusk.boot_id,after_startonce a Router is mounted, anddusk:perf_runin-process. A failed attempt, whatever it threw, is recorded in<out>/<scenario>-<label>.err(withflutter-dev.logwhen the start failed) and the campaign goes on; the app is stopped at the end; one line per scenario (or a--jsonenvelope) and exit 1 when any failed. Every line,.err, run file and envelope is masked for the campaign's secrets: the run file perf_run wrote is masked again for theafter_startones, a diagnostic exception message is masked before it is cut to 200 characters, and the--jsonenvelopes of both commands are tree-masked and printed once, so a numeric secret cannot break their JSON. Aflutter,adbor hook shell that cannot start stops the campaign with<exe> could not start, a--cdp-portthat is not a port exits 1 before anything runs, anandroid.grantentry or GradleapplicationIdthat is not[A-Za-z0-9_.]+is refused before it reaches the device shell, and a scenario that passed on a retry printsok (attempt N, see <err>).dusk_perf_runtakesvariant. CLI only, no MCP tool. (lib/src/commands/dusk_perf_campaign_command.dart,lib/src/dusk_artisan_provider.dart,doc/commands/dusk-perf-campaign.md) -
dusk:perf_runloads fragments and variants, runswhenguards and masks secrets. Both the scenario and--againstload throughloadPerfScenarios, soinclude,${...}andvariantswork in a run.--variant=<key>picks one variant (required when the file declaresvariants, the error listing the keys; refused when it does not; applied to--againsttoo), and the run file carries a top-levelvariantwhen one was picked. An include'swhenguard pollsext.dusk.find --textevery 250 ms up totimeout_ms,unless_textfirst:textruns the group,unless_textskips it, a timeout skips it withoutunless_textand fails the run naming both texts with one. Every line the command prints and every string in the run files it writes is masked for the loaded secrets, raw and JSON-encoded (PerfRedactor,RedactingOutput). The setup and step execution moved into the publicPerfSetupRunner(run,awaitRouter,diagnose) andPerfActions, which the campaign command reuses; existing scenarios run exactly as before. (lib/src/commands/dusk_perf_run_command.dart,lib/src/perf/perf_actions.dart,lib/src/perf/perf_setup_runner.dart,lib/src/perf/perf_redaction.dart,doc/commands/dusk-perf-run.md) -
Scenario fragments, interpolation, secrets and variants.
loadPerfScenarios(path, env:)loads a scenario file and answers(scenarios, secrets): a setup entry- include: <path>withwith:andwhen:flattens a fragment (params,when,steps, nested up to 8 deep, cycles refused) intosetup, each flattened entry keeping its origin (fragments/login.yaml steps[1]) and thewhenguard as aPerfSetupGuard; every scalar is interpolated once (${param},${env.NAME},$$); a value from the environment or asecret: trueparam may only be the text of afillortype, is markedPerfStep.secret, written as***bytoJsonand masked in every parse problem;variants: {<key>: {viewport, platforms, repeat, steps}}yields<name>-<key>scenarios validated one by one.loadPerfSetupreads a bare entry list (a campaign'safter_start:) the same way.PerfScenario.parserefusesincludeandvariants, and reads$$as$. (lib/src/perf/scenario.dart,lib/src/perf/scenario_loader.dart,doc/commands/dusk-perf-run.md) -
rendererReader, a seventh cross-package pointer, fillsenv.rendererin theperf_endreport. Its default is dusk's own answer:skwasmorcanvaskiton web,unknownelsewhere, where the launch log scrape ofdusk:perf_runcovers native. A host may reassign it. Exported from the barrel. (lib/src/utils/perf_readers.dart,lib/src/extensions/ext_perf.dart,lib/dusk.dart) -
perfInteractionAt(int us), exported. The interaction whose window[startUs, closedAtUs ?? now]holds a recorded time, the newest when windows overlap; for a host that recorded a time without recording the gesture behind it. (lib/src/utils/perf_interaction.dart,lib/dusk.dart) -
dusk:perf_insight/dusk_perf_insight/ext.dusk.perf_insight: drill into one insight of the lastperf_endreport. Takesid(and an optionaltokennaming the report's session) and returns Title / Summary / Detail / EstimatedSavings / NextStep, wheredetailis the raw rows behind the insight: the worst frames with their self-time blocks, the frame-number gaps, the frames where one block weighed most, or what a coverage gap left out. Ids are assigned before the report cuts its list, so an insight counted inomitted.insightsis still drillable. An unknown id, a stale token or a refused session answers an error naming what to read instead; an unknown id points atperf_end'sinsights[]. (lib/src/extensions/ext_perf.dart,lib/src/commands/dusk_perf_insight_command.dart,lib/src/dusk_artisan_provider.dart,doc/commands/dusk-perf-insight.md) -
perf_begingainsmode: attribution|timing.timingtouches nodebugProfile*flag and no collection flag, and its report carries frame timings only: the profiling that makes attribution possible inflates every duration it wraps, so milliseconds are only comparable between timing sessions.phaseswithtimingis rejected, as is an unknown mode, before anything is touched. (lib/src/extensions/ext_perf.dart,lib/src/commands/dusk_perf_begin_command.dart) -
perfInsightContributors, a fifth cross-package pointer, and a purebuildPerfReport, both exported from the barrel. The host (magic_devtools) appends rules that need to know what a wind or magic counter means; a contributor that throws or returns a malformed insight becomes onewarninsight (contributorErrors) and never costs the report.buildPerfReport(framePerf, extras, wind, env: ...)is the function the extension calls, so a host's conformance test builds the same report from the same maps. (lib/src/utils/perf_insights.dart,lib/src/utils/perf_readers.dart,lib/dusk.dart) -
A closable interaction per dusk gesture, carried in the zone. While a perf session is open, every verb that dispatches into the app (
tap,dblclick,triple_click,right_click,hover,drag,type,clear,fill,press_key,focus,blur,scroll,select_option,set_checkbox,navigate,navigate_back,dismiss_modals,reset_overlays) runs its dispatch insiderunZoned(zoneValues: {#fluttersdk_interaction: handle}), so the Timers, Futures and subscriptions its callbacks start can name the gesture that caused them.PerfInteraction {id, verb, target, startUs, closedAtUs}closes at settle (no frame scheduled for 300 ms, or 5 s, or the session closing), and a closed handle reads as absent: a socket opened during a tap keeps its zone forever and would otherwise be attributed to it for good. Frame-zone work (builds,initStaterefetches, post-frame callbacks), which the zone cannot reach, joins throughactiveInteraction(). A verb dispatched inside another joins its interaction, sofillis one. Outside a session nothing changes: no handle, no zone, no timer; the actionability gate and gesture dispatch are untouched.PerfInteractionandactiveInteractionare exported from the barrel. (lib/src/utils/perf_interaction.dart, the gesture extensions,lib/dusk.dart,doc/reference/perf-trace.md) -
ext.dusk.perf_trace: the closed session's timeline as Chrome Trace Event JSON, which ui.perfetto.dev andchrome://tracingopen as is. Interactions and frames (placed atvsyncStartUs) areXslices; host rows from the newperfTimelineReaderpointer map by kind, a span with anidto an asyncb/epair, one without toX, an instant toi, a counter toC. AnXslice that would straddle another on its track moves to an overflow lane (frames (2)) rather than being trimmed. Only what started inside the session window is exported; a malformed host row is counted inotherData.skippedRows. Takes an optionaltoken; answers an error before any session closed, for a stale token, while a newer session is open (itsperf_begincleared the buffers the trace reads), and when a host reader throws.dusk:perf_trace/dusk_perf_tracewrite it to a file. (lib/src/extensions/ext_perf_trace.dart,lib/src/utils/perf_readers.dart,lib/src/extensions/register_dusk_extensions.dart,doc/reference/perf-trace.md) -
ext.dusk.perf_endacceptsfull: 'true', which lifts every cut: block rankings, counter breakdowns, route transitions and insights carry every row andomittedreads all zeros. For a runner that writes the report to a file; the default stays bounded to about 6 KB.buildPerfReportgains the samefullflag. (lib/src/extensions/ext_perf.dart,lib/src/utils/perf_insights.dart,doc/commands/dusk-perf-end.md) -
dusk:perf_run/dusk_perf_run: a perf scenario, repeated from a clean start, to a file. A scenario YAML namessetup(hot_restart,navigate,wait_for_text,wait_for_network_idle, plus the gesturestap,fill,type,press_key,wheel,dragandwaitwith the steps' grammar and validation, run before every repeat and beforeperf_begin, so the path to the measured screen is not measured),steps(tap,fill,type,press_key,scroll,wheel,drag,navigate,resize,wait, each optionallyonly: [...]),platforms,viewport,repeatandthresholds. A target is exactly one of{text},{label},{role, name},{key}plus an optionalindex(not on a key or a label), resolved on the live screen right before its step;{role, name}is the nodedusk:snapprints as that role and name, listed throughext.dusk.observewith snap's roles (button,textbox,checkbox,link,heading,image), sinceext.dusk.find_by_labelwalks only the root pipeline owner and finds nothing in a running app; a literal ref, awheelorresizethat could run off Chrome, and a name or label outside[a-z0-9_-]are rejected, every problem at once.hot_restartis a hot restart on debug and a relaunch throughartisan restarton a build that cannot (env.restartMode); either way the runner waits, 90 s at most after a hot restart, forext.dusk.boot_idto answer a new id, not for a new isolate, which Flutter web never gets (DWDS keeps isolate"1"). Each repeat brings Chrome to front, thenperf_begin, the steps, a 300 ms settle andperf_end full=true. Writes<out>/<scenario>-<label>.json:env(plushostand the run log'srenderer),summary(medians of counts per painted frame and ms, with aspreadblock), the median repeat'sinsights, every repeat and the scenario;--jsonprints it minusrepeats[].--timinginterleaves timing-mode repeats,--against <baseline.yaml>runs a second scenario in the same rounds, the order alternating each round. A refused repeat is recorded and left out of the medians; the command exits 1 only when every repeat refused. (lib/src/commands/dusk_perf_run_command.dart,lib/src/perf/scenario.dart,doc/commands/dusk-perf-run.md) -
dusk:perf_run --semantics-passandext.dusk.semantics_hold. The pass records where eachtap,dragandwheeldispatched with the tree on, then replays them by coordinates with dusk's process-wide semantics handle released for the timed window only (action=releaseafterperf_begin, refused outside a session;action=acquireafterperf_end, awaiting one frame so the tree exists again, run even when a step failed). Reported as asemanticsOffseries; a scenario with afill,typeorscroll, or a replay that fails, recordssemanticsPass: "unsupported"with the reason instead of failing the run.DuskPlugingainsreleaseSemantics,acquireSemanticsandsemanticsReleased. (lib/src/extensions/ext_semantics_hold.dart,lib/src/dusk_plugin.dart,doc/reference/semantics-hold.md) -
Coordinate dispatch and reported points on
ext.dusk.tap,dragandhover.tap {x, y}anddrag {x, y, toX, toY}dispatch with no ref and read nothing from the semantics tree; theirchecksblock always saysgate: skippedand whether the handle isheldorreleased.reportPoint: trueadds the dispatchedpoint(tap, hover) orfrom/to(drag), anddragacceptsstartRefplusdx/dyin place ofendRef. Ref dispatch is otherwise unchanged. (lib/src/extensions/ext_pointer.dart) -
dusk:perf_compare/dusk_perf_compare: judge one run file against another. Gates on counts per painted frame, never raw counts, so a run that drew 10% fewer frames with the same per-frame counts isunchanged, notimproved; milliseconds only from timing-mode medians; emulator raster ms as info. A change inside either run's repeat-to-repeat range isunchanged. Default thresholds warn +10% and error +25%, overridden by the scenario. Prints a compact table, or the JSON with--json; exits 1 on an error-level regression. (lib/src/commands/dusk_perf_compare_command.dart,doc/commands/dusk-perf-compare.md) -
dusk:perf_trace/dusk_perf_trace: writeext.dusk.perf_traceto a Chrome Trace JSON file and print only its path. (lib/src/commands/dusk_perf_trace_command.dart,doc/commands/dusk-perf-trace.md) -
ext.dusk.boot_id, internal: the idDuskPlugin.install()mints for each run ofmain()(DuskPlugin.bootId). Registered last, so an answer means every other extension is registered too. No CLI command or MCP tool wraps it;dusk:perf_runreads it to tell a restarted app from the one before. (lib/src/extensions/ext_boot.dart,lib/src/dusk_plugin.dart,lib/src/extensions/register_dusk_extensions.dart)
Changed #
-
The artisan and contracts floors move to
fluttersdk_artisan ^0.0.17andfluttersdk_wind_diagnostics_contracts ^1.2.0.dusk:perf_campaignreadsStartCommand.browserDevices, public from artisan 0.0.17, instead of a local copy, and its Android runs rely on the profile launch and force-stop artisan 0.0.17 ships; contracts 1.2.0 documents thewidgetBuilds,wrapperEmissionsandinheritedReadsstats keys the perf snapshot reads. (pubspec.yaml,lib/src/commands/dusk_perf_campaign_command.dart,doc/getting-started/,CLAUDE.md) -
dusk:perf_campaignopens one driver per attempt.after_startanddusk:perf_rundescribed the host (asysctlorunamespawn) and read the run log twice per attempt, each through its ownconnectArtisanPerfRun; the attempt now opens the driver once and hands it toDuskPerfRunCommand.connected, which runs on it and leaves it to its owner to close. The cold start's boot wait and perf_run's restart wait share one loop (pollDuskBoot), andPerfRunDriver,PerfRunEnvironmentandPerfRunExceptionmoved tolib/src/perf/perf_run_driver.dart, so the perf library no longer imports a command. (lib/src/commands/dusk_perf_campaign_command.dart,lib/src/commands/dusk_perf_run_command.dart,lib/src/perf/perf_run_driver.dart,lib/src/perf/perf_support.dart) -
dusk:installgates the install under!kReleaseMode, and every doc says the same.dusk:perf_runrelaunches the app as a profile build, and theif (kDebugMode)block the installer wrote registered noext.dusk.*there, so the firstboot_idorperf_begincall against an app set up by the installer failed. The injected block, theMagicDuskIntegrationblock and the import (show kReleaseMode) now match theDuskPlugindocblock; release builds still tree-shake the branch. An app wired underkDebugModebefore this is left alone: the installer now checks for theDuskPlugin.install()andMagicDuskIntegration.install()calls rather than for its own snippet, so a re-run adds neither a second block nor an unused import, and such an app keeps working in debug until its guard is changed by hand. Thedusk:installpage also stops claiming the installer wiresWind.installDebugResolver(); it never did. (lib/src/commands/dusk_install_command.dart,install.yaml,README.md,ARCHITECTURE.md,CLAUDE.md,doc/getting-started/,doc/commands/dusk-install.md,doc/plugins/,doc/mcp/tool-reference.md,skills/fluttersdk-dusk/,example/lib/main.dart) -
ext.dusk.get_routesanswers the Router's location asuri.locationreads the root Navigator's top page name, which a Router-based app (go_router, MagicRouter) leaves empty on every screen, so a caller had no way to see where the app was. The newurifield is the first mounted Router's location, the valueext.dusk.navigatealready verifies against, and null while no Router is mounted;locationandtitleare unchanged. (lib/src/extensions/ext_navigation.dart,lib/src/dusk_artisan_provider.dart,doc/mcp/tool-reference.md) -
yamlmoves from dev_dependencies to dependencies, for the scenario filesdusk:perf_runreads. No consumer graph gains a package:fluttersdk_artisan, which dusk already requires, depends onyaml ^3.1.3itself. The only importer islib/src/perf/scenario.dart, reached from the host-sidedusk:perf_runanddusk:perf_comparecommands; in an app it sits behind the same!kReleaseModebranch as the rest of dusk and is tree-shaken from release. (pubspec.yaml,CLAUDE.md) -
Breaking:
perfSessionBeginHookreceives the session'sPerfMode(void Function(PerfMode mode)), so a host can leave wind's counting off in atimingsession. Counting sits onWindParser.parse, the hottest path in the framework, and switched on regardless it inflated exactly the milliseconds timing mode exists to report. A host assigning() {}no longer compiles; assign(PerfMode mode) {}. (lib/src/utils/perf_readers.dart,lib/src/extensions/ext_perf.dart,doc/commands/dusk-perf-begin.md) -
Breaking:
ext.dusk.perf_endanswers an LLM-first report instead of a raw dump, with no alias of the old keys. A real session's payload was 7 to 13 KB of mixed micros and millis, top-N lists that did not say what they cut, and an overhead caveat buried in prose. The payload is now{sessionToken, refused, mode, env, coverage, summary, counters, insights, omitted}, bounded to about 6 KB for a 3600-frame session over 500 block names. Every duration is in ms against a statedbudgetMsof 16.7;summary.framesgivescount,painted,dropped(frame-number gaps), over-budget counts split by thread, and p50/p90/p99/worst build and raster; blocks are ranked by SELF time (selfMicros, never the nestedmicros, which blames a parent for its child's work) and by count per painted frame; every counter is given raw and per painted frame;omittedcounts what each ranked list cut.envstatesplatform,isWeb,buildMode(fromkProfileMode/kDebugMode),semanticsEnabledandphases.coverage.missingnames sources never read, socounters.wind: nullplusmissing: ['wind']is no longer confusable with a wind that counted nothing. Built-in insight rules (over budget, dropped frames, a dominant self-time block, a count-per-frame outlier, incomplete coverage) each state their threshold in the evidence. Removed top-level keys:frameSummary,blockAttribution,note,wind,magic,liveness,phases(nowenv.phases); the refusal carries the liveness numbers undercoverage. The stalled-engine refusal (1 frame or fewer) and the flag receipt are unchanged. (lib/src/extensions/ext_perf.dart,lib/src/utils/frame_summary.dart,lib/src/utils/perf_insights.dart,lib/src/commands/dusk_perf_end_command.dart,doc/commands/dusk-perf-end.md,doc/mcp/tool-reference.md) -
perfExtrasReaderdocuments the full magic key set (controllerNotifies,notifyCauses,queryReloads,actions,events,casts,timerTicks,broadcasts,routeTransitions), and its default returns an empty structure for each. Hosts that still return only the first and last keep working: unknown or absent keys cost nothing. (lib/src/utils/perf_readers.dart) -
ext.dusk.snapregisters in profile builds too (!kReleaseModeinstead ofkDebugMode), so a profile-mode perf session can still be driven.ext.dusk.evaluatestays debug-only. (lib/src/extensions/ext_snapshot.dart)
0.0.16 - 2026-09-23 #
Fixed #
dusk:fill,dusk:typeanddusk:clearno longer write into a field on a route the visible one covers. The field is chosen by the largest overlap between eachEditableText's rect and the ref's rect, over every editable in the tree. A route under an opaque one stays alive and laid out at its old rect, so a second instance of the same screen ties with the visible field and, visited first, won the tie. Reported from a consumer's login screen, where a login route sat on top of a redirected one:fillansweredverified: truewith the typed value while the visible field stayed empty, because the read-back came off the covered field's own controller; popping the top route showed the value in the form underneath. Only reproducible when the pages keep their exact rects, which is go_router'sNoTransitionPage(what magic builds on web); a zoom or slide transition displaces the covered page and hides the tie. An editable under aTickerMode(enabled: false), which is howOverlaymarks an entry below an opaque one, now ranks below every unmuted editable, overlapping or not: a handle found by its label carries the label's rect, and on a screen pushed over a lookalike that rect can overlap only the covered field. The muted fields are ranked only when nothing unmuted exists, so an app that mutes a visible form keeps its targeting. One gap is left open on purpose: such an app with an unmuted field elsewhere on screen (a search box) gets that field, because nothing in the ecosystem mutes a visible subtree and telling the two apart needs a hit test, which this package already documents as unreliable on web debug builds.type's actionability gate checks the ref's own rect, not the field this ranking resolves, so it does not refuse a wrong field either. Read by walking the ancestors rather than throughTickerMode.of, which would subscribe the field to ticker changes from outside build, orgetValuesNotifier, which needs Flutter 3.35. (lib/src/extensions/ext_text_input.dart,test/src/extensions/ext_text_input_test.dart) (#45)
0.0.15 - 2026-09-21 #
Fixed #
- A
dusk:observecandidate could not be told apart from another carrying the same label, so acting on one moved the other. Every handle observe minted carried the semantics label alone (ext_observe.dart), so N candidates sharing a label got N handles that all re-resolved to the first match, which contradicts the one-candidate-per-interactive-node contract the tool documents. It costs a real session: an agent that correctly picked the third row of a list by its bounds tapped the first, and both calls reported success. A consumer spent two releases recording an app defect that was this, with a viewport sweep and six eliminations behind it, because moving the viewport moved the wrong control in and out of a bottom bar's way.DuskQuerygainsmatchIndex, the node's position among every node whose label matched, in walk order. Observe counts it onSemanticsNode.labelrather than ongetSemanticsData().label, which are different strings under merged semantics (semantics.dart:3801-3804concatenates every merged descendant's label into the data), and before its role filter and before its interactivity check, so the resolver's walk and its own count the same universe. An index that stops resolving reports no match rather than falling back to a surviving namesake; an index is a POSITION and not an identity, so a row inserted above a list after the observe call shifts every held handle with no staleness signal, which the docs now say.dusk:findis unchanged: a predicate a caller chose keeps answering the first match and reporting the total. (lib/src/ref_registry.dart,lib/src/extensions/ext_observe.dart,lib/src/extensions/ext_find.dart)
0.0.14 - 2026-09-19 #
Added #
server.json, the manifest that lists this package on the official MCP registry. The ecosystem was absent from every MCP directory while three competing Flutter MCP servers were listed, so an agent looking for a Flutter E2E driver found them and not this one. The entry carriesrepositoryandwebsiteUrland deliberately nopackagesblock:registryTypedocuments npm, pypi, oci, nuget and mcpb with no pub equivalent, and bothpackagesandremotesare optional onServerDetail, which requires only name, description and version. Nothing in the package reads the file and no workflow publishes it, sotest/server_json_version_test.dartguards the version againstpubspec.yaml, along with the schema's 100 character description cap. Excluded from the pub archive for the same reasoncodecov.ymlis. (server.json,.pubignore,test/server_json_version_test.dart)
Changed #
- The
fluttersdk_artisanfloor moves^0.0.10to^0.0.16. dusk is a plugin on artisan, and 0.0.16 is where a plugin injection that matches nothing stops reporting Success over a file it never touched. The old range admitted 0.0.16 already, so nothing resolves differently on a freshpub get; what changes is that a consumer reading the floor sees the release this package is verified against. The requirements tables indoc/getting-started/installation.mdanddoc/getting-started/index.mdstill said^0.0.8, which a reader following them would have pinned alongsidefluttersdk_dusk: ^0.0.14two lines below and hit a resolution failure. (pubspec.yaml,doc/getting-started/installation.md,doc/getting-started/index.md,CLAUDE.md)
0.0.13 - 2026-08-25 #
Fixed #
-
dusk:perf_endprinted a complete-looking report over a subset of the session's frames, so an empty attribution read as "nothing was slow". Two counters in the payload measure different things and nothing compared them:liveness.advancedcomes from a post-frame callback and cannot miss a frame, whileframeSummary.frame_countcounts what Flutter'sonReportTimingsdelivered, and Flutter batches those. Measured driving a real app on Chrome, a theme toggle drew 4 frames, 2 were reported, and the 2 block maps that joined were the pre-tap frames, which were empty. The report said "2 frames, worst build 114ms" withblockAttribution: [], which is exactly what a session with no hot blocks looks like; the frames carrying the work had simply never arrived. The payload now carriescoverage: {framesDrawn, framesSummarized, complete}plus adetailstring on the incomplete case, and the CLI prints aPartial:line beside the human summary rather than leaving the caveat in the JSON. It reports rather than refuses, because a subset is still a measurement. (lib/src/extensions/ext_perf.dart,lib/src/commands/dusk_perf_end_command.dart,doc/commands/dusk-perf-end.md,doc/mcp/tool-reference.md) -
The refusal explained a frameless session with the wrong cause first. It led with a backgrounded page, which sent a reader hunting for a visibility problem that usually is not there. On Flutter web the ordinary cause is an idle app: nothing schedules a frame when nothing is dirty, so a session that opens, sleeps and closes legitimately draws zero. Both causes were observed in one investigation, the idle one twice (once at
advanced: 0on a settled page, once atadvanced: 1when a wheel gesture hit a region that does not scroll). The message now names the idle case first and keeps the hidden-page case, which is still why the threshold is 1 rather than 0. (lib/src/extensions/ext_perf.dart,doc/commands/dusk-perf-end.md)
0.0.12 - 2026-08-25 #
Added #
-
dusk:perf_begin/dusk:perf_end: frame attribution around a driven interaction. A dusk session could tell you a screen was slow and nothing about why. The pair brackets an interaction:perf_beginturnsFlutterTimelinecollection on (first, becausestartSyncandfinishSyncboth read that flag and enabling the build flags ahead of it pushes a finish with no matching start), thendebugProfileBuildsEnabled+debugProfileBuildsEnabledUserWidgets, and on--phasesalsodebugProfileLayoutsEnabled+debugProfilePaintsEnabled. The flags live in two different libraries,package:flutter/widgets.dartandpackage:flutter/rendering.dart.perf_endreturns the frame summary under Flutter's own metric names, a session-wide block ranking naming the widget and RenderObject types that ran, wind's cache hit/miss/bypass counters and magic's controller-notify counts, then puts every flag back to the value it had BEFORE the session rather than tofalse, because a host that had build profiling on for its own reasons must get it back. Aperf_beginon an already-open session restarts it, restoring before it re-saves, so a dropped connection cannot strand the profiling flags on. New:lib/src/extensions/ext_perf.dart,lib/src/commands/dusk_perf_begin_command.dart,lib/src/commands/dusk_perf_end_command.dart, plus thedusk_perf_begin/dusk_perf_endMCP descriptors. 36 CLI commands, 35 MCP tools, 32ext.dusk.*extensions. -
A session the engine did not render through is refused, not reported. Every number in a stalled session is a zero, and a table of zeros reads as "fast": that is the reading a live probe produced three times against a Chrome tab that was merely behind another window.
perf_endcompares a liveness counter across the session and answersrefused: truewith a reason and NO metrics block at all when it did not advance. The counter is the authority rather than thewarningsblock on the same response:SchedulerBinding.framesEnabled, which that block reads, was measured reportingtruewith lifecycleresumedon a page that was hidden and had produced one frame in two seconds. Both signals can ride on one payload, sorefusedis always present and is the only discriminator.dusk:perf_endexits non-zero on a refusal so a shell caller cannot chain on a report that does not exist. -
Four settable perf pointers on the public barrel, and a frame summarizer behind them. dusk's frozen contract #10 limits it to four dependencies and telescope, wind and magic are none of them, so the data crosses through function pointers
magic_devtoolsassigns:framePerfReader(frames plus the liveness counter),perfExtrasReader(magic's controller notifies and route transitions),perfSessionBeginHook(zero wind's counters AND turn its counting on;WindPerfCounters.enableddefaults to false and dusk cannot reach it, so a hook that only zeroed would have produced a wind section of all zeros next to fully populated frame and magic sections with no error anywhere) andperfSessionEndHook(turn it back off, the same disciplineperf_endapplies to thedebugProfile*flags). All four default to no-ops, so dusk andmagic_devtoolsbuild independently.summarizeFramePerfinlib/src/utils/frame_summary.dart(package-internal, deliberately not on the barrel) turns the frame list intoaverage_frame_build_time_millis, the 90th/99th percentiles, the worst frame, the missed-budget counts and the rasterizer equivalents, usingflutter_driver's metric-name strings character for character so a reading here is comparable to devicelab's. Two additions Flutter's own summarizer has no counterpart for: a dropped-frame count derived from GAPS in the frame-number sequence, because on web a dropped scene is a missing frame number rather than a slow frame, and the worst N frames with their block attribution attached. New:lib/src/utils/perf_readers.dart,lib/src/utils/frame_summary.dart; the four pointers are exported fromlib/dusk.dartand listed under ARCHITECTURE.md's frozen contracts. -
Per-type durations are labelled indicative in the payload itself. Flutter's docblocks on the three
debugProfile*flags say the overhead of adding timeline events is significant relative to the time each object takes, and this session runs against a debug build, which widens the gap again. A number that travels without that caveat gets quoted as a production fact, soperf_endcarries anotesaying it: the counts, the ratios and the ranking are what direct a fix.
0.0.11 - 2026-08-20 #
Fixed #
-
A
--withinscope ref that no longer lived was walked anyway, and answered. Registry membership is not liveness: nothing callsdisposeGroupin production, so a token outlives the widget it was minted from, and both a detachedSemanticsNodeand a defunctElementstill answer their visit methods.dusk:snap --within=<stale ref>therefore returned an EMPTY tree, which an agent reads as "this region is empty" rather than "your ref is stale", anddusk:find --within=<stale ref>returned a match from the screen that had already been replaced. Both now checkSemanticsNode.attachedandElement.mountedand report the ref as no longer resolving. Toucheslib/src/extensions/ext_snapshot.dart,lib/src/extensions/ext_find.dart. -
ext.dusk.select_optionechoed the value it was handed back to the caller.{selected: true, value: <requested>}is the request, not the result, which is the exact pattern theeffectblock was introduced to kill and the one verb it had not reached. A dropdown whose parent refuses the change kept its old value and still reported a clean success. It now re-reads the control after the frame and returnseffect: {kind: 'selected', verified, value}, resolving the ref again rather than reusing aBuildContextfrom before the await. Toucheslib/src/extensions/ext_scroll.dart,lib/src/utils/effect_report.dart.
Documentation #
- The CDP clip docblock described the opposite of what the code does. It claimed
scale: 1"keeps the output at the page's own device pixel ratio"; it keeps it at CSS resolution. Measured against this package's example underdusk:device --preset=ipad-pro-12.9(1024x1366 @ 2.0x): full frame 1024x1266, clipped 992x32, both CSS. No code change: an unclippedPage.captureScreenshotreturns CSS resolution too, so the two paths agree and a caller switching between them gets one scale. The measurement is recorded in the docblock so the next reader does not re-derive it.
0.0.10 - 2026-08-20 #
Added #
-
The actionability gate now reports what it could NOT prove, instead of passing silently. Step 5 hit-tests the target's centre and throws when something else is on top, but it can also fail to ANSWER: on Flutter Web's debug build that is routine, because DWDS pipes hit-tests through a snapshot view that does not mirror the live element subtree. The gate proceeds in that case (breaking every valid tap on the artifact is the worse failure) and used to do so silently, so a clean pass was indistinguishable from a confirmed one. That is how
dusk:fillprinted a green tick four times onto a row covered by a pinned footer, with nothing in any response saying the check had not run.ensureActionablenow returns anActionabilityReport(confirmed/indeterminate/skipped), and the response carries achecksblock whenever step 5 did not confirm, with awhyand, on the indeterminate path,overlapCandidates: a rect scan naming render objects that overlap the target and paint after it, capped at five. Advisory rather than a verdict, since an overlap is not proof of occlusion. The block is absent on the healthy path. The six-step order and every failure-reason substring are unchanged. Covered bytest/src/utils/actionability_report_test.dartand a payload case intest/src/extensions/ext_pointer_test.dart. -
dusk:exceptions --clearempties the capture buffer after returning the current entries. The buffer is cumulative by design (it is the app's error history, which is what the command is for), so one real fault at boot rides along on every later read and a per-route sweep reports it against every route. A 12-of-12 "overflow on every screen" finding once turned out to be a single 4.8px transient, and an instrument with a permanent false positive stops being consulted. Clearing after the read rather than before gives a caller everything so far plus a clean slate, which is the primitive a before/after sweep needs. Only dusk's in-package buffer is affected; a wired telescope owns its own store. New:clearCapturedExceptions()inlib/src/dusk_error_capture.dart(the existing reset was test-only and also uninstalled the hook). Covered by three cases intest/src/extensions/ext_exceptions_test.dart. -
--jsonon everydusk:*verb prints the raw envelope. The CLI used to split by verb: read commands printed JSON, the side-effect verbs printed a one-line summary, and a caller driving from a shell had to know which shape each verb produced. Worse, the summarising verbs dropped fields that mattered, and one of them (dusk:wait) dropped the only field it had. The flag makes output shape a caller's choice; the default is unchanged, so a human at a terminal still gets the summary. Where a summary can hide a verdict it now names it:✓ Tapped e7 (no observable change). New:lib/src/commands/json_output.dart, applied to 20 commands. -
dusk:snapgained--within,--interactiveOnlyand--grep;dusk:findgained--within. A full tree is the wrong default answer to most questions. It costs context on any real screen, and on a shell whose sidebar repeats the labels of the pages it opens it is also the misleading one: an exact-label lookup resolves the nav item, so the caller measures the sidebar and concludes two pages differ. The workaround in the field was an x-coordinate threshold for "the content region", which is wrong at every other width and meaningless on a phone where there is no sidebar.--withintakes ane<N>ref and walks that subtree; an unknown or node-less ref is an error rather than a silent widening.--interactiveOnlydrops the plain- textlines.--grepkeeps matching nodes plus the ancestors leading to them, because the ancestors carry the refs an agent acts on and a matching text line has none of its own. The three compose, and an unfiltered call is byte-identical to before.On
find, the scope becomes part of the mintedq<N>handle (DuskQuery.withinRef) rather than a one-off resolution argument: a handle re-executes on every action, and a scoped locator that forgot its scope on the next re-resolve would look correct right up until the shell rebuilt. Playwright's scoped locators behave the same way, including the part where a handle stops resolving once its scope is gone; dusk reports that asmatched: falsewith a diagnostic naming the ref. Toucheslib/src/extensions/ext_snapshot.dart,lib/src/extensions/ext_find.dart,lib/src/ref_registry.dart, both commands and both MCP descriptors; covered bytest/src/extensions/ext_snapshot_filter_test.dartandtest/src/extensions/ext_find_within_test.dart. -
dusk:doctorgained two checks for the failures that present as "dusk is broken" and are not. Session ownership comparesstate.json'sprojectRootagainst the working directory:~/.artisan/state.jsonis a single global slot, so a sibling project'sartisan startsilently takes it and everydusk:*call from here drives that app instead, succeeding each time. The measured case had a worktree in another repository rewrite it mid-session, and two commands produced a screenshot of an entirely different product before anyone noticed. CDP session health probes the recordedcdpPortfor three failures that share one symptom, a capture that never changes: the port refuses (a killed run left its dev server holding the web port while its Chrome is gone), it serves no page on this run'swebPort(an orphan browser still up with the old build), or the matching page is hidden (frame production off). Both are WARN and both skip cleanly when the relevant state is absent. Toucheslib/src/commands/dusk_doctor_command.dart; covered by eight cases intest/src/commands/dusk_doctor_command_test.dart. -
CdpClient.connectacceptsmatchUrlSubstringto pick the page tab that belongs to this run. It selected the firsttype: "page"tab unconditionally, which is not reliably the app under test: an orphan Chrome from a killed run answers on its own debug port with the old build still loaded. The parameter defaults to null, so existing callers are unchanged;dusk:doctorpasses this run's web port. Toucheslib/src/cdp/cdp_client.dart. -
Five verbs now return an
effectblock reporting what the widget HOLDS, not what it was asked to do. A dusk action confirms that it DISPATCHED; nothing in the response confirmed the widget received, and that gap has produced defect-shaped stories more than once.ext.dusk.fillprinted a green tick four times onto a field covered by a pinned footer. A fill against anInputType.numberfield reported the text it had been handed while the widget kept nothing, and the resulting "the sheet holds a stale copy" theory survived two rewrites of a widget that had been correct the whole time. The block is always present on those five, because the agents who most need it are the ones who do not know to ask. The verbs left out have nothing cheap to read back;select_optionis the exception worth a follow-up, since it still echoes its ownvalueparameter:Verb kindFields taptreeChangedchanged(target-scoped route + semantics-subtree signal)type,clear,filltextverified,valueread back off the liveTextEditingControllerscrollscrollOffsetchanged,before,afterset_checkboxcheckedverified,before,afterre-read from the widgetTwo handlers were reporting the request rather than the result and now read back:
ext.dusk.typeechoed its owntextparameter, andext.dusk.set_checkboxreturnedvalue: <requested>for a control that may have ignored the tap.typeIntoElementreturns the post-write value for this. New:lib/src/utils/effect_report.dart. Covered bytest/src/extensions/ext_text_input_effect_test.dart(including a digits-only field that rejects the write, the reproducible stand-in for the number-field case) plus cases in the scroll, checkbox, fill and pointer suites. -
dusk:screenshotanddusk_screenshotnow exposerefandrect, so an agent can capture one component instead of the whole screen.ext.dusk.screenshothas supported all three modes (viewport, ref, ref + sub-rect) since it shipped, and the skill documented them, but neither surface an agent actually reaches declared the parameters: the CLI'sconfigurehad only--output/--format/--quality, the MCPinputSchemahad onlyformat/quality, and its description sent the reader todusk_snapfor "region screenshots", which mints a ref that nothing would accept. The capability was reachable only by calling the VM Service extension by hand. Agents worked around it by capturing the full frame and cropping in Python, or by growing the viewport to a size no device has and putting it back afterwards.rectstill requiresrefand is a hard error alone, rather than a silent full-frame capture.On web the CLI captures through CDP because the in-isolate rasterise hangs under CanvasKit + DWDS, and CDP has no notion of a Flutter ref. Rather than duplicate the geometry,
ext.dusk.screenshotgained ageometry: 'true'mode that resolves the sameref+rectand returns{rect: {x, y, width, height}, devicePixelRatio}without rasterising; the CLI turns that into aPage.captureScreenshotclip (Flutter logical pixels and CDP CSS pixels are the same unit, so it crosses over unscaled). A ref that no longer resolves exits1rather than falling back to a full-frame capture, because an image that looks right and answers a different question is the failure this flag exists to remove. Toucheslib/src/extensions/ext_screenshot.dart,lib/src/commands/dusk_screenshot_command.dart,lib/src/dusk_artisan_provider.dart; covered bytest/src/extensions/ext_screenshot_test.dartandtest/src/commands/dusk_screenshot_command_test.dart. New page:doc/reference/frame-production.mdsiblingdoc/commands/dusk-screenshot.mdexamples 5 and 6. -
Every
ext.dusk.*success payload now carries awarningsblock while the app has stopped producing frames, and the CLI prints a matching stderr banner. With frames off, semantics labels are never rebuilt and dispatched gestures cannot take effect, so two different readings go wrong at once and neither looks like a harness problem:dusk:snapreturns a screen with its buttons and none of its- textnodes (a rendering dashboard reads as "permanently stuck on loading skeletons", which nearly shipped as a defect), and an action reports a clean dispatch that could not possibly have landed. The block carriesframesEnabled: false, thelifecycleStatebehind it, and a hint namingPage.bringToFrontas the fix. It is omitted entirely on a healthy engine, so its presence is the signal and a clean run carries no extra bytes. The banner exists because the commands that summarise rather than print the envelope (dusk:snapprints only the tree,dusk:tapprints✓ Tapped e7) would otherwise drop the one field that says the result is untrustworthy. New:lib/src/utils/dusk_response.dart(duskResult, the single seam all 34 handler success paths now return through),frameProductionWarning()inlib/src/utils/frame_sync.dart,lib/src/commands/frame_warning_output.dart,doc/reference/frame-production.md. Covered bytest/src/utils/dusk_response_test.dartplus banner cases in the snap and tap command tests.
Removed #
ext.dusk.tap's opt-inverifyflag and its top-levelchangedfield. The signal it produced is now the always-oneffectblock above, so the flag was a second way to ask for something the response already carries.dusk:tap --verifyand theverifyMCP property are gone; readeffect.changedinstead ofchanged. Migration is a one-line rename for anything that branched on it.dusk:tap's one-line output also gained a(no observable change)suffix, because the default path prints✓ Tapped e7and would otherwise drop the one field worth reading.
Fixed #
-
The gate's
checksblock reachedtapand none of the other seven verbs that run the gate.ensureActionablereturns anActionabilityReport, and only the tap handler stamped it;hover,drag,dblclick,right_click,triple_clickandtypediscarded the return value, which meansfilldid too. ARCHITECTURE.md anddoc/reference/actionability-gate.mdboth present it as a gate-level guarantee, and the anecdote that motivated it is afillonto a row covered by a pinned footer, so the one verb it was written for was the one that could not report it. All eight now route through a sharedstampChecks. -
dusk:wait_for_network_idlereported success and exited 0 when the network never went idle, the same defectdusk:waithad, in the sibling command, fixed in the same release. See thedusk:waitentry below. -
dusk:scroll'seffectblock measured a different scrollable than the one it drove. The before-offset came fromScrollable.maybeOf(target), which is the ANCESTOR, while the delta branch resolves through a three-stage ladder that also accepts the target BEING a scrollable or containing one. Passing a ListView's own ref, which is whatdusk:find --key=my-listreturns, therefore reportedbefore: nullbeside a realafterand called itchanged: true: exactly the "ref is not a scrollable" case the block was added to catch. The offset is now read from the scrollable each branch actually resolved. -
dusk_screenshotadvertised aq<N>handle it could not resolve. Both the CLI help and the MCPinputSchemasayreftakes "ane<N>token from dusk_snap or aq<N>handle from dusk_find", but the resolver calledRefRegistry.lookup, which never sees theqspace. A query handle failed with "not found in RefRegistry. Call ext.dusk.snapshot first", pointing the agent at the wrong recovery. It now routes throughresolveRefForActionlike every other verb. -
dusk:doctorwarned that a session belonged to another project when the caller stood in a subdirectory of it, and crashed outright when the recorded CDP port had been taken over by a non-CDP service. The ownership row compared paths exactly while artisan's ownsessionOwnershipErrorcompares is-within, so the two tools disagreed about the same state file; and the CDP probe decoded JSON outside the guard that catches the port being dead, turning one of the three cases the check exists to name into a crash of the whole run. The warning text also still described~/.artisan/state.jsonas a single global slot, which stops being true with per-project sessions. -
dusk:doctorcarried a third copy of artisan's path-ownership rule, and the copy disagreed with the original. The doctor comparedstate.json'sprojectRootto the working directory for EQUALITY whilesessionOwnershipErrorcompares is-within, so standing in a package subdirectory made the doctor report the session as another project's while every artisan command drove it without complaint. Two tools disagreeing about one state file is worse than either answer alone.It now calls
sessionOwnershipErroras the predicate and keeps its own dusk-specific wording for the warning. That is what the dependency bump tofluttersdk_artisan ^0.0.10is for: the function does not exist in 0.0.9, so the older constraint would let a consumer resolve a version this package no longer compiles against. Toucheslib/src/commands/dusk_doctor_command.dart,pubspec.yaml. -
dusk:find --withinscoped only one of its five predicate legs and silently searched the whole tree for the other four._findElementByKey,_findElementByTextData,_findElementByTextContainsand_findSemanticsNodeByLabelContainseach took the scope as afromparameter and then walked from the root anyway, so--key/--contains(and--textwhenever it fell through to the element leg) resolved against the entire screen while reporting a scoped answer. Only--semanticsLabelhonoured it, which is why a live drive of the feature looked correct. An unused named parameter is not an analyzer diagnostic, so nothing caught it. All four walks now start at the scope.A second hole sat behind it: a scope entry carrying no
SemanticsNodeleft the semantics walks unbounded rather than refusing.ext.dusk.find_by_textmints exactly that shape (RefRegistry.registerwithout a node), so a ref taken from adusk:waitresult reached the widening path. A label lookup, which has no element-tree fallback, now returnsmatched: falsewith a diagnostic naming the recovery;--textand--containsfall through to the element leg, which the scope does bound. Toucheslib/src/extensions/ext_find.dart; covered by four cases intest/src/extensions/ext_find_within_test.dart. -
The gate reported
obscured by other widget (top=_ReusableRenderView), naming the render view as the thing covering the widget it hosts. The graceful-degradation branch testedpath.length == 1 && isRootRenderView(path.first), butpathruns deepest-first, so the root view being topmost already means nothing in the widget layer claimed the point. A path of view plus gesture-handler therefore missed the branch and threw. The condition is nowisRootRenderView(path.first), which is what it was a proxy for. Toucheslib/src/utils/actionability_gate.dart. -
dusk:waitreported success and exited 0 when the condition never matched.ext.dusk.wait_forreturns a SUCCESS envelope carryingmatched: falseon timeout rather than an error, and the command printed✓ Condition matchedwithout reading it. So the one command whose entire job is asserting a post-condition passed on exactly the case it exists to catch, and any shell chain gated on its exit code proved nothing. It now prints what happened and exits1whenmatchedis false.dusk:wait_for_network_idlehad the identical defect and the identical fix: its handler also answers a timeout with{matched: false}, and the command printedNetwork idleand returned 0 regardless. It is the one a CI script is most likely to chain on. Toucheslib/src/commands/dusk_wait_command.dartandlib/src/commands/dusk_wait_for_network_idle_command.dart; covered by both command test files. -
Every action extension hung forever when the app stopped producing frames, which is what a backgrounded browser tab does. Twenty-seven
await WidgetsBinding.instance.endOfFramecalls acrossext_pointer,ext_text_input,ext_navigation,ext_fill,ext_focus,ext_scrollandext_checkboxsettled a gesture or an edit by awaiting the binding directly.endOfFrameonly schedules a frame whileSchedulerBinding.framesEnabledis true, and Flutter Web turns frame production off once Chrome reportsdocument.visibilityState: "hidden", so the future never completed:dusk:tapsat for 45s+ with no output and no error until the caller's shell timeout killed it, which reads as a wedged app rather than a backgrounded window. All twenty-seven now route throughawaitFrameOrTimeout/awaitFramesOrTimeout(lib/src/utils/frame_sync.dart), which falls through afterkFrameSyncTimeout(200ms per frame). A healthy engine is unaffected: a real frame lands in ~16ms and still wins. The actionability gate's own private copy of this helper was removed in favour of the shared one, leaving one bound for the whole package. Covered bytest/src/utils/frame_sync_test.dartand a frame-starvation case intest/src/extensions/ext_pointer_test.dart. -
CdpClient.defaultHttpGetandChromeFinder.defaultHttpGetclosed theHttpClientwhile the response body was still streaming. Both returnedresponse.transform(utf8.decoder).join()without awaiting it inside atry/finallywhosefinallycallsclient.close(), so the close raced the body drain and a truncated or failed read was possible on a slow/jsonresponse. The same shape was in the integration smoke helper. Toucheslib/src/cdp/cdp_client.dart,lib/src/cdp/chrome_finder.dart,test/integration/cdp_smoke_test.dart.
Docs #
- The registry dispatch fires on a published release now, not on every push that touches the skill. Under the push trigger
fluttersdk/aiclimbed to v1.3.75, and most of those releases re-published identical skill content: a docs commit and a release commit each cost the registry a version. The registry version now tracks published dusk releases instead of counting commits.workflow_dispatchstays as the manual escape hatch when a skill fix has to reach users before the next release. (.github/workflows/dispatch-to-registry.yml) dusk_fillanddusk_reset_overlayswere invisible to the skill. Both shipped in 0.0.7, and neither appeared anywhere inskills/fluttersdk-dusk/: not inreferences/mcp-tools.md(whose header still promised "31 tools" against a real 33), not inreferences/cli-commands.md, not in the SKILL.md family table, not in the CLI output-shape law. An agent loading the skill therefore re-discovered the manual focus + clear + type + wait sequence thatdusk_fillexists to replace, and had no answer at all for an overlay that is not aPopupRoute, sincedusk_dismiss_modalsonly pops those. Both now carry a full entry: input schema, return shape, when to reach for them over the older tool, and the CLI form. (skills/fluttersdk-dusk/SKILL.md,skills/fluttersdk-dusk/references/mcp-tools.md,skills/fluttersdk-dusk/references/cli-commands.md)- Three counts corrected with them: the MCP tool total (31 to 33), the in-isolate
ext.dusk.*split (28 to 30 extension tools, 3 substrate), and the CLI side-effect verb list (18 to 19,dusk:fill).dusk:reset_overlayswent into the JSON-returning list instead, because unlike the other side-effect verbs it always emits JSON. (skills/fluttersdk-dusk/SKILL.md,skills/fluttersdk-dusk/references/mcp-tools.md) - Documented one asymmetry a Bash caller trips on:
includeSnapshotdefaults to true on thedusk_fillMCP tool and to false ondusk:fill. (skills/fluttersdk-dusk/references/cli-commands.md,skills/fluttersdk-dusk/references/mcp-tools.md)
0.0.9 - 2026-07-29 #
Added #
-
ext.dusk.findnow surfaces amatchCountfield and an ambiguitydiagnosticin its success response when--semanticsLabelor--textmatches more than one Semantics node. Previously the handler silently returned the first match, so--semanticsLabel "Password"over-matched the email field on forms where both<TextField semanticsLabel="Password"/>nodes shared the same label. The response now includesmatchCount: Non every match; whenN > 1adiagnostickey carries a human-readable hint (label 'X' matched N nodes; refine with --key, --text, or --contains). Single-match and no-match behaviour is unchanged (backward-compatible). Toucheslib/src/extensions/ext_find.dart; covered bytest/src/extensions/ext_find_test.dart. -
ext.dusk.snapnow surfaces captured non-fatal render/build FlutterErrors in arenderErrorsblock, anddusk:snapprints a⚠ N render error(s)banner to stderr while stdout stays the pure snapshot. A widget that throws at build time (aParentDataWidgetmisuse such asflex-1/Expandedplaced under aSemantics/WAnchorinstead of directly inside a Flex, or an overflow) can render partially and stay invisible in the semantics snapshot, so an action against it silently no-ops with no signal to the agent. The snapshot payload now carriesrenderErrors: {count, recent: [{type, message}], hint}(populated from the existingFlutterError.onErrorcapture buffer, omitted entirely when clean), so a broken screen is impossible to miss without separately callingext.dusk.exceptions. Toucheslib/src/extensions/ext_snapshot.dart,lib/src/commands/dusk_snap_command.dart; covered bytest/src/extensions/ext_snapshot_render_errors_test.dart.
Changed #
ext.dusk.navigatenow tries the consumer navigate adapter (DuskPlugin.navigateAdapter, e.g.MagicRoute.to) BEFORENavigator.pushNamed. On a Router-only stack (go_router / auto_route)Navigator.onGenerateRouteis null, soNavigator.pushNamedraised an asynchronous "no corresponding route"FlutterErroron every navigate. Because the failure was async, the handler's try/catch could not suppress it, and it landed in the FlutterError buffer, now doubly visible via the newrenderErrorssnapshot block as a false positive. Adapter-first dispatch routes through the app's own router public API (the correct path for these apps) and skips the throwingNavigator.pushNamedentirely; it remains the fallback for apps with no registered adapter. Toucheslib/src/extensions/ext_navigation.dart.
Fixed #
-
dusk:doctorcheck 3 (snapshot enrichers) now emits INFO when no enrichers are registered, instead of WARN. Enrichers are opt-in; zero is a valid state, not a problem. The WARN reading alongside "integration wired" (check 5) created false contradiction. Toucheslib/src/commands/dusk_doctor_command.dart; test case updated intest/src/commands/dusk_doctor_command_test.dart. -
q<N>(find / observe) taps now dispatch at the target's own rect instead of the viewport centre._entryFromSemanticsNodeanchored theRefEntryat the root element, sodispatchRectOfreturned_liveRectOf(root)(the whole viewport) and every find/observe gesture fired at screen centre. A centred target coincidentally worked; off-centre controls (a submit button, a checkbox, a sidebar item) were missed silently. The entry now resolves the element whoseRenderBoxcontributes the node (viadebugSemanticsidentity, matchingext_observe), so the gesture lands on the addressed widget. Toucheslib/src/extensions/ext_find.dart; covered bytest/src/extensions/ext_find_test.dart. -
find-by-label now prefers the first INTERACTIVE match when a label collides with inert text. A visibleTextnaming an adjacent control (a settings label beside a switch that shares itssemanticLabel) or a heading repeating a button's text (a "Sign In" heading over the submit button) sits first in tree order, so the handle resolved to the inert node and the tap landed on the label.findnow resolves to the first node exposingSemanticsAction.tap(button / switch / text field) when the label spans an interactive and a non-interactive node, falling back to the first match otherwise.matchCount/diagnosticstill report the collision. Toucheslib/src/extensions/ext_find.dart; covered bytest/src/extensions/ext_find_test.dart. -
dusk:typenow targets the editable inside the ref's own Semantics rect, not the firstEditableTextin the tree. The handler resolved the field to type into by walking to the first editable under the isolate, so on a form with several inputs atypeagainstq3(Password) could land in the first field (Email). It now maps the ref'sSemanticsNodeto its global rect (localToGlobal) and selects the editable whose render box OVERLAPS that rect by the largest area (falling back to the nearest-center editable when none overlaps), so the value goes into the addressed field. The same rect-based selection also backsdusk:clear. Toucheslib/src/extensions/ext_text_input.dart; covered bytest/src/extensions/ext_text_input_test.dart.
0.0.8 - 2026-06-17 #
Changed #
dusk:installnow injectsimport 'package:magic_devtools/dusk.dart';and gates on themagic_devtoolsdependency instead of the removedpackage:magic/dusk_integration.dart. TheMagicDuskIntegrationclass was extracted from themagiccore into the newmagic_devtoolspackage; the injected class name (MagicDuskIntegration.install()) is unchanged. Consumers that follow magic's install.yaml (which addsmagic_devtoolsto dev_dependencies before runningdusk:install) get the integration wired automatically; magic-only consumers withoutmagic_devtoolsin pubspec.yaml are unaffected. Coordinated with the magic_devtools extraction.
Fixed #
dusk:installno longer injectsimport 'package:magic_devtools/dusk.dart';orMagicDuskIntegration.install()into a vanilla Flutter app that hasmagic_devtoolsin its pubspec but noawait Magic.init(call inlib/main.dart. Previously, themagic_devtoolswiring block ran whenever the pubspec listed the dependency, regardless of whether aMagic.initanchor existed. This left an unused import in the consumer's file, causingdart analyzeto fail. The gate is nowhasMagicInit && _hasMagicDevtoolsDep(), matching the block's own intent documented in the comment above it. The existingtry/catcharoundinjectAfterMagicInitis retained as a defensive fallback.
Documentation #
- Docs, skill, and example synced to the
magic_devtoolsextraction:doc/plugins/magic-integration.mdupdated to note thatMagicDuskIntegrationnow ships inmagic_devtools(add as a dev_dependency) and shows the requiredimport 'package:magic_devtools/dusk.dart';.skills/fluttersdk-dusk/references/cli-commands.mdupdated to reflect themagic_devtoolsgate andmagic_devtools/dusk.dartimport.ARCHITECTURE.mdfrozen-contracts item updated frommagictomagic_devtools. Version pins bumped to^0.0.8throughout (pubspec.yaml,example/pubspec.yaml,doc/getting-started/installation.md,skills/fluttersdk-dusk/SKILL.md).
0.0.7 - 2026-06-17 #
Added #
-
dusk:consolenow surfacesdebugPrintoutput even withoutfluttersdk_telescope.DuskPlugin.install()now chains adebugPrintoverride that records every call into a bounded in-package ring buffer (cap 50, newest-first).ext.dusk.consolemerges this buffer with the existing telescoperecentLogsReaderoutput using the same merge+dedup pattern asext.dusk.exceptions, sodebugPrint(...)/print(...)calls appear indusk:consoleresults regardless of whether telescope is installed. The telescope reader indirection is preserved: when telescope is wired it augments withLogger.root.onRecordentries and any other watchers it ships. Directdart:developerlog()calls that bypassdebugPrintare not captured by the in-package path; they require telescope'sLogWatcher. Capture scope is documented indoc/commands/index.mdunder "Console and exceptions". -
Opt-in
verifyflag ondusk:tap/dusk_tap/ext.dusk.tap. Whenverify: true, the tap handler captures a cheap TARGET-scoped signal before and after the pointer (the nearest enclosing route name plus a hash of the target element's own semantics subtree: label, value, enabled/checked flags) and adds achanged: true|falsefield to the response reporting whether the tap produced an observable effect on the target. The signal is deliberately target-scoped, not a global route/whole-tree hash, so a counter button whose own label increments reportschanged: truewhile unrelated background churn elsewhere in the tree does not. Default off (verify: false) keeps the response shape byte-identical to before: nochangedkey. Thedusk:tapCLI gains a--verifyflag and thedusk_tapMCP descriptor gains averifyboolean property (a parameter addition to the existingdusk:tap/dusk_tap, not a new command or tool). -
Optional
sincefilter ondusk:exceptions/dusk_exceptions/ext.dusk.exceptions. Passsince: "<iso8601>"(e.g.2024-01-01T10:00:00.000Z) to receive only exceptions whosetimeis strictly after that timestamp. Agents can record the current time before an action, then calldusk:exceptions --since=<time>afterwards to see only new exceptions raised by that action, eliminating false positives from cumulative history. Default behavior (nosince) is unchanged: the full cumulative list is returned. Unparseablesincevalues are silently treated as absent. Thedusk:exceptionsCLI gains a--sinceflag and thedusk_exceptionsMCP descriptor gains asincestring property (a parameter addition to the existingdusk:exceptions/dusk_exceptions, not a new command or tool). -
dusk:fill/dusk_fill/ext.dusk.fill— one-call text-field fill. Resolves a text-field ref (e<N>/q<N>), then focuses, clears, types, and settles in a single round-trip, replacing the manual focus + clear + type + settle dance every agent re-discovers. Composes the existing GATEDext.dusk.focus,ext.dusk.clear, andext.dusk.typehandlers verbatim (so the 6-check actionability gate, IME focus,onChanged/validator firing, and post-action snapshot semantics are reused, never re-implemented). Retries the whole resolve + focus + clear + type sequence ONCE when the ref goes stale mid-fill (a transiently-missingq<N>re-walks the now-settled tree on the second pass); a second stale outcome surfaces a typedstaleenvelope so the agent re-snaps or re-finds. Returns{ref, text, filled: true}plus an optional post-fill snapshot. This is a NEW command (CLI 32 -> 34) and a NEW MCP tool (31 -> 33) backed by a NEWext.dusk.fillextension (28 -> 30). -
dusk:reset_overlays/dusk_reset_overlays/ext.dusk.reset_overlays— one-call overlay reset. Returns the app to a known clean screen via three escalating, idempotent layers: (1) pop everyPopupRoute(reusingdismissAllModals, never touching the page stack); (2) anEscapekey press for overlays driven by the dismiss shortcut that are notPopupRoutes; (3) a Cancel/Dismiss/Close/OK/Done labelled tap for modal barriers that need an explicit affordance. Each layer is a no-op when the prior already cleared the overlays, so the command is safe to call speculatively between flows. Returns{popped: N, escaped: bool, dismissTapped: bool}. This is a NEW command (CLI 32 -> 34) and a NEW MCP tool (31 -> 33) backed by a NEWext.dusk.reset_overlaysextension (28 -> 30). -
untilconfirmation ondusk:tap/dusk_tap/ext.dusk.tap. Whenuntil: "<text>"is set, after the tap settles the handler polls the live element tree (reusing thedusk:wait_forpoll loop) for aTextwhose data equals the expected string, up tountilTimeoutMs(default 3000), and adds anuntilMatched: true|falsefield reporting whether it appeared. Confirms a navigation / state change produced the expected text in one call, replacing a separatedusk_wait_forround-trip. Default off (nountil) keeps the response shape unchanged. Thedusk:tapCLI gains a--untilflag and thedusk_tapMCP descriptor gainsuntil/untilTimeoutMsproperties (a param addition, not a new tool). -
"Driving real apps: gotchas for agents" doc page (
doc/getting-started/driving-real-apps-gotchas.md). Captures the hard-won lessons from a long real-app E2E session, each now partly or fully addressed by D1-D7: refs go stale on rebuild (re-snap, or prefer aq<N>fromdusk:find); text fields may snapshot nested (usedusk:fill, or note thetypeable: truemarker on the collapsed outer node);dusk:consolecapturesdebugPrintin-package now and is enriched by telescope;dusk:exceptionsis cumulative (use--since);restartpreserves the CDP port; overlays may needdusk:reset_overlays. Linked fromllms.txt.
Fixed #
dusk:dismiss_modalsnow dismisses modals on ALL NavigatorState instances, not just the first. The previous implementation walked the element tree with a first-match guard and poppedPopupRouteentries one-at-a-time with anendOfFrameawait between each pop.showDialogdefaults touseRootNavigator: true(root navigator) andshowModalBottomSheetdefaults touseRootNavigator: false(nearest navigator); when these are different navigators, the first-match walk left one modal open. The fix collects everyNavigatorStatein a full DFS walk, then callspopUntil((r) => r is! PopupRoute)on each navigator innermost-first, counting everyPopupRoutedismissed. Thepoppedreturn value is the additive sum across all navigators. The per-popendOfFrameawait is removed, which also unblocks unit tests that previously hung the flutter_test fake-clock harness when real modal routes were open.
Changed #
- Pointer verbs now dispatch at the element's LIVE rect, not the cached snapshot rect. Every pointer verb (
tap,hover,dragstart + end endpoints,dblclick,right_click,triple_click) re-resolves the target's current bounding rect via the newdispatchRectOf(entry)helper immediately after the actionability gate passes and dispatches at that live center, falling back to the cachedentry.rect.centeronly when the live rect is null (sliver / detached / synthetic). A host that rebuilt the target into a shifted slot between snapshot and action retains the sameElement/RenderObjectidentity, so the live rect is valid. This fixes the false-success class wheredusk:tapreported success whileonTapnever fired because the pointer landed on the target's stale gate-time position. The helper is purely additive to the FROZEN actionability gate: it runs after the gate passes and before dispatch, touching neither the gate order nor any failure-reason substring. dusk:snapcollapses nestedtextboxnodes and marks the survivortypeable: true. A windWInputwraps asSemantics(textField:true) > MergeSemantics > TextField; becauseRenderEditableunconditionally owns its owntextFieldSemantics node (flutter#26336) andMergeSemanticscannot absorb it (flutter#160281), the tree carried TWO nestedtextboxnodes and minted twoeNrefs. Agents naturally targeted the inner leaf, wheredusk:typethrew-32000. The snapshot walk now suppresses anytextboxnode whose render object is a render-tree DESCENDANT of an enclosingtextboxnode's render object, emitting a single ref for the outer typeable node so existing scripts keep resolving. The surviving textbox line gains an additivetypeable: truesub-line. Collapse is by render-object CONTAINMENT only, never label/value equality, so two sibling fields sharing a label stay two distinct refs. Thetextboxrole string is unchanged;eNminting stays snapshot-only. The source-side fix lives in wind (W1); this is the defensive dusk-side collapse.- Bumped
fluttersdk_artisanto^0.0.8. Picks up the substraterestartfix that preserves--cdp-portacross the stop/start cycle (sodusk:resize/dusk:devicekeep working afterfsa restart) and the published-config import-path fix in the plugin installer.
0.0.6 - 2026-06-09 #
Added #
dusk:screenshotweb CDP fallback viaPage.captureScreenshot. When~/.artisan/state.jsoncarries acdpPort(a web target), the CLI command sendsPage.enable+Page.captureScreenshot(format,quality,fromSurface: true) over the Chrome DevTools Protocol and writes the decoded bytes directly, bypassing the in-isolateext.dusk.screenshotextension that hangs under CanvasKit+DWDS (issue #13). Native targets (nocdpPort) keep usingext.dusk.screenshot. The command captures the full app frame. This CDP fallback is CLI-only; thedusk_screenshotMCP tool still dispatchesext.dusk.screenshotin-isolate, so web agents should use the CLI for screenshots. Region (ref/rect) capture remains deferred.- Non-fatal
FlutterErrorcapture surfaced bydusk:exceptions.DuskPlugin.install()now chains aFlutterError.onErrorhandler that records every non-fatal error (including RenderFlex overflow, taggedtype: "overflow") into a bounded in-package ring buffer (cap 50, dedup bymessage + stackHead, newest-first).ext.dusk.exceptionsmerges this buffer with the existing telescope reader output, so overflow and other non-fatal rendering errors appear indusk:exceptionsresults even whenfluttersdk_telescopeis absent (issue #14). - Per-ref
overflow:annotation indusk:snapoutput. Interactive nodes inside a currently-overflowing render ancestor now carry an additiveoverflow: truesub-line in the snapshot YAML. The check is a liverenderObject.toStringShort().contains(' OVERFLOWING')call (the Flutter debug-mode convention fromRenderFlex.toStringShort); no retained state, no Expando. Non-overflowing layouts produce no annotation. The annotation silently drops if a future Flutter version renames the suffix;dusk:exceptionsremains the authoritative overflow signal.
Changed #
fluttersdk_artisanconstraint bumped from^0.0.6to^0.0.7(Dart pre-1.0 caret rule:^0.0.7resolves to>=0.0.7 <0.0.8). Consumers now pull in artisan 0.0.7 which hardensstart --cdp-portwith busy-port fast-fail and Chrome/FIFO/profile cleanup (issue #25). All dusk-consumed artisan surfaces (CommandBoot, ArtisanCommand, ArtisanContext.callExtension, McpToolDescriptor, registerExtensionIdempotent, StateFile.read/write) are signature-identical to 0.0.6; the bump is non-breaking.
0.0.5 - 2026-05-28 #
Changed #
fluttersdk_artisanconstraint bumped from^0.0.5to^0.0.6(Dart pre-1.0 caret rule:^0.0.6resolves to>=0.0.6 <0.0.7). Consumers now pull in artisan 0.0.6 which ships the substratemcp:install --invocation=<exec>flag this release depends on for the fallback behavior below.mcp:installfallback whenbin/fsais absent now writesdart run fluttersdk_dusk mcp:serve. The dusk wrapper auto-injects--invocation=fluttersdk_duskwhen forwardingmcp:installto the substrate, so the substrate's.mcp.jsonwriter picks the plugin-aware payload instead of the legacydart run :dispatcher mcp:servefallback. No change in behavior when fastcli is present; the./bin/fsa mcp:servepayload is unchanged.- Renamed every dart run fluttersdk_artisan reference inside the dusk package to dart run fluttersdk_dusk (33 docs/code occurrences). The dusk wrapper proxies the full artisan command surface; the package-local invocation is now canonical inside dusk's own docs, error messages, dartdocs, and chained subprocess calls. Substrate package:fluttersdk_artisan/ Dart imports unchanged.
Fixed #
bin/fluttersdk_dusk.dartnow forcescollectMcpTools: truewhen dispatchingmcp:serve, sodart run fluttersdk_dusk mcp:servesurfaces all 31 dusk_* MCP tools even without the fastcli scaffold. Previously returned 0 plugin tools (only the 10 substrate tools). Verified end-to-end on a freshflutter createconsumer with path-linked dusk + artisan 0.0.6 against a running Flutter app on Chrome (real counter increments visible viadusk:tap+ subsequentdusk:snap).
0.0.4 - 2026-05-27 #
Added #
README.md## AI Coding Assistantssection +llms.txt## AI & Toolingsection +📡 AI-first Distributionfeature-table row. Aligns dusk's surface with the cross-package fluttersdk pattern (already shipped onfluttersdk_wind): the canonicalfluttersdk-duskskill atskills/fluttersdk-dusk/is distributed through fluttersdk/ai to 8 agents (Claude Code, Cursor, OpenCode, Gemini CLI, VS Code Copilot, Codex CLI, Cline, Roo Code) vianpx skills add fluttersdk/ai --skill fluttersdk-dusk. The hosted docs MCP atmcp.fluttersdk.comexposes asearch-docstool over Streamable HTTP for direct docs-corpus queries, with annpx @fluttersdk/mcpstdio bridge for clients without HTTP MCP transport. The README copy is explicit that this is independent of dusk's own runtime MCP (./bin/fsa mcp:serve): the docs MCP teaches the agent ABOUT dusk; the runtime MCP gives the agent eyes and hands on a running Flutter app.
Changed #
- Hero logo (
.github/dusk-logo.svg) realigned tofluttersdk_magic1:1. The previous logo had drifted toward indigo (#3730A3,#4338CA,#6366F1,#818CF8) which is not in the magic palette the sibling packages share, and its custom wavy shimmer accents diverged from the family line work. The new SVG is a verbatim copy ofmagic-logo.svg: same 4-layer 3D chevron geometry, same three tilted orbit rings (rotated -12°, 25°, 60° around the same center), samerx/ry/stroke-width/stop-opacitytokens, same 7-color violet palette (#4C1D95through#DDD6FE). The only change is the gradient ID prefix (m*->d*, plusorbit-N->d-orbit-N) so both logos can render on the same page without DOM-level ID collisions. Verification:diff <(grep colors dusk) <(grep colors magic)is empty (set-equal); the same diff overrotate()transforms, ellipse params, chevron paths, and stroke / opacity tokens is also empty.
Fixed #
- README + CI workflow stale
developreferences.README.mdhero logo URL, CI badge?branch=, and contributor-section CI sentence pointed at the retireddevelopbranch (404 after the GitHub Flow migration in 0.0.3). All three now point atmaster..github/workflows/ci.ymlpush + pull_request triggers reduced from[main, master, develop]to[master](single long-lived branch per the new flow;mainwas never used,developis retired). Pub.dev's frozen 0.0.3 archive still carries the broken logo URL; this 0.0.4 docs-only release ships the fix to pub.dev.
0.0.3 - 2026-05-26 #
Added #
skills/fluttersdk-dusk/Section 7 +references/community.md. Opt-in star and issue-report CTAs for the LLM-agent skill, bumped to skillversion: 0.0.3. Section 7 carries the trigger matrix only (star = task verified end-to-end; issue = dusk-side bug, explicitly excluding all six Core Law 3 actionability substrings since those are app-state signals). Executable detail (preflightcommand -v gh && gh auth status,gh api --method PUT /user/starred/fluttersdk/dusk --silent,gh issue create -R fluttersdk/dusk --body-file -heredoc,dusk:doctor+dusk_console+dusk_exceptionsdiagnostic gather, prefill URL fallback under 6KB, spam brakes) lives inreferences/community.mdso the always-loaded SKILL.md body stays compact. Both flows are prose-permission only, maximum once per session, never auto-executed; onghabsence the agent prints the URL but does not invokeopen/xdg-open/start.
Changed #
-
Skill bundle decontaminated from consumer-specific identifiers.
dusk_evaluateexamples inreferences/mcp-tools.md,references/cli-commands.md, andreferences/workflows.mdnow use generic placeholders (MyService.instance.state,MyService.instance.state.toString()) instead of consumer-private symbols (Magic.find<MonitorController>(),Magic.find<MagicApplication>()). Route-discovery hint switched fromgrep -r 'MagicRoute.page'to portablegrep -rEn 'GoRoute|MaterialPage|name:' lib/. -
Tinker REPL guidance unified on the concrete command
./bin/fsa tinkeracross the published skill bundle. Package-name attribution (magic_tinker,artisan_tinker) dropped fromSKILL.md,references/mcp-tools.md,references/workflows.md,references/cli-commands.mdsince users only ever need the command they run. Code-sidemagic_tinkerreferences inlib/src/dusk_artisan_provider.dart,lib/src/extensions/ext_evaluate.dart,ARCHITECTURE.md, anddoc/mcp/tool-reference.mdare unchanged and tracked for a separate follow-up. -
Three Copilot review findings on closed PR #5.
references/mcp-tools.mdIIFE closure now returnsstate.toString()so the placeholder API stays consistent with the surroundingMyService.instance.stateexamples.references/workflows.mdroute-discovery grep uses portablegrep -rEnextended-regex syntax instead of the BSD-incompatible basic-regex\|alternation.skills/fluttersdk-dusk/SKILL.mdstale REPL attribution rewritten. -
Two Copilot review findings on PR #6.
skills/fluttersdk-dusk/SKILL.mdCLI output description rewritten to matchreferences/cli-commands.mdtruth: 9 read / query verbs emit JSON, the 18 side-effect verbs print a one-line success summary by default and only emit JSON when--includeSnapshotis passed.references/community.mdstar-flow note drops the spurious HTTP 304 reference; GitHub'sPUT /user/starred/{owner}/{repo}is idempotent and returns 204 whether the star was new or already set.
Docs #
- CLAUDE.md adopts GitHub Flow (Golden Rule 5 + Branching section). One long-lived branch (
master); task branches cut frommaster, PR back intomaster; releases bumppubspec.yaml+ promote[Unreleased]then tag (git tag X.Y.Z && git push origin X.Y.Ztriggerspublish.yml). Matches flutter/flutter, dart-lang/sdk, dart-lang/pub, and the modern OSS ecosystem (react, vscode, rust, node, kubernetes, go, angular). The repo'sdevelopbranch is retired after this release PR merges.
0.0.2 - 2026-05-24 #
Added #
skills/fluttersdk-dusk/LLM-agent skill bundle. Ships an Anthropic-shape skill that teaches an LLM agent (Claude Code or any MCP client) how to drive a Flutter app wherefluttersdk_duskis installed. Mirrors thefluttersdk_telescopeskill layout. Five files:SKILL.md(frontmatter + 6 core laws + 3 agent loops + tool families + install snippet),references/mcp-tools.md(per-tool input schema / return shape / when-to-use / pitfalls across all 31dusk_*tools),references/cli-commands.md(CLI mirror via./bin/fsa dusk:*, pipeline patterns, exit codes),references/actionability-and-refs.md(6-step gate detail +e<N>/q<N>ref recovery matrix),references/workflows.md(8 concrete agent playbooks: form fill, scroll-to-tap, modal flow, navigation verify, hot-reload-after-edit, pull-to-refresh, log tail, before/after diff). Frontmatter front-loadsTRIGGER when:/DO NOT TRIGGER when:vocabulary so the model auto-loads the skill on anydusk_*MCP call,dusk:*CLI invocation, or E2E-driver task on a running Flutter app.
Docs #
- README demo.gif placeholder removed. The
<p align="center"><img src=".../screenshots/demo.gif"></p>block plus itsTODO(v0.0.2-followup)recording-instructions comment are dropped until the actual asset ships. The hero logo, badges, and below-the-fold content are unchanged. - example showroom em-dash sweep.
example/lib/main.dartsection headers and inline comments are normalised to commas / colons / parentheses, aligning the example with the global no-em-dash rule applied across the rest of the repo.
0.0.1 - 2026-05-23 #
Initial public release of fluttersdk_dusk. E2E driver for Flutter apps. Snapshot, tap, type, drag, scroll, screenshot, wait, find via VM Service extensions (ext.dusk.*). Framework-agnostic (vanilla Flutter friendly); Magic / Wind integrations ship inside those packages via DuskPlugin.enrichers extension point. Plugin of fluttersdk_artisan ^0.0.5 (hosted-only; no path overrides). Wind diagnostics flow through the neutral fluttersdk_wind_diagnostics_contracts bridge (WindDebugRegistry) rather than through the enricher list, so wind alpha-10 needs no dusk-side install wiring.
Added #
- 32 CLI commands via
DuskArtisanProvider.commands()(live count fromls lib/src/commands/*_command.dart):dusk:install,dusk:snap,dusk:tap,dusk:screenshot,dusk:type,dusk:scroll,dusk:wait,dusk:wait_for_network_idle,dusk:hover,dusk:drag,dusk:modal,dusk:doctor,dusk:navigate,dusk:navigate_back,dusk:get_routes,dusk:press_key,dusk:select_option,dusk:close_app,dusk:find,dusk:focus,dusk:blur,dusk:clear,dusk:right_click,dusk:dblclick,dusk:triple_click,dusk:set_checkbox,dusk:console,dusk:exceptions,dusk:observe,dusk:resize,dusk:device,dusk:hot_reload_and_snap.dusk:installis the one-shot bootstrap; the rest wrap a matching VM Service extension or substrate-routed action. - 31 MCP tool descriptors via
DuskArtisanProvider.mcpTools()(live count fromgrep "name: 'dusk_" lib/src/dusk_artisan_provider.dart | sort -u):dusk_blur,dusk_clear,dusk_close_app,dusk_console,dusk_dblclick,dusk_device_profile,dusk_dismiss_modals,dusk_drag,dusk_evaluate,dusk_exceptions,dusk_find,dusk_focus,dusk_get_routes,dusk_hot_reload_and_snap,dusk_hover,dusk_navigate,dusk_navigate_back,dusk_observe,dusk_press_key,dusk_resize_viewport,dusk_right_click,dusk_screenshot,dusk_scroll,dusk_select_option,dusk_set_checkbox,dusk_snap,dusk_tap,dusk_triple_click,dusk_type,dusk_wait_for,dusk_wait_for_network_idle. AllMcpToolDescriptorconst instances with Claude Code canonical descriptions (imperative opener + context paragraph +Usage:bullets). - 28 ext.dusk. VM Service extensions + 3 artisan:dusk: substrate-routed tools** (live count from
grep "extensionMethod:" lib/src/dusk_artisan_provider.dart | sort -u). Direct ext.dusk.:snap,screenshot,tap,hover,drag,type,scroll,wait_for,wait_for_network_idle,dismiss_modals,press_key,select_option,navigate,navigate_back,get_routes,evaluate,close_app,find,focus,blur,clear,right_click,dblclick,triple_click,set_checkbox,console,exceptions,observe. Substrate-routed viaartisan:dusk:*:resize,device,hot_reload_and_snap(in-isolate hot-reload deadlock avoidance). All ext.dusk. extensions register throughregisterExtensionIdempotentfor hot-restart safety. DuskPlugin.install(); idempotent host-side install entry. Wraps the app widget root in aRepaintBoundary(noGlobalKey) soext.dusk.screenshotcan find it via render-tree walk. Hot-restart safe via static_installCountguard. HonorsDUSK_DISABLEenv var (1/true/yes, case-insensitive) as kill switch.DuskSnapshotEnrichertypedef; snapshot-enricher extension point.String? Function(Element, RefRegistry). Magic ships its enrichers viaMagicDuskIntegration. Wind no longer ships an enricher as of wind alpha-10: wind state is read through the neutralfluttersdk_wind_diagnostics_contracts.WindDebugRegistry.current?.resolve(element)bridge insideext_snapshot.dartandext_observe.dartahead of the enricher loop, so the 6 core wind fields (breakpoint, brightness, platform, states, bgColor, textColor) survive without an enricher registration. Contract: synchronous, stateless w.r.t. call ordering, may returnnullto skip, multi-line fragments split + indented under the ref entry by the dispatcher.fluttersdk_wind_diagnostics_contractsintegration: new production depfluttersdk_wind_diagnostics_contracts: ^1.0.0.ext.dusk.snapandext.dusk.observeread wind state viaWindDebugRegistry.current?.resolve(element)in addition to the existing enricher list dispatch; thewind:block (filtered by_kDefaultWindKeysindefaultsmode) is emitted directly by dusk. Magic enricher contract UNCHANGED.RefRegistry; stablee<N>(snapshot-frozen) andq<N>(re-resolvable Playwright-Locator) token systems.e<N>refs are minted atdusk_snaptime and consumed by every action tool;q<N>refs are minted bydusk:findand re-execute their stored predicates against the live tree on every action call (resilient to widget rebuild + route push).- Actionability gate (
lib/src/utils/actionability_gate.dart);tap/hover/drag/typeresolve through a single gate that verifies the target's enabled flag (Tristate.isFalsefails;Tristate.noneandTristate.isTruepass), zero-area rect, and viewport overlap BEFORE synthesising the pointer / key event. Failures surfaceServiceExtensionResponse.error(extensionError, "Widget ref=$ref is not actionable: $reason")with$reason∈ {"not enabled","zero rect","off-viewport (rect=..., viewport=...)"}.scroll,select_option, andpress_keyintentionally skip the gate (see Known gaps). dusk:installone-shot bootstrap; minimal install. Edits the consumer'slib/main.dartonly (nobin/artisan.dartorlib/app/scaffolding for vanilla Flutter apps). Detects Magic-stack apps via theawait Magic.init(anchor and injectsDuskPlugin.install()BEFORE Magic.init (thenMagicDuskIntegration.install()AFTER), falling back to therunApp(anchor for vanilla Flutter apps. Wind alpha-10 needs no install-time wiring from dusk: the consumer callsWind.installDebugResolver()directly, and dusk reads wind state throughWindDebugRegistryat snap time. Vanilla consumers access dusk viadart run fluttersdk_dusk <cmd>. Idempotent; safe to re-run.- Flutter-free CLI wrapper;
bin/fluttersdk_dusk.dart+executables: fluttersdk_duskpubspec entry.dart run fluttersdk_dusk <cmd>proxies the full artisan CLI surface and exposes the dusk commands without draggingdart:uiinto pure-Dart contexts. install.yamlplugin manifest; V1 manifest at the package root makesplugin:install fluttersdk_duskwork end-to-end via the artisanPluginInstaller.lib/cli.dartcodegen barrel; Flutter-free typedef aliasFluttersdkDuskArtisanProvider. Consumed by consumer-sidelib/app/_plugins.g.dartauto-discovery without pulling Flutter symbols into the pure-Dart artisan codegen path.dusk:findPlaywright-Locator pattern; mintsq<N>query handles backed bytext/semanticsLabel/keypredicates. Unlikee<N>refs (frozen at snap time), q-handles re-execute the Semantics + Element walk on every action call, so they survive widget rebuilds and route pushes as long as the predicates still match. Stale match returns an explicitstale-handleerror; the agent re-finds, never silently retries.dusk:doctor; diagnostic command that checks~/.artisan/state.jsonChrome PID staleness,DUSK_DISABLEenv-var value, registered enricher count, Semantics-tree-forced flag, and Magic-init wiring in one pass. Emits a categorised report (OK / WARN / ERROR per check); exit code 0 when every check passes.- Chrome reaper (
lib/src/utils/chrome_reaper.dart); graceful Chromium subprocess teardown between dusk:* runs so leftover headless tabs no longer accumulate. Detects orphans by VM Service URI, exits cleanly viaSystemNavigator.popfirst, falls back to SIGTERM. - Example apps:
example/(vanilla Flutter, 7 scenario screens: home menu + buttons / inputs / scroll / modals / drawer / forms) for live e2e validation against the 31 MCP tools + 32 CLI commands. - CDP driver (
lib/src/cdp/):CdpClient,DevicePresets(8 curated device presets with explicit DPR values:iphone-x,iphone-13,iphone-15-pro,pixel-5,pixel-8,ipad-pro-12.9,desktop-1440,desktop-1920),ChromeFinder. Minimal in-house Chrome DevTools Protocol client (~110 LoC, dart:io WebSocket + dart:convert; no pub.dev deps). dusk:resizeCLI (lib/src/commands/dusk_resize_command.dart):dart run fluttersdk_dusk dusk:resize --width=375 --height=812 [--dpr=3] [--mobile] [--touch]. ReadscdpPortfrom state.json, opensCdpClient, sendsEmulation.setDeviceMetricsOverride(+ optionalsetTouchEmulationEnabled).--resetsends 3-call clear chain. Fails loudly when CDP not enabled.dusk:deviceCLI (lib/src/commands/dusk_device_command.dart):dart run fluttersdk_dusk dusk:device --preset=iphone-x. Applies the full emulation chain (metrics + conditional touch + UA) from the curated preset database.--listprints all 8 preset entries;--resetmirrorsdusk:resize --reset.- 2 CDP MCP tools (
dusk_resize_viewport+dusk_device_profile): both dispatch via the existingartisan:substrate prefix (nomcp_server.dartchanges). FakeCdpServertest harness (test/src/cdp/fake_cdp_server.dart): dart:ioHttpServer+WebSocketTransformer.upgradeon an ephemeral loopback port. Configurable failure modes (failOnJsonVersion,dropWebSocket,delayResponseMs). Used bycdp_client_test.dart,dusk_resize_command_test.dart,dusk_device_command_test.dart.- Integration smoke test (
test/integration/cdp_smoke_test.dart): tagged@Skipso defaultflutter testskips it; run manually viaflutter test test/integration --tags integrationto validatedart-lang/webdev#2642regression status. dusk:installmagic-detect branch: now injectsimport 'package:magic/dusk_integration.dart';instead ofimport 'package:magic/magic.dart';. Pairs with magic 1.0.0-alpha.15 which extracts the integration class into a dedicated sub-barrel.- 6-step actionability gate (Wave 3): Step 0 defunct preflight + Stable + Receives-Events gates round out
ensureActionable(now async). Total preconditions in evaluation order: defunct (preflight), enabled, zero-rect, off-viewport, stable (rect unchanged across 2 consecutive frames; Playwright auto-waiting), receives-events (hit-test confirms ref is the front-most pointer target). Opt-out viacheckStable=false/checkReceivesEvents=false(both defaulttrue). Failure-reason substrings extended:"defunct","not stable","obscured by"join the existing agent branch surface. - Snapshot-in-action-response (Wave 3, Playwright
setIncludeSnapshotpattern): 8 action handlers (tap,hover,drag,type,press_key,scroll,navigate,navigate_back) acceptincludeSnapshot=trueand append the post-action snapshot YAML to the success response. The agent no longer needs a mandatory follow-updusk_snapcall.duskSnapBuildwidened from@visibleForTestingto public (legitimate production reuse).press_keyhandler endOfFrame omission fixed in passing. - Structured error envelope + fuzzy-match suggestions (Wave 3):
lib/src/utils/error_envelope.dartwithDuskErrorEnvelopecarryingtype+widget_path+suggestions[]. 10 type values:timeout,not_found,obscured,disabled,stale,zero_rect,off_viewport,not_stable,missing_param,unexpected. 6 factories. Dual-write intoerrorDetail(JSON envelope alongside the free-form message) preserves backward compat for substring-matching agents. Levenshtein with prefix-bonus drives the suggestions list fornot_found.RefRegistry.activeRefs()added to support candidate collection. ext.dusk.wait_for_network_idle(Wave 3): pollsTelescopeStore.pendingHttpCountuntil the count hits zero for a configurableidleMswindow. ParamstimeoutMs(5000),idleMs(500),pollIntervalMs(200). Function-pointer indirection (pendingHttpCountReaderexported fromdusk.dart) keeps dusk free of a hard telescope dependency; magic-side wires the real reader at install time. New CLI commanddusk:wait_for_network_idle.- 4 utility tools (Wave 3):
dusk_console(telescope log reader, function-pointer indirection viarecentLogsReader),dusk_exceptions(telescope exception reader viarecentExceptionsReader),dusk_dblclick(two synthesised taps with 100ms inter-tap delay, shared 6-step actionability gate + snapshot embed),dusk_set_checkbox(idempotentCheckbox/Switchtoggle via element walk; no-op when current value matches target). ext.dusk.observe(Wave 4): Stagehand-style observe-once-act-many pattern. Walks every activePipelineOwnersemantics tree, filters interactive nodes (buttons / textfields / links / checkboxes / dropdowns via_roleFor/_isInteractive), mints a re-resolvableq<N>ref per candidate (Playwright Locator pattern; nevere<N>), and returns a structured JSON list{candidates: [...], count: N}. Each candidate carriesref,role,label,value,bounds,isEnabled,isVisible, plus enricher-projected fields. Params:intent(caller hint, echoed only),limit(default 50),roles(comma-separated filter),includeEnrichers.dusk:hot_reload_and_snap(Wave 4): CLI-side orchestration viaVmServiceClient.reloadSources(in-isolate handler cannot reload its own isolate; deadlock avoidance). Sequence: reload -> wait -> snap -> screenshot -> exceptions -> bundle. Success envelope{reloaded, durationMs, snapshot, screenshot, recentExceptions}; compile-error envelope skips snap/screenshot but still gathers exceptions. Screenshot failure surfaces as partial-resultscreenshotErrorrather than aborting the round-trip. MCP descriptor uses theartisan:substrate routing prefix (extensionMethod: 'artisan:dusk:hot_reload_and_snap').dusk:installis now self-sufficient (Wave 5 pre-publish). Phase 1 patcheslib/main.dart(unchanged contract). Phase 2 chainsdart run fluttersdk_dusk install(scaffoldsbin/dispatcher.dart+./bin/fsaAOT wrapper) followed bydart run fluttersdk_dusk plugin:install fluttersdk_dusk(registersDuskArtisanProvider; artisan 0.0.5 auto-purges the AOT bundle cache). Both Phase 2 sub-process calls are file-marker-guarded (bin/dispatcher.dart,.artisan/installed/fluttersdk_dusk.json) so re-runs are fast no-ops; failures swallow with a warning so Phase 1'slib/main.dartinject remains the guaranteed contract regardless of the consumer'sdartPATH / sandbox state. Net effect: a fresh consumer needs onlyflutter pub add fluttersdk_dusk+dart run fluttersdk_dusk dusk:installto reach a working./bin/fsa list+ MCPtools/listsurface.ext.dusk.findsubstring predicate +dusk:find --contains=<substring>CLI flag (Wave 5; pre-publish E2E pass). Existing--text=<exact>semantics unchanged; agents now have a brittle / dynamic-label fallback.DuskQuery.containsTextfield is the carrier; matching walks Semantics labels first, thenText.data, mirroring thetextpath.dusk:drag --fromRef=<eN> --toRef=<eN>flag aliases parallel to the--refshape used bydusk:tap/dusk:hover(Wave 5). Legacy--startRef/--endRefflags retained for back-compat.dusk:scroll --direction=<up|down|left|right> --pixels=<N>convenience flags that translate to signed--dy/--dx(Wave 5). Explicit--dy/--dxstill win when both forms supplied.- Surface deltas (live counts): CLI commands: 32 (
lib/src/commands/*_command.dart); MCP tool descriptors: 31 (dusk_artisan_provider.dart); VM Service extensions: 28ext.dusk.*+ 3artisan:dusk:*substrate-routed.
Fixed (pre-publish macOS + web E2E pass, Wave 5) #
dusk_resize_viewportMCP arg parsing (GAP I): handler castctx.input.option('width') as String?which failed when MCPtools/calldelivers{"width":390}as a native JSON int rather than a stringified arg. Resize command now defensively readsint/double/boolfrom either type via_readInt/_readDouble/_readBoolhelpers. CLI invocations still work unchanged (ArgParser-emitted strings).
Fixed #
ext.dusk.focuson TextField + EditableText (GAP C): handler walked UP from the snap-captured Semantics element looking for aFocusancestor; for TextField the FocusNode sits BELOW the captured element (insideEditableText/FocusableActionDetector). Now falls back to a descendant walk that picks the firstEditableText.focusNodeorFocus.focusNodeit finds. Reproducer:dusk:focus --ref=<textbox-eN>previously returnedno Focus ancestor; now returnsfocused: true.ext.dusk.scrollwith ref pointing at the Scrollable itself (GAP D):Scrollable.maybeOf(context)walks UP, so passing the ListView's own ref (e.g. fromdusk:find --key=my-list) returned null. Handler now resolves in three stages: (1) target element IS a Scrollable, use its state; (2) Scrollable ancestor (legacy); (3) descendant Scrollable walk (when ref is a parent like a Scaffold wrapping a list).dusk:press_key --key=case-sensitivity (NIT 5): agents calling--key=TABor--key=enterhitunknown keyeven though the supported set covered the intent. Lookup now does a case-insensitive fallback over_kKeyMap.keyswhen the direct hit misses; canonical PascalCase keys (Tab,Enter,ArrowUp) remain documented.dusk:screenshotsuccess message now reports decoded byte count + KB + format, e.g.Wrote 239456 bytes (233.8 KB, jpeg) to ./shot.jpg(NIT 1). Previously the line referenced the base64 character count which misled agents parsing for byte size.dusk:screenshotmissing-output error now suggests the canonical invocationdusk:screenshot --output=./shot.jpg --format=jpeg(NIT 8).- README + installation.md document the full 3-step install flow:
flutter pub add fluttersdk_dusk+dart run fluttersdk_dusk dusk:install+dart run fluttersdk_dusk install && dart run fluttersdk_dusk plugin:install fluttersdk_dusk(GAP B). Previously theplugin:installstep was missing, leaving consumers with./bin/fsa listshowing 0 dusk:* commands.installation.mdcarries a new## Register with artisansection explaining the fastcli scaffold + plugin registration.
Test coverage #
- 678 tests passing (2026-05-23 pre-publish,
flutter test --exclude-tags=integration --timeout=30s). Scope covers handler entry points (params + error paths + happy paths where reachable underflutter_test), 32 CLI commands (name / boot / description / configure / handle / missing-arg validation),DuskArtisanProvider.commands()/mcpTools()shape,DuskPlugin.install()idempotency +DUSK_DISABLEenv-var kill switch,RefRegistrymint / lookup / disposeGroup / disposeAll / refsForGroup / registerQuery / lookupQuery, actionability gate (6-step: defunct / enabled / zero-rect / off-viewport / not-stable / obscured),encodeToJpegPNG-to-JPEG roundtrip + quality boundaries (1, 100, error), modal-route classification, dispatcher contract, CDP client + device presets + resize/device commands, Wave 3 structured error envelopes, Wave 4 observe + hot-reload-and-snap, Wave 5 find-contains substring + descendant focus walk + Scrollable-own-ref scroll. Pre-publish E2E pass against a fresh vanilla Flutter consumer (/tmp/dusk_e2e) verified 27 of 32 CLI commands + MCPinitialize+tools/list(41 tools = 31 dusk_* + 10 artisan_*) +tools/call dusk_snap(identical to CLI) +tools/call dusk_evaluate(actual evaluation via artisan 0.0.5 substrate routing). - Coverage: dusk ~79% line coverage via
flutter test --coverage. The remaining gap covers engine-dependent paths that hang theflutter_testfake-clock harness: handlerendOfFramewaits,Future.delayedpoll loops inwait_for, realtoImage()rasterisation inscreenshotsuccess paths, and private_defaultProcessStartTime/_parsePsLstartdoctor seam defaults. End-to-end coverage for those paths is captured by the example/ playground sweep.
Known gaps #
dusk:doctorruns in pure-Dart CLI context and cannot importpackage:flutter/rendering.dartwithout draggingdart:ui(breaksdart runinvocation). Two checks defang gracefully as a result:semanticsEnabledProbedefaults totrue(the only ERROR-class check, so doctor cannot ERROR from CLI) andenrichersProbedefaults to0(always WARNs on Check 3). The real probes belong to a future VM-Service-attached doctor invocation that calls into the running app.scroll,select_option, andpress_keyintentionally skip the actionability gate: scroll targets the parent scrollable not the ref, select_option dispatches through Material/Cupertino popup machinery that owns its own enabled check, and press_key targets the focused widget rather than a ref. Adding the gate to these three handlers is V1.x candidate work.RefRegistry._queries(q-handle store) is monotonically growing within a debug session; onlyRefRegistry.disposeAll()clears it. Worst-case memory bounded by debug-session lifetime; per-handle eviction is V1.x candidate work.
Risks Accepted #
dart-lang/webdev#2642live regression: "Hot restart broken when running DWDS without Chrome Debug Port". Integration smoke test (test/integration/cdp_smoke_test.dart) surfaces this if active. Mitigation lives in the user's pinned Flutter SDK; plan does not block on regression resolution.- Flutter SDK >= 3.30.0 required for
--cdp-port(perflutter/flutter#170612). Lower versions get an actionable error from bothartisan doctor(advisory) andartisan start --cdp-port(fail-fast). - GAP E (drag synthesis vs Flutter Draggable):
dusk:dragreturns success but Flutter'sDragTarget.onAcceptWithDetailsdoes not fire on synthesised events in some configurations (Pointer Down + 5x Move + Up sequence may not match Draggable's gesture recognizer expectations on certain platforms / dwell times). Verified via E2E showroom (2026-05-23). Tracked for a 0.0.2 follow-up; agents needing drag should fall back to a pair ofdusk:tap+ manual scroll for now. - GAP G (advisory): receives-events check + q-refs on widgets with deep render subtrees: when
dusk:find --key=<name-field>resolves to aTextField(or any widget whosefindRenderObject()returns a top-level RenderObject), the actionability gate's receives-events check sees a hit-test path topped by a deeper descendant (e.g.RenderEditable) and tripsobscured by other widget. The_isDescendantOfwalk does not catch this case consistently. Workarounds: (1) use thee<N>ref from a priordusk:snaprather than aq<N>from--key; (2) pass--no-checkReceivesEventson the action. Tracked for a 0.0.2 follow-up; deeper investigation needed in the gate's hit-test path traversal. - GAP H (web):
dusk:screenshot+dusk:close_apptimeout on Chrome (DWDS): 10s timeout. macOS desktop works fine. The web path likely needs special handling forRepaintBoundary.toImage()under DWDS pixel pipeline + the platform-close semantics ofSystemNavigator.pop()(which closes the tab, so the response can't return). Workaround for close: rely on./bin/fsa stopSIGTERM (works). Workaround for screenshot on web: use the browser DevTools snapshot. Tracked for a 0.0.2 follow-up.
Backward compat #
DuskSnapshotEnricher typedef, DuskPlugin.install / DuskPlugin.enrichers / DuskPlugin.registerNavigateAdapter, RefRegistry public methods (register, lookup, registerQuery, lookupQuery, disposeAll, resetForTesting), and every MCP tool name / ext.dusk.* extension name are part of the public 0.0.1 contract. Future releases keep these stable across the 0.x line; any change requires a coordinated bump with magic + wind.