diff --git a/README.md b/README.md index 0e9f66e..cbd2ece 100644 --- a/README.md +++ b/README.md @@ -136,16 +136,25 @@ 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: + +```bash +shutter shot lib/preview/button_preview.dart --press text:Save +``` + +`--tap` (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. + ## 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`). | -| `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`/`--press`/`--hover`/`--focus`). | +| `diff ` | Compare two runs (`--images`). | ## Exit codes @@ -159,7 +168,7 @@ The same `--widget` and `--import` give the same shot id, so two such runs line ## Limits -* One frame, no interaction: taps, hovers, scrolling, and mid-animation states are not shot. State comes from the widget's construction expression. +* 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. * 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 f03b0c8..ee56c42 100644 --- a/doc/agent.md +++ b/doc/agent.md @@ -67,6 +67,21 @@ 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 + +A preview shows the state its construction gives. For a state a gesture gives, act on the preview in the shot: + +```bash +shutter shot lib/preview/button_preview.dart --press text:Save +``` + +* `--tap` (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. +* `--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. +* A page a tap navigates to is shot through its own preview, not through the tap. +* An animation is shot point by point: shoot again with another `--settle`. The clock is simulated, so the same `--settle` gives the same image, and `diff` pairs a shot across the runs. +* Ink lands on the nearest `Material`, like the background above: an `InkWell` or `ListTile` wrapped in `Material` in the preview shows its press, hover, or focus; buttons carry their own. + ## Exit codes * `shot`: 0 all ok, 2 any error. diff --git a/doc/manual.md b/doc/manual.md index e6f7225..0ea5ae1 100644 --- a/doc/manual.md +++ b/doc/manual.md @@ -41,10 +41,35 @@ The shot id hashes the expression and the resolved imports, so the same `--widge A widget expression that does not compile is an `error` shot carrying the compiler message; it has no `file` or `at`. `--widget` and preview files cannot be combined in one shot. +## Actions + +A state that comes from a gesture 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. +* `--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. + +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. + +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. + +### Taps that navigate + +A page is shot through a preview of its own: returned from a preview function, it is shot from its first frame, with an id of its own. +A tap that pushes a page covers the preview, which is then no longer painted: the shot is an `error` saying so. + ## Drawing model -The engine renders each preview in a `flutter_test` binding, one frame, no interaction. -State comes from the widget's construction expression; a widget that fetches or builds state internally can only be shot in the state it reaches on its own. +The engine renders each preview in a `flutter_test` binding: one frame, then the run's actions, then the capture. +State comes from the widget's construction expression or from the actions; a widget that fetches or builds state internally can only be shot in the state it reaches on its own. Per preview, from the outside in: @@ -56,6 +81,7 @@ Per preview, from the outside in: The PNG holds what is painted inside the captured region, and nothing outside it: the shell's surface lies outside, so where the preview paints no background the PNG is transparent, and the viewer's own background shows through. A screen with a `Scaffold` paints its own; a widget is shot on no background, since shutter cannot know where the app places it. To shoot a widget on the surface it sits on, paint it inside the preview: a `wrapper`, or a `ColoredBox` or `Material` around the widget in the preview function. +The same holds for ink: a press, hover, or focus is drawn on the nearest `Material` above the widget, which for a widget without its own (an `InkWell`, a `ListTile`) is the shell's surface; a `Material` in the preview brings it into the PNG. The project's shell replaces the default one entirely, so it decides what surrounds every shot: a Material surface (what `shutter init` writes, and what widgets such as `ListTile` or `TextField` need), a `CupertinoApp`, or a `WidgetsApp` with the app's own design system. Shutter adds no design library to a shell without one. @@ -79,7 +105,7 @@ The test engine has no system font fallback, so a glyph missing from the style's Cupertino text names the system font through the families `CupertinoSystemText` and `CupertinoSystemDisplay`, which the test engine does not resolve. On a macOS host they get SF Pro from `/System/Library/Fonts/`, as a macOS app does and as iOS draws; on other hosts, Roboto, the Android system font. Host fonts come from the machine that shoots, so compare runs made on the same machine. -Settling: `Image` widgets are precached, then one `pump(settle)` (`--settle`, default 300 ms). `pumpAndSettle` is never used, because a loading indicator never settles. +Settling: `Image` widgets are precached, then one `pump(settle)` (`--settle`, default 300 ms); after each action, `--settle` ms more in 16 ms frames. `pumpAndSettle` is never used, because a loading indicator never settles. HTTP is blocked by the test binding. A `NetworkImage` fails and the shot is `error`. @@ -131,7 +157,7 @@ Fonts bundled in the project's assets are used as they are. These come from rendering through `flutter test`. -* One frame, no interaction: taps, hovers, scrolling, and mid-animation states are not shot. +* 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`. * 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. @@ -145,7 +171,7 @@ When Flutter ships a capture command in the previewer itself, it replaces v1 wit ## Runs -`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `shell` when a shell file was used, `shots`). +`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `shell` when a shell file was used, `actions` when given, `shots`). `` is the UTC time of the shot (`20260918T101530Z`), suffixed `-2`, `-3`, ... when taken; a hidden `.` file claims the name, so runs started in the same second get distinct ids. `shot` prints the run directory as `run:`; `diff` accepts a run's directory, its id, `latest` for the newest run, or `latest~N` for the run N before it. `latest` counts runs in id order (time, then suffix) and skips a run still being shot, whose `manifest.json` is not written yet. @@ -181,8 +207,8 @@ When the two runs were shot with different shells (path or sha256), `shell` show ## Output `shot` and `diff` print YAML starting with the comment `# shutter ai-report v1`, with absolute paths to open. -`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `summary`, and `shots`, errors first. -`diff` gives `diff` (with `--images`), `before`, `after`, `shell` (when the shells differ), `summary`, and `entries` in the order changed → added → removed → unchanged. +`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `actions` (when given), `summary`, and `shots`, errors first. +`diff` gives `diff` (with `--images`), `before`, `after`, `shell` and `actions` (each when the two runs differ in it), `summary`, and `entries` in the order changed → added → removed → unchanged. ## Exit codes @@ -196,11 +222,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`) | -| `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`/`--press`/`--hover`/`--focus`) | +| `diff` | compare two runs (`--images`) | diff --git a/lib/src/cli/agent_text.dart b/lib/src/cli/agent_text.dart index 67a758b..1b77b26 100644 --- a/lib/src/cli/agent_text.dart +++ b/lib/src/cli/agent_text.dart @@ -70,6 +70,21 @@ 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 + +A preview shows the state its construction gives. For a state a gesture gives, act on the preview in the shot: + +```bash +shutter shot lib/preview/button_preview.dart --press text:Save +``` + +* `--tap` (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. +* `--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. +* A page a tap navigates to is shot through its own preview, not through the tap. +* An animation is shot point by point: shoot again with another `--settle`. The clock is simulated, so the same `--settle` gives the same image, and `diff` pairs a shot across the runs. +* Ink lands on the nearest `Material`, like the background above: an `InkWell` or `ListTile` wrapped in `Material` in the preview shows its press, hover, or focus; buttons carry their own. + ## Exit codes * `shot`: 0 all ok, 2 any error. diff --git a/lib/src/cli/manual_text.dart b/lib/src/cli/manual_text.dart index 2d73a2b..c464bb9 100644 --- a/lib/src/cli/manual_text.dart +++ b/lib/src/cli/manual_text.dart @@ -44,10 +44,35 @@ The shot id hashes the expression and the resolved imports, so the same `--widge A widget expression that does not compile is an `error` shot carrying the compiler message; it has no `file` or `at`. `--widget` and preview files cannot be combined in one shot. +## Actions + +A state that comes from a gesture 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. +* `--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. + +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. + +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. + +### Taps that navigate + +A page is shot through a preview of its own: returned from a preview function, it is shot from its first frame, with an id of its own. +A tap that pushes a page covers the preview, which is then no longer painted: the shot is an `error` saying so. + ## Drawing model -The engine renders each preview in a `flutter_test` binding, one frame, no interaction. -State comes from the widget's construction expression; a widget that fetches or builds state internally can only be shot in the state it reaches on its own. +The engine renders each preview in a `flutter_test` binding: one frame, then the run's actions, then the capture. +State comes from the widget's construction expression or from the actions; a widget that fetches or builds state internally can only be shot in the state it reaches on its own. Per preview, from the outside in: @@ -59,6 +84,7 @@ Per preview, from the outside in: The PNG holds what is painted inside the captured region, and nothing outside it: the shell's surface lies outside, so where the preview paints no background the PNG is transparent, and the viewer's own background shows through. A screen with a `Scaffold` paints its own; a widget is shot on no background, since shutter cannot know where the app places it. To shoot a widget on the surface it sits on, paint it inside the preview: a `wrapper`, or a `ColoredBox` or `Material` around the widget in the preview function. +The same holds for ink: a press, hover, or focus is drawn on the nearest `Material` above the widget, which for a widget without its own (an `InkWell`, a `ListTile`) is the shell's surface; a `Material` in the preview brings it into the PNG. The project's shell replaces the default one entirely, so it decides what surrounds every shot: a Material surface (what `shutter init` writes, and what widgets such as `ListTile` or `TextField` need), a `CupertinoApp`, or a `WidgetsApp` with the app's own design system. Shutter adds no design library to a shell without one. @@ -82,7 +108,7 @@ The test engine has no system font fallback, so a glyph missing from the style's Cupertino text names the system font through the families `CupertinoSystemText` and `CupertinoSystemDisplay`, which the test engine does not resolve. On a macOS host they get SF Pro from `/System/Library/Fonts/`, as a macOS app does and as iOS draws; on other hosts, Roboto, the Android system font. Host fonts come from the machine that shoots, so compare runs made on the same machine. -Settling: `Image` widgets are precached, then one `pump(settle)` (`--settle`, default 300 ms). `pumpAndSettle` is never used, because a loading indicator never settles. +Settling: `Image` widgets are precached, then one `pump(settle)` (`--settle`, default 300 ms); after each action, `--settle` ms more in 16 ms frames. `pumpAndSettle` is never used, because a loading indicator never settles. HTTP is blocked by the test binding. A `NetworkImage` fails and the shot is `error`. @@ -134,7 +160,7 @@ Fonts bundled in the project's assets are used as they are. These come from rendering through `flutter test`. -* One frame, no interaction: taps, hovers, scrolling, and mid-animation states are not shot. +* 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`. * 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. @@ -148,7 +174,7 @@ When Flutter ships a capture command in the previewer itself, it replaces v1 wit ## Runs -`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `shell` when a shell file was used, `shots`). +`.dart_tool/shutter/runs//` holds `.png` per shot and `manifest.json` (`run`, `shell` when a shell file was used, `actions` when given, `shots`). `` is the UTC time of the shot (`20260918T101530Z`), suffixed `-2`, `-3`, ... when taken; a hidden `.` file claims the name, so runs started in the same second get distinct ids. `shot` prints the run directory as `run:`; `diff` accepts a run's directory, its id, `latest` for the newest run, or `latest~N` for the run N before it. `latest` counts runs in id order (time, then suffix) and skips a run still being shot, whose `manifest.json` is not written yet. @@ -184,8 +210,8 @@ When the two runs were shot with different shells (path or sha256), `shell` show ## Output `shot` and `diff` print YAML starting with the comment `# shutter ai-report v1`, with absolute paths to open. -`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `summary`, and `shots`, errors first. -`diff` gives `diff` (with `--images`), `before`, `after`, `shell` (when the shells differ), `summary`, and `entries` in the order changed → added → removed → unchanged. +`shot` gives `run`, `shell` (the shell file with its sha256, or `default`), `actions` (when given), `summary`, and `shots`, errors first. +`diff` gives `diff` (with `--images`), `before`, `after`, `shell` and `actions` (each when the two runs differ in it), `summary`, and `entries` in the order changed → added → removed → unchanged. ## Exit codes @@ -199,12 +225,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`) | -| `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`/`--press`/`--hover`/`--focus`) | +| `diff` | compare two runs (`--images`) | '''; diff --git a/lib/src/cli/shot_command.dart b/lib/src/cli/shot_command.dart index baa9b6c..4ab683c 100644 --- a/lib/src/cli/shot_command.dart +++ b/lib/src/cli/shot_command.dart @@ -35,10 +35,38 @@ class ShotCommand(final ShutterContext context) extends Command { ) ..addOption( 'settle', - help: 'Milliseconds pumped once before capture.', + help: + 'Milliseconds pumped once before capture, and again, in 16 ms ' + 'frames, after each action.', defaultsTo: '300', ) - ..addOption('shell', help: shellHelp); + ..addOption('shell', help: shellHelp) + ..addMultiOption( + 'tap', + help: + 'Tap this widget of every shot before the capture: ' + '$targetHelp. Repeatable, in order.', + splitCommas: false, + ) + // Multi-options, so that a second --press is rejected rather than + // silently replacing the first. + ..addMultiOption( + 'press', + help: 'Hold a pointer down on this widget through the capture.', + splitCommas: false, + ) + ..addMultiOption( + 'hover', + help: 'Keep a mouse pointer over this widget through the capture.', + splitCommas: false, + ) + ..addMultiOption( + 'focus', + help: + 'Give this widget keyboard focus, highlighted as with a ' + 'keyboard.', + splitCommas: false, + ); } @override @@ -63,6 +91,7 @@ class ShotCommand(final ShutterContext context) extends Command { final raw? => parseSize(raw), null => null, }; + final actions = parseActions(args); final files = args.rest; if (source == null && (imports.isNotEmpty || size != null)) { throw ShutterException.usage('--import and --size need --widget.'); @@ -130,12 +159,14 @@ class ShotCommand(final ShutterContext context) extends Command { settleMs: settle, widget: widget, shell: shell, + actions: actions, ), ); final manifest = RunManifest( run: p.basename(runDir), shots: [...shots]..sort(Shot.bySource), shell: shellRecord, + actions: [for (final action in actions) action.label], )..write(runDir); reportShots(manifest, runDir, ShutterIO.stdoutSink); return manifest.exitCode; diff --git a/lib/src/cli/shot_options.dart b/lib/src/cli/shot_options.dart index 74cbf4e..38ae61c 100644 --- a/lib/src/cli/shot_options.dart +++ b/lib/src/cli/shot_options.dart @@ -4,6 +4,7 @@ import 'package:args/args.dart'; import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; +import '../engine/interaction.dart'; import '../engine/widget_shot.dart'; import '../project/project.dart'; import '../run/manifest.dart'; @@ -51,6 +52,46 @@ 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. +List parseActions(ArgResults results) { + final held = [ + for (final kind in const [ + ActionKind.press, + ActionKind.hover, + ActionKind.focus, + ]) + for (final raw in results.multiOption(kind.name)) (kind, raw), + ]; + if (held.length > 1) { + throw ShutterException.usage( + 'Give at most one of --press, --hover, and --focus.', + ); + } + return [ + for (final raw in results.multiOption('tap')) _action(ActionKind.tap, raw), + for (final (kind, raw) in held) _action(kind, raw), + ]; +} + +ShotAction _action(ActionKind kind, String raw) { + final colon = raw.indexOf(':'); + final by = colon < 0 + ? null + : TargetKind.values.asNameMap()[raw.substring(0, colon)]; + final value = raw.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); +} + +/// Help of the target the action options take. +const targetHelp = 'key:>, text:, or type:'; + String _importUri(Project project, String import, String workingDirectory) => import.startsWith('package:') ? import diff --git a/lib/src/diff/diff_engine.dart b/lib/src/diff/diff_engine.dart index e1df2ca..ba44ee9 100644 --- a/lib/src/diff/diff_engine.dart +++ b/lib/src/diff/diff_engine.dart @@ -38,6 +38,8 @@ RunDiff diffRuns(StoredRun before, StoredRun after, {String? imagesDir}) { entries: entries, beforeShell: before.manifest.shell, afterShell: after.manifest.shell, + beforeSetup: before.manifest.setup, + afterSetup: after.manifest.setup, ); } diff --git a/lib/src/diff/run_diff.dart b/lib/src/diff/run_diff.dart index 71161ff..0f9a06e 100644 --- a/lib/src/diff/run_diff.dart +++ b/lib/src/diff/run_diff.dart @@ -38,6 +38,10 @@ class const RunDiff({ /// The shell file of each run, null for the default shell. final ShellFile? beforeShell, final ShellFile? afterShell, + + /// The actions and capture of each run. + final RunSetup beforeSetup = plainSetup, + final RunSetup afterSetup = plainSetup, }) { /// Count per status, every status present. Map get summary => { diff --git a/lib/src/engine/engine.dart b/lib/src/engine/engine.dart index f3e1a9e..1870580 100644 --- a/lib/src/engine/engine.dart +++ b/lib/src/engine/engine.dart @@ -1,6 +1,7 @@ import '../project/project.dart'; import '../run/manifest.dart'; import '../scan/candidate.dart'; +import 'interaction.dart'; import 'widget_shot.dart'; /// What an engine is asked to capture. @@ -19,6 +20,9 @@ class const CaptureRequest({ /// Absolute path of the shell file, or null for the default shell. final String? shell, + + /// Performed on every preview before the capture, in order. + final List actions = const [], }); /// The only swappable layer: turns scanned previews and a `--widget` into diff --git a/lib/src/engine/generator.dart b/lib/src/engine/generator.dart index 52d37be..af0fab3 100644 --- a/lib/src/engine/generator.dart +++ b/lib/src/engine/generator.dart @@ -159,7 +159,15 @@ String mainSource(List helpers, GeneratorConfig config) { ..writeln(' packageName: ${dartString(project.name)},') ..writeln(' materialFontsDir: ${dartString(config.materialFontsDir)},') ..writeln(' settleMs: ${request.settleMs},') - ..writeln(' googleFonts: ${config.googleFonts},') + ..writeln(' googleFonts: ${config.googleFonts},'); + if (request.actions.isNotEmpty) { + buffer.writeln(' actions: ['); + for (final action in request.actions) { + buffer.writeln(' ${action.source},'); + } + buffer.writeln(' ],'); + } + buffer ..writeln(' ),') ..writeln(r' defaultShell: $design.defaultShell,') ..writeln(r' themeFallback: $design.themeFallback,'); diff --git a/lib/src/engine/harness_text.dart b/lib/src/engine/harness_text.dart index 31349eb..97a700f 100644 --- a/lib/src/engine/harness_text.dart +++ b/lib/src/engine/harness_text.dart @@ -16,6 +16,7 @@ import 'dart:io'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widget_previews.dart'; @@ -34,8 +35,44 @@ class const ShutterConfig({ /// The project depends on google_fonts. required final bool googleFonts, + + /// Performed on every preview before the capture, in order. + final List actions = const [], }); +/// What an action does to its target. +enum ShutterActionKind { tap, press, hover, focus } + +/// How an action's target is found. +enum ShutterTarget { key, text, type } + +/// One `--tap`, `--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'; + + Finder get finder => switch (by) { + .key => find.byKey(ValueKey(value)), + .text => find.text(value), + .type => find.byWidgetPredicate((widget) { + final type = '${widget.runtimeType}'; + return type == value || type.split('<').first == value; + }), + }; +} + +/// An action that cannot be performed; the shot is an error. +class _ActionError(final String message) implements Exception { + @override + String toString() => message; +} + /// A google_fonts file cached by the CLI. class const ShutterFont({ /// Family name google_fonts registers (`Lobster_regular`). @@ -213,6 +250,9 @@ Future _capture( 'text_scale_factor': preview.textScaleFactor, }; LocalizationsResolver? resolver; + // What undoes a held action: a pressed or hovering pointer, the focus + // highlight strategy. + final releases = Function()>[]; try { final size = preview.size; final width = size != null && size.width.isFinite ? size.width : null; @@ -279,8 +319,25 @@ Future _capture( await _precacheImages(tester); await tester.pump(Duration(milliseconds: config.settleMs)); - final boundary = key.currentContext?.findRenderObject(); - if (boundary is RenderRepaintBoundary && !boundary.debugNeedsPaint) { + RenderRepaintBoundary? painted() => + switch (key.currentContext?.findRenderObject()) { + final RenderRepaintBoundary region when !region.debugNeedsPaint => + region, + _ => null, + }; + // A preview the shell never paints has nothing to act on. + final shown = painted() != null; + if (shown) await _act(tester, config, releases); + final boundary = painted(); + if (shown && boundary == null) { + firstError ??= { + 'error': 'the preview is no longer painted after the actions (a ' + 'page covers it); shoot that page through its own preview', + ..._entryAt(entry), + }; + } + if (boundary != null) { + final size = boundary.size; final image = boundary.toImageSync(pixelRatio: _pixelRatio); final bytes = await tester.runAsync( () => image.toByteData(format: ui.ImageByteFormat.png), @@ -292,10 +349,7 @@ Future _capture( bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes), ); result['png'] = png; - result['size'] = [ - _round(boundary.size.width), - _round(boundary.size.height), - ]; + result['size'] = [_round(size.width), _round(size.height)]; } } } catch (error, stack) { @@ -308,6 +362,12 @@ Future _capture( debugPrint = previousPrint; debugDisableShadows = previousShadows; } + // Released while the tree they point into is still mounted. + for (final release in releases.reversed) { + try { + await release(); + } catch (_) {} + } try { await tester.pumpWidget(const SizedBox.shrink()); } catch (_) {} @@ -356,6 +416,112 @@ Future _precacheImages(WidgetTester tester) async { await tester.pump(); } +/// Performs the run's actions on the mounted preview, each followed by +/// one `pump(settle)`. What undoes a held action goes to [releases]. +Future _act( + WidgetTester tester, + ShutterConfig config, + List Function()> releases, +) async { + for (final action in config.actions) { + final element = _target(action); + switch (action.kind) { + case .tap: + await tester.tapAt(_hitPoint(tester, action, element)); + case .press: + final gesture = await tester.startGesture( + _hitPoint(tester, action, element), + ); + releases.add(gesture.cancel); + case .hover: + // Added where it hovers, so it passes over no other widget. + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: _hitPoint(tester, action, element)); + releases.add(mouse.removePointer); + case .focus: + final node = _focusNode(action, element); + // flutter_test runs as a touch device, where focus draws no + // highlight; a keyboard would. + final manager = FocusManager.instance; + final previous = manager.highlightStrategy; + manager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + releases.add(() async => manager.highlightStrategy = previous); + node.requestFocus(); + } + await _frames(tester, config.settleMs); + } +} + +/// Frame interval of a 60 Hz display. +const _frame = Duration(milliseconds: 16); + +/// Advances [ms] in frames, as a device draws them. One long pump would +/// draw a single frame, in which an animation an action starts (ink, a +/// check mark, a route) is only beginning, and a timer the action set (a +/// tap's down, after `kPressTimeout`) would start its animation there. +Future _frames(WidgetTester tester, int ms) async { + final total = Duration(milliseconds: ms); + var elapsed = Duration.zero; + do { + final step = total - elapsed < _frame ? total - elapsed : _frame; + await tester.pump(step); + elapsed += step; + } while (elapsed < total); +} + +/// The one widget [action] names. +Element _target(ShutterAction action) { + final found = action.finder.evaluate().toList(); + return switch (found) { + [final element] => element, + [] => throw _ActionError('${action.label}: no widget matches'), + _ => throw _ActionError( + '${action.label}: ${found.length} widgets match; name one', + ), + }; +} + +/// The centre of [element], where a pointer reaches it; as `tap` in +/// flutter_test, but a miss is an error rather than a warning. +Offset _hitPoint(WidgetTester tester, ShutterAction action, Element element) { + final box = element.renderObject; + if (box is! RenderBox || !box.hasSize) { + throw _ActionError('${action.label}: the widget is not laid out'); + } + final point = box.localToGlobal(box.size.center(Offset.zero)); + final hit = tester.hitTestOnBinding(point); + if (!hit.path.any((entry) => entry.target == box)) { + throw _ActionError( + '${action.label}: a pointer at its centre does not reach it ' + '(covered, outside the viewport, or ignoring pointers)', + ); + } + return point; +} + +/// 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) { + final around = Focus.maybeOf(element, createDependency: false); + FocusNode? inside; + void visit(Element child) { + if (inside != null) return; + final node = Focus.maybeOf(child, createDependency: false); + if (node != null && node != around) { + inside = node; + return; + } + child.visitChildren(visit); + } + + element.visitChildren(visit); + final node = inside ?? around; + if (node == null || !node.canRequestFocus) { + throw _ActionError('${action.label}: the widget cannot take focus'); + } + return node; +} + /// The first line of the error, and `at`: the first `lib/` location in its /// report, else the failing `Image`'s, else the preview's own (none for an /// expression). diff --git a/lib/src/engine/interaction.dart b/lib/src/engine/interaction.dart new file mode 100644 index 0000000..6a15f33 --- /dev/null +++ b/lib/src/engine/interaction.dart @@ -0,0 +1,46 @@ +import '../dart_literal.dart'; + +/// What an action does to its target before the capture. +enum ActionKind { + /// Taps it: a pointer down and up. + tap, + + /// Holds a pointer down on it through the capture. + press, + + /// Keeps a mouse pointer over it through the capture. + hover, + + /// Gives it keyboard focus, with the focus highlight a keyboard shows. + focus, +} + +/// How an action's target is found. +enum TargetKind { + /// A `ValueKey`. + key, + + /// A `Text` showing exactly this string, or an `EditableText` holding it. + text, + + /// A widget by its type name, with or without type arguments. + type, +} + +/// One `shot --tap`, `--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, +}) { + /// As given on the command line, and recorded in the manifest: + /// `tap text:Save`. + String get label => '${kind.name} ${by.name}:$value'; + + /// The harness's `ShutterAction` for this action, as Dart source. + String get source => + '\$shutter.ShutterAction(.${kind.name}, .${by.name}, ' + '${dartString(value)})'; +} diff --git a/lib/src/reporters/diff_reporter.dart b/lib/src/reporters/diff_reporter.dart index 75f7c36..b02677f 100644 --- a/lib/src/reporters/diff_reporter.dart +++ b/lib/src/reporters/diff_reporter.dart @@ -27,6 +27,19 @@ void reportDiff(RunDiff diff, String? dir, IOSink sink) { ..writeln(' before: ${formatShell(diff.beforeShell)}') ..writeln(' after: ${formatShell(diff.afterShell)}'); } + // Likewise the actions: pressing a button changes the image without the + // widget changing. + final (before, after) = (diff.beforeSetup, diff.afterSetup); + for (final (key, a, b) in [ + ('actions', formatActions(before.actions), formatActions(after.actions)), + ]) { + if (a != b) { + body + ..writeln('$key:') + ..writeln(' before: $a') + ..writeln(' after: $b'); + } + } body ..writeln('summary: {$summary}') ..writeln(listHeader('entries', diff.entries.length)); diff --git a/lib/src/reporters/format.dart b/lib/src/reporters/format.dart index c218446..85e644c 100644 --- a/lib/src/reporters/format.dart +++ b/lib/src/reporters/format.dart @@ -15,6 +15,17 @@ String formatShell(ShellFile? shell) => switch (shell) { null => 'default', }; +/// `["tap text:Save", "press key:ok"]`. +String formatActions(List actions) => + '[${actions.map(yamlScalar).join(', ')}]'; + +/// The `actions` line of a run's [setup], only when it has actions. +void writeSetup(StringBuffer body, RunSetup setup) { + if (setup.actions.isNotEmpty) { + body.writeln('actions: ${formatActions(setup.actions)}'); + } +} + /// `: []` or `:`, the list header of a report. String listHeader(String key, int length) => length == 0 ? '$key: []' : '$key:'; diff --git a/lib/src/reporters/shot_reporter.dart b/lib/src/reporters/shot_reporter.dart index d956d94..1049a68 100644 --- a/lib/src/reporters/shot_reporter.dart +++ b/lib/src/reporters/shot_reporter.dart @@ -20,7 +20,9 @@ void reportShots(RunManifest manifest, String dir, IOSink sink) { final body = StringBuffer() ..writeln('# shutter ai-report v1') ..writeln('run: ${yamlScalar(dir)}') - ..writeln('shell: ${formatShell(manifest.shell)}') + ..writeln('shell: ${formatShell(manifest.shell)}'); + writeSetup(body, manifest.setup); + body ..writeln('summary: {error: ${errors.length}, ok: ${oks.length}}') ..writeln(listHeader('shots', errors.length + oks.length)); for (final shot in [...errors, ...oks]) { diff --git a/lib/src/run/manifest.dart b/lib/src/run/manifest.dart index e9ca2e9..27ffc04 100644 --- a/lib/src/run/manifest.dart +++ b/lib/src/run/manifest.dart @@ -108,6 +108,13 @@ class const Shot({ /// absolute outside the project) and the sha256 of its bytes. typedef ShellFile = ({String path, String sha256}); +/// What a run did to every preview besides rendering it: the actions +/// performed before the capture. +typedef RunSetup = ({List actions}); + +/// The setup of a run shot without actions. +const RunSetup plainSetup = (actions: []); + /// `manifest.json` of one run directory. class const RunManifest({ /// Run id (`YYYYMMDDTHHMMSSZ`, suffixed on collision). @@ -116,6 +123,10 @@ class const RunManifest({ /// The shell file, or null for the default shell. final ShellFile? shell, + + /// The actions performed on every preview before the capture, as given + /// (`tap text:Save`). + final List actions = const [], }) { factory RunManifest.fromJson(Map json) => RunManifest( run: json['run'] as String, @@ -130,6 +141,7 @@ class const RunManifest({ ), _ => null, }, + actions: [...?(json['actions'] as List?)?.cast()], ); /// Reads `/manifest.json`. @@ -138,6 +150,8 @@ class const RunManifest({ as Map, ); + RunSetup get setup => (actions: actions); + /// 0 when every shot is ok, 2 when any is an `error`. int get exitCode => shots.any((s) => s.status == ShotStatus.error) ? 2 : 0; @@ -145,6 +159,7 @@ class const RunManifest({ 'run': run, if (shell case (:final path, :final sha256)?) 'shell': {'path': path, 'sha256': sha256}, + if (actions.isNotEmpty) 'actions': actions, 'shots': [for (final shot in shots) shot.toJson()], }; diff --git a/skills/shutter-visual-check/SKILL.md b/skills/shutter-visual-check/SKILL.md index 0b13ac8..50cc8dd 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), 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, or opened state), to check the result by image instead of by "it compiles" or "tests pass". --- # shutter: close a visual change with an image @@ -16,4 +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; `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 4f81e5c..b6a906b 100644 --- a/test/cli/shot_command_test.dart +++ b/test/cli/shot_command_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:path/path.dart' as p; +import 'package:shutter/src/engine/interaction.dart'; import 'package:shutter/src/engine/widget_shot.dart'; import 'package:shutter/src/run/manifest.dart'; import 'package:test/test.dart'; @@ -233,6 +234,60 @@ void main() { expect(missing.stderr, contains('--shell nope.dart does not exist.')); }); + test('actions: every --tap in order, then the held one; recorded in the ' + 'manifest and the report', () async { + final root = createProject(); + final engine = FakeEngine(const []); + final result = await runCli([ + 'shot', + '--widget', + 'A()', + '--tap', + 'text:Open, then close', + '--press', + 'key:save', + '--tap', + 'type:DropdownButton', + ], fakeContext(root, engine: engine)); + expect(result.exitCode, 0, reason: result.stderr); + final request = engine.requests.single; + expect( + [ + for (final action in request.actions) + (action.kind, action.by, action.value), + ], + [ + (ActionKind.tap, TargetKind.text, 'Open, then close'), + (ActionKind.tap, TargetKind.type, 'DropdownButton'), + (ActionKind.press, TargetKind.key, 'save'), + ], + ); + final report = loadYaml(result.stdout) as YamlMap; + const labels = [ + 'tap text:Open, then close', + 'tap type:DropdownButton', + 'press key:save', + ]; + expect(report['actions'], labels); + final manifest = RunManifest.read(report['run'] as String); + expect(manifest.actions, labels); + + for (final (flag, kind) in [ + ('--hover', ActionKind.hover), + ('--focus', ActionKind.focus), + ]) { + final held = FakeEngine(const []); + await runCli([ + 'shot', + '--widget', + 'A()', + flag, + 'type:TextField', + ], fakeContext(root, engine: held)); + expect(held.requests.single.actions.single.kind, kind); + } + }); + test('a named file without previews is missing input', () async { final root = createProject( resolvable: true, @@ -280,6 +335,33 @@ void main() { expect(bad.exitCode, 64, reason: settle); expect(bad.stderr, contains('--settle must be a whole number')); } + for (final (option, target) in [ + ('--tap', 'Save'), + ('--tap', 'label:Save'), + ('--tap', 'text:'), + ]) { + final bad = await run(['--widget', 'x', option, target]); + expect(bad.exitCode, 64, reason: target); + expect( + bad.stderr, + contains( + '$option must name its widget by key:, text:, or ' + 'type:', + ), + ); + } + for (final second in ['--focus', '--press']) { + final held = await run([ + '--widget', + 'x', + '--press', + 'key:a', + second, + 'key:b', + ]); + expect(held.exitCode, 64, reason: second); + expect(held.stderr, contains('at most one of --press, --hover')); + } final outside = await run(['--widget', 'x', '--import', 'outside.dart']); expect(outside.exitCode, 64); expect(outside.stderr, contains('--import must be under lib/')); diff --git a/test/diff/diff_engine_test.dart b/test/diff/diff_engine_test.dart index 0103595..1e82a61 100644 --- a/test/diff/diff_engine_test.dart +++ b/test/diff/diff_engine_test.dart @@ -32,6 +32,25 @@ Shot ok(String id, {String? file, int? line, (double, double) size = (1, 1)}) => ); void main() { + test("carries each run's shell and setup", () { + final dir = p.join(tempDir(), 'pressed'); + Directory(dir).createSync(); + final pressed = StoredRun( + dir, + const RunManifest( + run: 'pressed', + shots: [], + shell: (path: 'lib/preview/shell.dart', sha256: '1f2e'), + actions: ['press text:OK'], + )..write(dir), + ); + final diff = diffRuns(run('plain', const [], const {}), pressed); + expect(diff.beforeShell, isNull); + expect(diff.afterShell?.path, 'lib/preview/shell.dart'); + expect(diff.beforeSetup.actions, isEmpty); + expect(diff.afterSetup.actions, ['press text:OK']); + }); + test('classifies every id; --images writes diff images', () { final black = png(4, 4); final white = png(4, 4, [255, 255, 255, 255]); diff --git a/test/e2e/e2e_test.dart b/test/e2e/e2e_test.dart index ade1e87..cdcf549 100644 --- a/test/e2e/e2e_test.dart +++ b/test/e2e/e2e_test.dart @@ -58,6 +58,34 @@ String lastRun(String root) => (Directory( p.join(root, '.dart_tool', 'shutter', 'runs'), ).listSync().map((d) => d.path).toList()..sort()).last; +/// Shoots [args] in [root]: the run directory and its shots by name. +Future<(String, Map)> shootIn( + String root, + List args, +) async { + final result = await shutter(root, ['shot', ...args]); + final run = (loadYaml(result.stdout) as YamlMap)['run'] as String; + return (run, {for (final s in RunManifest.read(run).shots) s.name: s}); +} + +/// A button that pushes a page. +const routePreview = ''' +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; + +@Preview(name: 'Route', size: Size(200, 100)) +Widget route() => Builder( + builder: (context) => ElevatedButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Next page')), + ), + ), + child: const Text('Go'), + ), +); +'''; + void main() { test('shot → edit → shot → diff on the example app', () async { final root = await exampleCopy(); @@ -425,6 +453,170 @@ Widget tile() => const ListTile(title: Text('x')); expect(RunManifest.read(lastRun(root)).shots.single.status, ShotStatus.ok); }); + test( + 'actions change the shot; a target they cannot reach is an error', + () async { + final root = await exampleCopy(); + Future<(String, Map)> shoot(List args) => + shootIn(root, args); + writeFiles(root, { + 'lib/preview/route_preview.dart': routePreview, + 'lib/preview/button_state_preview.dart': ''' +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; + +@Preview(name: 'State / button', size: Size(200, 80)) +Widget button() => Center( + child: ElevatedButton(onPressed: () {}, child: const Text('Save')), +); + +@Preview(name: 'State / two buttons', size: Size(200, 80)) +Widget twoButtons() => Row( + children: [ + TextButton(onPressed: () {}, child: const Text('Save')), + TextButton(onPressed: () {}, child: const Text('Save')), + ], +); +''', + }); + const buttons = 'lib/preview/button_state_preview.dart'; + final (plain, _) = await shoot([buttons]); + for (final action in [ + ['--press', 'text:Save'], + ['--hover', 'type:ElevatedButton'], + ['--focus', 'type:ElevatedButton'], + ]) { + final (run, shots) = await shoot([buttons, ...action]); + final button = shots['State / button']!; + expect(button.status, ShotStatus.ok, reason: '$action ${button.error}'); + expect(button.size, (200.0, 80.0)); + final two = shots['State / two buttons']!; + expect(two.png, isNull); + expect(two.at, startsWith('lib/preview/button_state_preview.dart:')); + final diff = await shutter(root, ['diff', plain, run]); + final report = loadYaml(diff.stdout) as YamlMap; + expect(report['actions'], { + 'before': [], + 'after': ['${action[0].substring(2)} ${action[1]}'], + }); + final entry = (report['entries'] as YamlList) + .cast() + .singleWhere((e) => e['name'] == 'State / button'); + expect(entry['status'], 'changed', reason: '$action'); + } + final (_, missed) = await shoot([buttons, '--tap', 'text:Cancel']); + expect( + missed['State / button']!.error, + 'tap text:Cancel: no widget matches', + ); + expect( + missed['State / two buttons']!.error, + 'tap text:Cancel: no widget matches', + ); + final (_, twice) = await shoot([buttons, '--press', 'text:Save']); + expect( + twice['State / two buttons']!.error, + 'press text:Save: 2 widgets match; name one', + ); + writeFiles(root, { + 'lib/preview/unreachable_preview.dart': ''' +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; + +@Preview(name: 'Ignored', size: Size(200, 80)) +Widget ignored() => IgnorePointer( + child: Center( + child: ElevatedButton(onPressed: () {}, child: const Text('Hidden')), + ), +); + +@Preview(name: 'Plain', size: Size(200, 80)) +Widget plain() => const Text('Plain'); +''', + }); + const unreachable = 'lib/preview/unreachable_preview.dart'; + final (_, ignored) = await shoot([unreachable, '--tap', 'text:Hidden']); + expect( + ignored['Ignored']!.error, + startsWith( + 'tap text:Hidden: a pointer at its centre does not reach it', + ), + ); + expect(ignored['Ignored']!.at, 'lib/preview/unreachable_preview.dart:4'); + final (_, unfocusable) = await shoot([ + unreachable, + '--focus', + 'text:Plain', + ]); + expect( + unfocusable['Plain']!.error, + 'focus text:Plain: the widget cannot take focus', + ); + + // A tap that pushes a page covers the preview. + const route = ['lib/preview/route_preview.dart', '--tap', 'text:Go']; + final (_, covered) = await shoot(route); + expect(covered['Route']!.png, isNull); + expect( + covered['Route']!.error, + startsWith('the preview is no longer painted after the actions'), + ); + + // Taps run in order: the second finds what the first expanded. + writeFiles(root, { + 'lib/preview/tile_state_preview.dart': ''' +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; + +@Preview(name: 'State / tile', size: Size(240, 200)) +Widget tile() => const Material( + child: ExpansionTile(title: Text('More'), children: [_Agree()]), +); + +class _Agree extends StatefulWidget { + const _Agree(); + + @override + State<_Agree> createState() => _AgreeState(); +} + +class _AgreeState extends State<_Agree> { + bool agreed = false; + + @override + Widget build(BuildContext context) => Checkbox( + value: agreed, + onChanged: (value) => setState(() => agreed = value!), + ); +} +''', + }); + const tile = 'lib/preview/tile_state_preview.dart'; + final (expanded, _) = await shoot([tile, '--tap', 'text:More']); + final (checked, checkedShots) = await shoot([ + tile, + '--tap', + 'text:More', + '--tap', + 'type:Checkbox', + ]); + final agreed = checkedShots['State / tile']!; + expect(agreed.status, ShotStatus.ok, reason: agreed.error); + expect((await shutter(root, ['diff', expanded, checked])).exitCode, 1); + final (_, reversed) = await shoot([ + tile, + '--tap', + 'type:Checkbox', + '--tap', + 'text:More', + ]); + expect( + reversed['State / tile']!.error, + 'tap type:Checkbox: no widget matches', + ); + }, + ); + 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 cd0f0ff..557c880 100644 --- a/test/engine/generator_test.dart +++ b/test/engine/generator_test.dart @@ -5,6 +5,7 @@ import 'package:shutter/src/engine/design.dart'; import 'package:shutter/src/engine/engine.dart'; import 'package:shutter/src/engine/generator.dart'; import 'package:shutter/src/engine/harness_text.dart'; +import 'package:shutter/src/engine/interaction.dart'; import 'package:shutter/src/engine/widget_shot.dart'; import 'package:shutter/src/fonts/font_cache.dart'; import 'package:shutter/src/fonts/google_fonts.dart'; @@ -117,6 +118,39 @@ void main() { expect(withFonts, contains("asset: 'Lobster-Regular.ttf',")); }); + test('mainSource passes the actions to the harness; none by default', () { + final project = Project.load(createProject()); + final source = mainSource( + const [], + GeneratorConfig( + request: CaptureRequest( + project: project, + libraries: const [], + runDir: '/runs/r1', + settleMs: 300, + actions: const [ + ShotAction(kind: .tap, by: .text, value: r"it's $1"), + ShotAction(kind: .focus, by: .type, value: 'TextField'), + ], + ), + materialFontsDir: '/sdk/fonts', + design: const DesignSupport(available: [], shell: null), + googleFonts: false, + fonts: const [], + ), + ); + expect( + source, + contains( + ' actions: [\n' + " \$shutter.ShutterAction(.tap, .text, 'it\\'s \\\$1'),\n" + " \$shutter.ShutterAction(.focus, .type, 'TextField'),\n" + ' ],\n', + ), + ); + expect(mainSource(const [], config(project)), isNot(contains('actions:'))); + }); + test('a --widget helper imports unprefixed, escapes the expression in the ' 'name, and ids by expression and imports', () { const sized = WidgetShot( diff --git a/test/reporters/diff_reporter_test.dart b/test/reporters/diff_reporter_test.dart index 86bc67f..9e115c0 100644 --- a/test/reporters/diff_reporter_test.dart +++ b/test/reporters/diff_reporter_test.dart @@ -146,6 +146,30 @@ entries: }); }); + test('the actions of both runs, only when they differ', () async { + Future report(RunSetup before, RunSetup after) async => loadYaml( + await collect( + (sink) => reportDiff( + RunDiff( + before: 'a', + after: 'b', + entries: const [], + beforeSetup: before, + afterSetup: after, + ), + null, + sink, + ), + ), + ) as YamlMap; + const pressed = (actions: ['press text:OK']); + expect((await report(pressed, pressed)).containsKey('actions'), isFalse); + expect((await report(plainSetup, pressed))['actions'], { + 'before': [], + 'after': ['press text:OK'], + }); + }); + test('a change of one pixel in many keeps a nonzero ratio', () async { const one = RunDiff( before: 'a', diff --git a/test/reporters/shot_reporter_test.dart b/test/reporters/shot_reporter_test.dart index 877f0b8..21c0cb3 100644 --- a/test/reporters/shot_reporter_test.dart +++ b/test/reporters/shot_reporter_test.dart @@ -78,6 +78,24 @@ shots: ); }); + test('the actions after the shell', () async { + final text = await render( + const RunManifest( + run: 'r', + shots: [], + actions: ['tap text:Open', 'press key:save'], + ), + ); + expect( + text, + contains( + 'shell: default\n' + 'actions: ["tap text:Open", "press key:save"]\n' + 'summary:', + ), + ); + }); + test('no shots; the shell file with its sha256', () async { final text = await render( const RunManifest( diff --git a/test/run/manifest_test.dart b/test/run/manifest_test.dart index 580de3e..51eec5e 100644 --- a/test/run/manifest_test.dart +++ b/test/run/manifest_test.dart @@ -70,4 +70,19 @@ void main() { expect(back.toJson().keys, ['run', 'shell', 'shots']); expect(RunManifest.fromJson(const {'run': 'r', 'shots': []}).shell, isNull); }); + + test('the actions round-trip; a plain run writes none', () { + final dir = tempDir(); + const RunManifest( + run: 'r', + shots: [], + actions: ['tap text:Open', 'press key:save'], + ).write(dir); + final back = RunManifest.read(dir); + expect(back.setup.actions, ['tap text:Open', 'press key:save']); + expect(back.toJson().keys, ['run', 'actions', 'shots']); + final plain = RunManifest.fromJson(const {'run': 'r', 'shots': []}); + expect(plain.toJson().keys, ['run', 'shots']); + expect(plain.setup.actions, isEmpty); + }); }