From 26835f253d80b27907f197f98197ffb4e85f1ec9 Mon Sep 17 00:00:00 2001 From: Koji Wakamiya Date: Wed, 23 Sep 2026 08:49:08 +0900 Subject: [PATCH 1/2] feat(shot): enter text before the capture A form that validates, counts, or suggests as it is typed into could not be shot in that state when it keeps its own controller. shot now takes --enter =: the text field the target is or holds takes focus and its text is replaced, as the platform's keyboard does. The target ends at the first '='. Taps and entries run in the order given on the command line, so an entry can precede the tap that submits it; the parser keeps each option's values in order but not the order between options, which is read back from the arguments. A target with no text field, or more than one, makes the shot an error. The on-screen keyboard is not drawn. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 23 ++++---- doc/agent.md | 7 +-- doc/manual.md | 28 +++++----- lib/src/cli/agent_text.dart | 7 +-- lib/src/cli/manual_text.dart | 28 +++++----- lib/src/cli/shot_command.dart | 10 +++- lib/src/cli/shot_options.dart | 37 ++++++++++--- lib/src/engine/harness_text.dart | 42 ++++++++++++--- lib/src/engine/interaction.dart | 24 +++++++-- skills/shutter-visual-check/SKILL.md | 4 +- test/cli/shot_command_test.dart | 19 +++++-- test/e2e/e2e_test.dart | 78 ++++++++++++++++++++++++++++ test/engine/generator_test.dart | 3 ++ 13 files changed, 242 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index d8c0f98..00ece6c 100644 --- a/README.md +++ b/README.md @@ -136,27 +136,28 @@ shutter shot --widget 'PrimaryButton(label: "OK")' --import lib/ui/button.dart - `--import` names a file under `lib/`, or a `package:` URI of a dependency, that the widget expression needs imported (repeatable; `package:flutter/widgets.dart` is always imported). The same `--widget` and `--import` give the same shot id, so two such runs line up in `diff`. -A state that comes from a gesture is shot by acting on the preview first, and what opens above it by capturing the whole screen: +A state that comes from a gesture or typing is shot by acting on the preview first, and what opens above it by capturing the whole screen: ```bash shutter shot lib/preview/button_preview.dart --press text:Save shutter shot lib/preview/filter_preview.dart --tap 'type:DropdownButton' --capture screen --viewport 390x400 +shutter shot lib/preview/login_preview.dart --enter 'key:email=example@example.com' --tap 'text:Sign in' --settle 700 ``` -`--tap` (repeatable, in the order given), then `--press`, `--hover`, or `--focus`, name their widget by `key:`, `text:`, or `type:`. +`--tap` and `--enter` (repeatable, in the order given), then `--press`, `--hover`, or `--focus`, name their widget by `key:`, `text:`, or `type:`. Each is followed by `--settle` milliseconds; shoot again with another `--settle` for another point of an animation. `--capture screen` holds the whole viewport, with the menus, dialogs, and tooltips the app draws above the preview. ## Subcommands -| Command | Purpose | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `agent` | The step-by-step playbook for AI agents. | -| `manual` | The reference: preview files, drawing model, engine, runs, ids, diff, output, exit codes. | -| `doctor` | Check the Flutter SDK version, its font cache, and the project's shell (`--shell`). | -| `init` | Write `/shell.dart`, or the `--shell` file. | -| `shot` | Render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`). | -| `diff ` | Compare two runs (`--images`). | +| Command | Purpose | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agent` | The step-by-step playbook for AI agents. | +| `manual` | The reference: preview files, drawing model, engine, runs, ids, diff, output, exit codes. | +| `doctor` | Check the Flutter SDK version, its font cache, and the project's shell (`--shell`). | +| `init` | Write `/shell.dart`, or the `--shell` file. | +| `shot` | Render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`). | +| `diff ` | Compare two runs (`--images`). | ## Exit codes @@ -170,7 +171,7 @@ Each is followed by `--settle` milliseconds; shoot again with another `--settle` ## Limits -* One capture per preview: state comes from the widget's construction expression and from taps, a press, a hover, or focus before the capture; scrolling, dragging, and typing are not shot. +* One capture per preview: state comes from the widget's construction expression and from taps, entered text, a press, a hover, or focus before the capture; scrolling and dragging are not shot, nor is the on-screen keyboard. * HTTP is blocked while rendering, so network images fail to load. * Text renders with the project's fonts plus Roboto; CJK and emoji fall back to the host's system fonts, and Cupertino text uses SF Pro on macOS (Roboto elsewhere), as a device would. Compare runs made on the same machine. diff --git a/doc/agent.md b/doc/agent.md index 7435b41..ce6acf2 100644 --- a/doc/agent.md +++ b/doc/agent.md @@ -67,16 +67,17 @@ shutter shot --widget 'PrimaryButton(label: "OK")' --import lib/ui/button.dart - * Without `--size` the widget is shot at its own size; `--size` fixes both dimensions and stretches the widget to them. * The same `--widget` and `--import` give the same shot id in every run, so before and after line up in `diff`. -## Pressed, hovered, focused, opened +## Pressed, hovered, focused, opened, typed -A preview shows the state its construction gives. For a state a gesture gives, act on the preview in the shot: +A preview shows the state its construction gives. For a state a gesture or typing gives, act on the preview in the shot: ```bash shutter shot lib/preview/button_preview.dart --press text:Save shutter shot lib/preview/filter_preview.dart --tap 'type:DropdownButton' --capture screen --viewport 390x400 +shutter shot lib/preview/login_preview.dart --enter 'key:email=example@example.com' --tap 'text:Sign in' --settle 700 ``` -* `--tap` (repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, or `type:`. +* `--tap` and `--enter =` (both repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, or `type:`. * The actions apply to every preview of the named files; a preview where the target matches no widget, or several, is an `error` shot. Name the files whose previews have the target, and give the same actions to the before and after shots. * Menus, dialogs, bottom sheets, and tooltips open above the preview and are in the image only with `--capture screen`; `--viewport` gives a small preview the room they open into. * `--settle` (default 300 ms) is how long after the last action the image is taken. A tapped button's ink is still fading at 300 ms and gone by 700 ms: pass `--settle 700` for the state after a tap without it. diff --git a/doc/manual.md b/doc/manual.md index 064a1f5..3a0bf6e 100644 --- a/doc/manual.md +++ b/doc/manual.md @@ -43,20 +43,21 @@ A widget expression that does not compile is an `error` shot carrying the compil ## Actions -A state that comes from a gesture is shot by acting on the preview before the capture: +A state that comes from a gesture or from typing is shot by acting on the preview before the capture: -* `--tap ` taps the widget: a pointer down and up at its centre. Repeatable; the taps run in the order given. +* `--tap ` taps the widget: a pointer down and up at its centre. Repeatable. +* `--enter =` enters the text into the text field the target is or holds (exactly one `EditableText`), as the platform's keyboard does: the field takes focus and its text is replaced. The target ends at the first `=`. Repeatable; taps and entries run in the order given. * `--press ` holds a pointer down on it through the capture: its pressed state, with the ink the press has drawn by then. * `--hover ` keeps a mouse pointer over it through the capture. * `--focus ` gives it keyboard focus, highlighted as with a keyboard: `flutter_test` runs as a touch device, where focus draws no highlight. The node is the first focus node inside the widget (a button's or text field's own), else the one around it. -* At most one of `--press`, `--hover`, and `--focus`; it follows the taps. +* At most one of `--press`, `--hover`, and `--focus`; it follows the taps and entries. A target is `key:` (a `ValueKey`), `text:` (a `Text` showing exactly that string, or an `EditableText` holding it), or `type:` (a widget of that type, with or without type arguments: `type:Checkbox`, `type:DropdownButton`); offstage widgets do not count. Each action is followed by `--settle` milliseconds drawn in 16 ms frames, as on a device, so `--settle` is also how long after an action the capture comes: an animation the action starts (ink, a check mark, a menu opening) is shot at that point of its course. `flutter test` runs as Android, so Material 3 presses use `InkSparkle`: its sparkle shows from about 100 ms to about 600 ms of a press, then the flat pressed overlay stays, as on a device. The actions apply to every preview of the run. -A preview is an `error` shot, without a PNG and with `at` pointing at the preview, when a target matches no widget or more than one, when a pointer at the target's centre does not reach it (covered, outside the viewport, or ignoring pointers), or when a `--focus` target cannot take focus. +A preview is an `error` shot, without a PNG and with `at` pointing at the preview, when a target matches no widget or more than one, when a pointer at the target's centre does not reach it (covered, outside the viewport, or ignoring pointers), when an `--enter` target holds no text field or more than one, or when a `--focus` target cannot take focus. The run records its actions in `manifest.json`; `shot` prints them, and `diff` prints both runs' when they differ, since they change every image without any widget changing. They are not part of shot ids, so a run with actions pairs shot for shot with one without. @@ -166,7 +167,8 @@ Fonts bundled in the project's assets are used as they are. These come from rendering through `flutter test`. -* One capture per preview, after the actions: scrolling, dragging, and typing are not shot. A sequence of states, such as the course of an animation, is shot as one run per point in time, each with its own `--settle`. +* One capture per preview, after the actions: scrolling and dragging are not shot. A sequence of states, such as the course of an animation, is shot as one run per point in time, each with its own `--settle`. +* No on-screen keyboard: text entered with `--enter` reaches the field, but the keyboard a device would show is not drawn, even with `--capture screen`. * HTTP is blocked, so network images fail to load. * `flutter test` runs the engine with test fonts, which has no system font fallback. Shutter's host fonts are added to the text themes and the default text style, so a text style that sets its own `fontFamilyFallback` does not get them. google_fonts styles do this: glyphs outside the Google font (for example Japanese in a Latin-only font) render as boxes, where a device would fall back to a system font. @@ -231,11 +233,11 @@ Other failures follow sysexits: 64 usage, 66 missing run or file, 69 no Flutter ## Commands -| command | purpose | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `agent` | the step-by-step playbook | -| `manual` | this document | -| `doctor` | SDK version, font cache, shell (`--shell`) | -| `init` | write `shell.dart` (`--shell`) | -| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`) | -| `diff` | compare two runs (`--images`) | +| command | purpose | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agent` | the step-by-step playbook | +| `manual` | this document | +| `doctor` | SDK version, font cache, shell (`--shell`) | +| `init` | write `shell.dart` (`--shell`) | +| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`) | +| `diff` | compare two runs (`--images`) | diff --git a/lib/src/cli/agent_text.dart b/lib/src/cli/agent_text.dart index b0d6759..7538653 100644 --- a/lib/src/cli/agent_text.dart +++ b/lib/src/cli/agent_text.dart @@ -70,16 +70,17 @@ shutter shot --widget 'PrimaryButton(label: "OK")' --import lib/ui/button.dart - * Without `--size` the widget is shot at its own size; `--size` fixes both dimensions and stretches the widget to them. * The same `--widget` and `--import` give the same shot id in every run, so before and after line up in `diff`. -## Pressed, hovered, focused, opened +## Pressed, hovered, focused, opened, typed -A preview shows the state its construction gives. For a state a gesture gives, act on the preview in the shot: +A preview shows the state its construction gives. For a state a gesture or typing gives, act on the preview in the shot: ```bash shutter shot lib/preview/button_preview.dart --press text:Save shutter shot lib/preview/filter_preview.dart --tap 'type:DropdownButton' --capture screen --viewport 390x400 +shutter shot lib/preview/login_preview.dart --enter 'key:email=example@example.com' --tap 'text:Sign in' --settle 700 ``` -* `--tap` (repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, or `type:`. +* `--tap` and `--enter =` (both repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, or `type:`. * The actions apply to every preview of the named files; a preview where the target matches no widget, or several, is an `error` shot. Name the files whose previews have the target, and give the same actions to the before and after shots. * Menus, dialogs, bottom sheets, and tooltips open above the preview and are in the image only with `--capture screen`; `--viewport` gives a small preview the room they open into. * `--settle` (default 300 ms) is how long after the last action the image is taken. A tapped button's ink is still fading at 300 ms and gone by 700 ms: pass `--settle 700` for the state after a tap without it. diff --git a/lib/src/cli/manual_text.dart b/lib/src/cli/manual_text.dart index 0a1cee3..cc9cdef 100644 --- a/lib/src/cli/manual_text.dart +++ b/lib/src/cli/manual_text.dart @@ -46,20 +46,21 @@ A widget expression that does not compile is an `error` shot carrying the compil ## Actions -A state that comes from a gesture is shot by acting on the preview before the capture: +A state that comes from a gesture or from typing is shot by acting on the preview before the capture: -* `--tap ` taps the widget: a pointer down and up at its centre. Repeatable; the taps run in the order given. +* `--tap ` taps the widget: a pointer down and up at its centre. Repeatable. +* `--enter =` enters the text into the text field the target is or holds (exactly one `EditableText`), as the platform's keyboard does: the field takes focus and its text is replaced. The target ends at the first `=`. Repeatable; taps and entries run in the order given. * `--press ` holds a pointer down on it through the capture: its pressed state, with the ink the press has drawn by then. * `--hover ` keeps a mouse pointer over it through the capture. * `--focus ` gives it keyboard focus, highlighted as with a keyboard: `flutter_test` runs as a touch device, where focus draws no highlight. The node is the first focus node inside the widget (a button's or text field's own), else the one around it. -* At most one of `--press`, `--hover`, and `--focus`; it follows the taps. +* At most one of `--press`, `--hover`, and `--focus`; it follows the taps and entries. A target is `key:` (a `ValueKey`), `text:` (a `Text` showing exactly that string, or an `EditableText` holding it), or `type:` (a widget of that type, with or without type arguments: `type:Checkbox`, `type:DropdownButton`); offstage widgets do not count. Each action is followed by `--settle` milliseconds drawn in 16 ms frames, as on a device, so `--settle` is also how long after an action the capture comes: an animation the action starts (ink, a check mark, a menu opening) is shot at that point of its course. `flutter test` runs as Android, so Material 3 presses use `InkSparkle`: its sparkle shows from about 100 ms to about 600 ms of a press, then the flat pressed overlay stays, as on a device. The actions apply to every preview of the run. -A preview is an `error` shot, without a PNG and with `at` pointing at the preview, when a target matches no widget or more than one, when a pointer at the target's centre does not reach it (covered, outside the viewport, or ignoring pointers), or when a `--focus` target cannot take focus. +A preview is an `error` shot, without a PNG and with `at` pointing at the preview, when a target matches no widget or more than one, when a pointer at the target's centre does not reach it (covered, outside the viewport, or ignoring pointers), when an `--enter` target holds no text field or more than one, or when a `--focus` target cannot take focus. The run records its actions in `manifest.json`; `shot` prints them, and `diff` prints both runs' when they differ, since they change every image without any widget changing. They are not part of shot ids, so a run with actions pairs shot for shot with one without. @@ -169,7 +170,8 @@ Fonts bundled in the project's assets are used as they are. These come from rendering through `flutter test`. -* One capture per preview, after the actions: scrolling, dragging, and typing are not shot. A sequence of states, such as the course of an animation, is shot as one run per point in time, each with its own `--settle`. +* One capture per preview, after the actions: scrolling and dragging are not shot. A sequence of states, such as the course of an animation, is shot as one run per point in time, each with its own `--settle`. +* No on-screen keyboard: text entered with `--enter` reaches the field, but the keyboard a device would show is not drawn, even with `--capture screen`. * HTTP is blocked, so network images fail to load. * `flutter test` runs the engine with test fonts, which has no system font fallback. Shutter's host fonts are added to the text themes and the default text style, so a text style that sets its own `fontFamilyFallback` does not get them. google_fonts styles do this: glyphs outside the Google font (for example Japanese in a Latin-only font) render as boxes, where a device would fall back to a system font. @@ -234,12 +236,12 @@ Other failures follow sysexits: 64 usage, 66 missing run or file, 69 no Flutter ## Commands -| command | purpose | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `agent` | the step-by-step playbook | -| `manual` | this document | -| `doctor` | SDK version, font cache, shell (`--shell`) | -| `init` | write `shell.dart` (`--shell`) | -| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`) | -| `diff` | compare two runs (`--images`) | +| command | purpose | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `agent` | the step-by-step playbook | +| `manual` | this document | +| `doctor` | SDK version, font cache, shell (`--shell`) | +| `init` | write `shell.dart` (`--shell`) | +| `shot` | render the named preview files, or one `--widget`, into a new run (`--widget`/`--import`/`--size`, `--settle`, `--shell`, `--tap`/`--enter`/`--press`/`--hover`/`--focus`, `--capture`/`--viewport`) | +| `diff` | compare two runs (`--images`) | '''; diff --git a/lib/src/cli/shot_command.dart b/lib/src/cli/shot_command.dart index d6698cf..6321735 100644 --- a/lib/src/cli/shot_command.dart +++ b/lib/src/cli/shot_command.dart @@ -45,7 +45,15 @@ class ShotCommand(final ShutterContext context) extends Command { 'tap', help: 'Tap this widget of every shot before the capture: ' - '$targetHelp. Repeatable, in order.', + '$targetHelp. Repeatable; taps and --enter run in the order ' + 'given.', + splitCommas: false, + ) + ..addMultiOption( + 'enter', + help: + 'Enter text into this text field, as =, e.g. ' + 'key:name=Koji. Repeatable.', splitCommas: false, ) // Multi-options, so that a second --press is rejected rather than diff --git a/lib/src/cli/shot_options.dart b/lib/src/cli/shot_options.dart index c1b1997..554fc3c 100644 --- a/lib/src/cli/shot_options.dart +++ b/lib/src/cli/shot_options.dart @@ -53,8 +53,9 @@ int parseCount(ArgResults results, String name) { ); } -/// The actions of `shot`: every `--tap` in order, then the one `--press`, -/// `--hover`, or `--focus`, whose state lasts through the capture. +/// The actions of `shot`: every `--tap` and `--enter` in the order given, +/// then the one `--press`, `--hover`, or `--focus`, whose state lasts +/// through the capture. List parseActions(ArgResults results) { final held = [ for (final kind in const [ @@ -69,25 +70,47 @@ List parseActions(ArgResults results) { 'Give at most one of --press, --hover, and --focus.', ); } + // The parser keeps each option's values in order, but not the order + // between options, which the command line still has. + final steps = { + for (final kind in const [ActionKind.tap, ActionKind.enter]) + kind: [...results.multiOption(kind.name)], + }; return [ - for (final raw in results.multiOption('tap')) _action(ActionKind.tap, raw), + for (final argument in results.arguments.takeWhile((a) => a != '--')) + for (final MapEntry(key: kind, value: values) in steps.entries) + if ((argument == '--${kind.name}' || + argument.startsWith('--${kind.name}=')) && + values.isNotEmpty) + _action(kind, values.removeAt(0)), for (final (kind, raw) in held) _action(kind, raw), ]; } ShotAction _action(ActionKind kind, String raw) { - final colon = raw.indexOf(':'); + var target = raw; + String? text; + if (kind == ActionKind.enter) { + final equals = raw.indexOf('='); + if (equals < 0) { + throw ShutterException.usage( + '--enter must be =, e.g. key:name=Koji (got "$raw").', + ); + } + (target, text) = (raw.substring(0, equals), raw.substring(equals + 1)); + } + final colon = target.indexOf(':'); final by = colon < 0 ? null - : TargetKind.values.asNameMap()[raw.substring(0, colon)]; - final value = raw.substring(colon + 1); + : TargetKind.values.asNameMap()[target.substring(0, colon)]; + final value = target.substring(colon + 1); if (by == null || value.isEmpty) { throw ShutterException.usage( '--${kind.name} must name its widget by key:, text:, or ' 'type: (got "$raw").', ); } - return ShotAction(kind: kind, by: by, value: value); + return ShotAction(kind: kind, by: by, value: value, text: text); } /// Help of the target the action options take. diff --git a/lib/src/engine/harness_text.dart b/lib/src/engine/harness_text.dart index cf63a8f..5db97f4 100644 --- a/lib/src/engine/harness_text.dart +++ b/lib/src/engine/harness_text.dart @@ -48,21 +48,26 @@ class const ShutterConfig({ }); /// What an action does to its target. -enum ShutterActionKind { tap, press, hover, focus } +enum ShutterActionKind { tap, enter, press, hover, focus } /// How an action's target is found. enum ShutterTarget { key, text, type } -/// One `--tap`, `--press`, `--hover`, or `--focus` of the CLI. +/// One `--tap`, `--enter`, `--press`, `--hover`, or `--focus` of the CLI. class const ShutterAction( final ShutterActionKind kind, final ShutterTarget by, /// The key, text, or type name. - final String value, -) { - /// As given on the command line: `tap text:Save`. - String get label => '${kind.name} ${by.name}:$value'; + final String value, { + + /// The text `--enter` types. + final String? text, +}) { + /// As given on the command line: `tap text:Save`, `enter key:name=Koji`. + String get label => text == null + ? '${kind.name} ${by.name}:$value' + : '${kind.name} ${by.name}:$value=$text'; Finder get finder => switch (by) { .key => find.byKey(ValueKey(value)), @@ -450,6 +455,11 @@ Future _act( switch (action.kind) { case .tap: await tester.tapAt(_hitPoint(tester, action, element)); + case .enter: + _textField(action, element); + // Focuses the field and replaces its text, as the platform's + // keyboard does; the test engine draws no keyboard. + await tester.enterText(action.finder, action.text!); case .press: final gesture = await tester.startGesture( _hitPoint(tester, action, element), @@ -521,6 +531,26 @@ Offset _hitPoint(WidgetTester tester, ShutterAction action, Element element) { return point; } +/// Checks that [element] is, or holds, exactly one `EditableText`, which +/// `enterText` types into. +void _textField(ShutterAction action, Element element) { + var fields = element.widget is EditableText ? 1 : 0; + void visit(Element child) { + if (child.widget is EditableText) fields++; + child.visitChildren(visit); + } + + element.visitChildren(visit); + if (fields == 0) { + throw _ActionError('${action.label}: the widget holds no text field'); + } + if (fields > 1) { + throw _ActionError( + '${action.label}: the widget holds $fields text fields; name one', + ); + } +} + /// The focus node of [element]: the first one inside it (a button's or /// text field's own), else the one around it. FocusNode _focusNode(ShutterAction action, Element element) { diff --git a/lib/src/engine/interaction.dart b/lib/src/engine/interaction.dart index 6a15f33..ce0e731 100644 --- a/lib/src/engine/interaction.dart +++ b/lib/src/engine/interaction.dart @@ -5,6 +5,9 @@ enum ActionKind { /// Taps it: a pointer down and up. tap, + /// Enters text into it, as a keyboard would. + enter, + /// Holds a pointer down on it through the capture. press, @@ -27,20 +30,31 @@ enum TargetKind { type, } -/// One `shot --tap`, `--press`, `--hover`, or `--focus`. +/// One `shot --tap`, `--enter`, `--press`, `--hover`, or `--focus`. class const ShotAction({ required final ActionKind kind, required final TargetKind by, /// The key, text, or type name. required final String value, + + /// The text `--enter` types. + final String? text, }) { /// As given on the command line, and recorded in the manifest: - /// `tap text:Save`. - String get label => '${kind.name} ${by.name}:$value'; + /// `tap text:Save`, `enter key:name=Koji`. + String get label => switch (text) { + final text? => '${kind.name} ${by.name}:$value=$text', + null => '${kind.name} ${by.name}:$value', + }; /// The harness's `ShutterAction` for this action, as Dart source. - String get source => + String get source => switch (text) { + final text? => + '\$shutter.ShutterAction(.${kind.name}, .${by.name}, ' + '${dartString(value)}, text: ${dartString(text)})', + null => '\$shutter.ShutterAction(.${kind.name}, .${by.name}, ' - '${dartString(value)})'; + '${dartString(value)})', + }; } diff --git a/skills/shutter-visual-check/SKILL.md b/skills/shutter-visual-check/SKILL.md index 3eac938..ec3b010 100644 --- a/skills/shutter-visual-check/SKILL.md +++ b/skills/shutter-visual-check/SKILL.md @@ -1,6 +1,6 @@ --- name: shutter-visual-check -description: Render Flutter widgets to PNG and compare before / after with the shutter CLI. Use when a change affects how a Flutter widget or screen looks (layout, padding, text, colour, theme, new screen or state, including a pressed, hovered, focused, or opened (menu, dialog) state), to check the result by image instead of by "it compiles" or "tests pass". +description: Render Flutter widgets to PNG and compare before / after with the shutter CLI. Use when a change affects how a Flutter widget or screen looks (layout, padding, text, colour, theme, new screen or state, including a pressed, hovered, focused, opened (menu, dialog), or typed-into state), to check the result by image instead of by "it compiles" or "tests pass". --- # shutter: close a visual change with an image @@ -16,5 +16,5 @@ shutter shot ... # after shutter diff latest~1 latest # the last two runs ``` -* For a state a gesture gives, add `--tap`, `--press`, `--hover`, or `--focus` to both shots, and `--capture screen` for what opens above the widget; `shutter agent` has the details. +* For a state a gesture or typing gives, add `--tap`, `--enter`, `--press`, `--hover`, or `--focus` to both shots, and `--capture screen` for what opens above the widget; `shutter agent` has the details. * Open the PNG paths `diff` prints and check them against the intended change. Deciding whether the change is right is yours; shutter makes no judgement. diff --git a/test/cli/shot_command_test.dart b/test/cli/shot_command_test.dart index 8bb5ce3..a78a790 100644 --- a/test/cli/shot_command_test.dart +++ b/test/cli/shot_command_test.dart @@ -246,8 +246,11 @@ void main() { 'text:Open, then close', '--press', 'key:save', + '--enter=key:name=Koji=K', '--tap', 'type:DropdownButton', + '--enter', + 'type:TextField=', '--capture', 'screen', '--viewport', @@ -258,19 +261,23 @@ void main() { expect( [ for (final action in request.actions) - (action.kind, action.by, action.value), + (action.kind, action.by, action.value, action.text), ], [ - (ActionKind.tap, TargetKind.text, 'Open, then close'), - (ActionKind.tap, TargetKind.type, 'DropdownButton'), - (ActionKind.press, TargetKind.key, 'save'), + (ActionKind.tap, TargetKind.text, 'Open, then close', null), + (ActionKind.enter, TargetKind.key, 'name', 'Koji=K'), + (ActionKind.tap, TargetKind.type, 'DropdownButton', null), + (ActionKind.enter, TargetKind.type, 'TextField', ''), + (ActionKind.press, TargetKind.key, 'save', null), ], ); expect((request.screen, request.viewport), (true, (390.0, 844.0))); final report = loadYaml(result.stdout) as YamlMap; const labels = [ 'tap text:Open, then close', + 'enter key:name=Koji=K', 'tap type:DropdownButton', + 'enter type:TextField=', 'press key:save', ]; expect(report['actions'], labels); @@ -349,6 +356,7 @@ void main() { ('--tap', 'Save'), ('--tap', 'label:Save'), ('--tap', 'text:'), + ('--enter', 'name=Koji'), ]) { final bad = await run(['--widget', 'x', option, target]); expect(bad.exitCode, 64, reason: target); @@ -360,6 +368,9 @@ void main() { ), ); } + final noText = await run(['--widget', 'x', '--enter', 'key:name']); + expect(noText.exitCode, 64); + expect(noText.stderr, contains('--enter must be =')); for (final second in ['--focus', '--press']) { final held = await run([ '--widget', diff --git a/test/e2e/e2e_test.dart b/test/e2e/e2e_test.dart index 5c72cbc..7c912b2 100644 --- a/test/e2e/e2e_test.dart +++ b/test/e2e/e2e_test.dart @@ -718,6 +718,84 @@ Widget box() => const ColoredBox(color: Color(0xFF0000FF)); expect((await shutter(root, ['diff', at700, at800])).exitCode, 0); }); + test('--enter types into a field, in order with the taps', () async { + final root = await exampleCopy(); + Future<(String, Map)> shoot(List args) => + shootIn(root, args); + writeFiles(root, { + 'lib/preview/form_preview.dart': ''' +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; + +@Preview(name: 'Form', size: Size(240, 160)) +Widget form() => const Material(child: _Greeting()); + +class _Greeting extends StatefulWidget { + const _Greeting(); + + @override + State<_Greeting> createState() => _GreetingState(); +} + +class _GreetingState extends State<_Greeting> { + final controller = TextEditingController(); + String? shown; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Column( + children: [ + TextField(key: const ValueKey('name'), controller: controller), + TextButton( + onPressed: () => setState( + () => shown = controller.text.isEmpty + ? 'Name is empty' + : 'Hello, \${controller.text}', + ), + child: const Text('Submit'), + ), + if (shown case final shown?) Text(shown), + ], + ); +} +''', + }); + const form = 'lib/preview/form_preview.dart'; + final (blank, _) = await shoot([form]); + final (typed, typedShots) = await shoot([form, '--enter', 'key:name=Koji']); + expect(typedShots['Form']!.status, ShotStatus.ok); + expect((await shutter(root, ['diff', blank, typed])).exitCode, 1); + final (enterFirst, _) = await shoot([ + form, + '--enter', + 'key:name=Koji', + '--tap', + 'text:Submit', + '--settle', + '700', + ]); + final (tapFirst, _) = await shoot([ + form, + '--tap', + 'text:Submit', + '--enter', + 'key:name=Koji', + '--settle', + '700', + ]); + expect((await shutter(root, ['diff', enterFirst, tapFirst])).exitCode, 1); + final (_, noField) = await shoot([form, '--enter', 'text:Submit=x']); + expect( + noField['Form']!.error, + 'enter text:Submit=x: the widget holds no text field', + ); + }); + test('shadowing, throwing, resolved, unpainted, and crashing previews ' 'are reported', () async { final root = await exampleCopy(); diff --git a/test/engine/generator_test.dart b/test/engine/generator_test.dart index 6e5f6b5..71c731b 100644 --- a/test/engine/generator_test.dart +++ b/test/engine/generator_test.dart @@ -131,6 +131,7 @@ void main() { settleMs: 300, actions: const [ ShotAction(kind: .tap, by: .text, value: r"it's $1"), + ShotAction(kind: .enter, by: .key, value: 'name', text: "O'Hara"), ShotAction(kind: .focus, by: .type, value: 'TextField'), ], screen: true, @@ -147,6 +148,8 @@ void main() { contains( ' actions: [\n' " \$shutter.ShutterAction(.tap, .text, 'it\\'s \\\$1'),\n" + " \$shutter.ShutterAction(.enter, .key, 'name', " + "text: 'O\\'Hara'),\n" " \$shutter.ShutterAction(.focus, .type, 'TextField'),\n" ' ],\n' ' screen: true,\n' From f6a60e9f1ab11a9f866eb48703dd965df24036fc Mon Sep 17 00:00:00 2001 From: Koji Wakamiya Date: Wed, 23 Sep 2026 12:07:58 +0900 Subject: [PATCH 2/2] feat(shot): name a target by its semantics label Shooting the example's LoginForm with --enter found no way to name its fields: they have no keys, both are TextFields, and text:Email matches the label's Text, which holds no field. A label: target finds the widget whose semantics label is exactly the string, which is how a text field publishes its labelText or hintText, so label:Email names that field and not a heading or a button beside it. It names buttons by their text too. Semantics are turned on for the shot only when a target uses them. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 ++-- doc/agent.md | 4 ++-- doc/manual.md | 3 ++- lib/src/cli/agent_text.dart | 4 ++-- lib/src/cli/manual_text.dart | 3 ++- lib/src/cli/shot_options.dart | 8 +++++--- lib/src/engine/harness_text.dart | 10 +++++++++- lib/src/engine/interaction.dart | 4 ++++ test/cli/shot_command_test.dart | 12 ++++++------ test/e2e/e2e_test.dart | 22 ++++++++++++++++++++++ test/engine/generator_test.dart | 4 ++-- 11 files changed, 58 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 00ece6c..8e0b28c 100644 --- a/README.md +++ b/README.md @@ -141,10 +141,10 @@ A state that comes from a gesture or typing is shot by acting on the preview fir ```bash shutter shot lib/preview/button_preview.dart --press text:Save shutter shot lib/preview/filter_preview.dart --tap 'type:DropdownButton' --capture screen --viewport 390x400 -shutter shot lib/preview/login_preview.dart --enter 'key:email=example@example.com' --tap 'text:Sign in' --settle 700 +shutter shot lib/preview/login_preview.dart --enter 'label:Email=example@example.com' --tap 'text:Sign in' --settle 700 ``` -`--tap` and `--enter` (repeatable, in the order given), then `--press`, `--hover`, or `--focus`, name their widget by `key:`, `text:`, or `type:`. +`--tap` and `--enter` (repeatable, in the order given), then `--press`, `--hover`, or `--focus`, name their widget by `key:`, `text:`, `label:` (a semantics label, such as a text field's label), or `type:`. Each is followed by `--settle` milliseconds; shoot again with another `--settle` for another point of an animation. `--capture screen` holds the whole viewport, with the menus, dialogs, and tooltips the app draws above the preview. diff --git a/doc/agent.md b/doc/agent.md index ce6acf2..79840a2 100644 --- a/doc/agent.md +++ b/doc/agent.md @@ -74,10 +74,10 @@ A preview shows the state its construction gives. For a state a gesture or typin ```bash shutter shot lib/preview/button_preview.dart --press text:Save shutter shot lib/preview/filter_preview.dart --tap 'type:DropdownButton' --capture screen --viewport 390x400 -shutter shot lib/preview/login_preview.dart --enter 'key:email=example@example.com' --tap 'text:Sign in' --settle 700 +shutter shot lib/preview/login_preview.dart --enter 'label:Email=example@example.com' --tap 'text:Sign in' --settle 700 ``` -* `--tap` and `--enter =` (both repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, or `type:`. +* `--tap` and `--enter =` (both repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, `label:` (a text field's label or hint, a button's text), or `type:`. * The actions apply to every preview of the named files; a preview where the target matches no widget, or several, is an `error` shot. Name the files whose previews have the target, and give the same actions to the before and after shots. * Menus, dialogs, bottom sheets, and tooltips open above the preview and are in the image only with `--capture screen`; `--viewport` gives a small preview the room they open into. * `--settle` (default 300 ms) is how long after the last action the image is taken. A tapped button's ink is still fading at 300 ms and gone by 700 ms: pass `--settle 700` for the state after a tap without it. diff --git a/doc/manual.md b/doc/manual.md index 3a0bf6e..b91b834 100644 --- a/doc/manual.md +++ b/doc/manual.md @@ -52,7 +52,8 @@ A state that comes from a gesture or from typing is shot by acting on the previe * `--focus ` gives it keyboard focus, highlighted as with a keyboard: `flutter_test` runs as a touch device, where focus draws no highlight. The node is the first focus node inside the widget (a button's or text field's own), else the one around it. * At most one of `--press`, `--hover`, and `--focus`; it follows the taps and entries. -A target is `key:` (a `ValueKey`), `text:` (a `Text` showing exactly that string, or an `EditableText` holding it), or `type:` (a widget of that type, with or without type arguments: `type:Checkbox`, `type:DropdownButton`); offstage widgets do not count. +A target is `key:` (a `ValueKey`), `text:` (a `Text` showing exactly that string, or an `EditableText` holding it), `label:` (a widget whose semantics label is exactly that string: a text field's `labelText` or `hintText`, a button's text), or `type:` (a widget of that type, with or without type arguments: `type:Checkbox`, `type:DropdownButton`); offstage widgets do not count. +`label:` names a text field without a key: `--enter 'label:Email=example@example.com'`. Each action is followed by `--settle` milliseconds drawn in 16 ms frames, as on a device, so `--settle` is also how long after an action the capture comes: an animation the action starts (ink, a check mark, a menu opening) is shot at that point of its course. `flutter test` runs as Android, so Material 3 presses use `InkSparkle`: its sparkle shows from about 100 ms to about 600 ms of a press, then the flat pressed overlay stays, as on a device. diff --git a/lib/src/cli/agent_text.dart b/lib/src/cli/agent_text.dart index 7538653..2961bc3 100644 --- a/lib/src/cli/agent_text.dart +++ b/lib/src/cli/agent_text.dart @@ -77,10 +77,10 @@ A preview shows the state its construction gives. For a state a gesture or typin ```bash shutter shot lib/preview/button_preview.dart --press text:Save shutter shot lib/preview/filter_preview.dart --tap 'type:DropdownButton' --capture screen --viewport 390x400 -shutter shot lib/preview/login_preview.dart --enter 'key:email=example@example.com' --tap 'text:Sign in' --settle 700 +shutter shot lib/preview/login_preview.dart --enter 'label:Email=example@example.com' --tap 'text:Sign in' --settle 700 ``` -* `--tap` and `--enter =` (both repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, or `type:`. +* `--tap` and `--enter =` (both repeatable, run in the order given), then at most one of `--press`, `--hover`, `--focus`. A target is `key:>`, `text:`, `label:` (a text field's label or hint, a button's text), or `type:`. * The actions apply to every preview of the named files; a preview where the target matches no widget, or several, is an `error` shot. Name the files whose previews have the target, and give the same actions to the before and after shots. * Menus, dialogs, bottom sheets, and tooltips open above the preview and are in the image only with `--capture screen`; `--viewport` gives a small preview the room they open into. * `--settle` (default 300 ms) is how long after the last action the image is taken. A tapped button's ink is still fading at 300 ms and gone by 700 ms: pass `--settle 700` for the state after a tap without it. diff --git a/lib/src/cli/manual_text.dart b/lib/src/cli/manual_text.dart index cc9cdef..1170d60 100644 --- a/lib/src/cli/manual_text.dart +++ b/lib/src/cli/manual_text.dart @@ -55,7 +55,8 @@ A state that comes from a gesture or from typing is shot by acting on the previe * `--focus ` gives it keyboard focus, highlighted as with a keyboard: `flutter_test` runs as a touch device, where focus draws no highlight. The node is the first focus node inside the widget (a button's or text field's own), else the one around it. * At most one of `--press`, `--hover`, and `--focus`; it follows the taps and entries. -A target is `key:` (a `ValueKey`), `text:` (a `Text` showing exactly that string, or an `EditableText` holding it), or `type:` (a widget of that type, with or without type arguments: `type:Checkbox`, `type:DropdownButton`); offstage widgets do not count. +A target is `key:` (a `ValueKey`), `text:` (a `Text` showing exactly that string, or an `EditableText` holding it), `label:` (a widget whose semantics label is exactly that string: a text field's `labelText` or `hintText`, a button's text), or `type:` (a widget of that type, with or without type arguments: `type:Checkbox`, `type:DropdownButton`); offstage widgets do not count. +`label:` names a text field without a key: `--enter 'label:Email=example@example.com'`. Each action is followed by `--settle` milliseconds drawn in 16 ms frames, as on a device, so `--settle` is also how long after an action the capture comes: an animation the action starts (ink, a check mark, a menu opening) is shot at that point of its course. `flutter test` runs as Android, so Material 3 presses use `InkSparkle`: its sparkle shows from about 100 ms to about 600 ms of a press, then the flat pressed overlay stays, as on a device. diff --git a/lib/src/cli/shot_options.dart b/lib/src/cli/shot_options.dart index 554fc3c..295f95f 100644 --- a/lib/src/cli/shot_options.dart +++ b/lib/src/cli/shot_options.dart @@ -106,15 +106,17 @@ ShotAction _action(ActionKind kind, String raw) { final value = target.substring(colon + 1); if (by == null || value.isEmpty) { throw ShutterException.usage( - '--${kind.name} must name its widget by key:, text:, or ' - 'type: (got "$raw").', + '--${kind.name} must name its widget by key:, text:, ' + 'label: