0.0.10
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)