Skip to content
2 changes: 1 addition & 1 deletion lib/src/cli/init_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class InitCommand(final ShutterContext context) extends Command<int> {
} else {
File(path)
..createSync(recursive: true)
..writeAsStringSync(shellTemplate(project.dependencies).trimLeft());
..writeAsStringSync(shellTemplate(project.dependencies));
ShutterIO.stdoutSink.writeln('wrote ${project.shown(path)}');
}
return 0;
Expand Down
8 changes: 5 additions & 3 deletions lib/src/cli/shot_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,11 @@ class ShotCommand(final ShutterContext context) extends Command<int> {
run: p.basename(runDir),
shots: [...shots]..sort(Shot.bySource),
shell: shellRecord,
actions: [for (final action in actions) action.label],
screen: screen,
viewport: viewport,
setup: (
actions: [for (final action in actions) action.label],
screen: screen,
viewport: viewport,
),
)..write(runDir);
reportShots(manifest, runDir, ShutterIO.stdoutSink);
return manifest.exitCode;
Expand Down
22 changes: 14 additions & 8 deletions lib/src/engine/flutter_test_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ const _maxFontRounds = 3;
/// `flutter test` exits 79 when a suite registers no tests.
const _noTestsRan = 79;

/// Engine v1: generates a `flutter_test` file under
/// `.dart_tool/shutter/test/`, runs
/// it with `flutter test`, and deletes it.
/// Engine v1: generates a `flutter_test` suite in a per-run directory,
/// `.dart_tool/shutter/test/<run-id>/`, runs it with `flutter test`, and
/// deletes the directory.
class FlutterTestEngine implements Engine {
FlutterTestEngine({
required this.sdk,
Expand All @@ -45,7 +45,12 @@ class FlutterTestEngine implements Engine {
final googleFonts = request.project.packageRoot('google_fonts');
final cache = FontCache(request.project.googleFontsDir, fetch: fetchFont);
final design = DesignSupport.detect(request.project, sdk);
var pass = await _capture(request, design, cache.list());
var pass = await _capture(
request,
design,
cache.list(),
googleFonts: googleFonts != null,
);
if (googleFonts == null) return pass.shots;
final known = <String, GoogleFontFile>{};
final failures = <String, String>{};
Expand Down Expand Up @@ -82,7 +87,7 @@ class FlutterTestEngine implements Engine {

await Future.wait(pending.map(fetch));
if (!added) break;
pass = await _capture(request, design, cache.list());
pass = await _capture(request, design, cache.list(), googleFonts: true);
}
return [
for (final shot in pass.shots)
Expand All @@ -101,8 +106,9 @@ class FlutterTestEngine implements Engine {
Future<({List<Shot> shots, Map<String, Set<String>> missingFonts})> _capture(
CaptureRequest request,
DesignSupport design,
List<CachedFont> fonts,
) async {
List<CachedFont> fonts, {
required bool googleFonts,
}) async {
final project = request.project;
final shots = <Shot>[
for (final library in request.libraries)
Expand All @@ -114,7 +120,7 @@ class FlutterTestEngine implements Engine {
request: request,
materialFontsDir: sdk.materialFontsDir,
design: design,
googleFonts: project.packageRoot('google_fonts') != null,
googleFonts: googleFonts,
fonts: fonts,
),
);
Expand Down
6 changes: 3 additions & 3 deletions lib/src/engine/generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class const GeneratorConfig({
});

/// Name of the helper library shooting [CaptureRequest.widget].
const widgetHelper = 'widget';
const _widgetHelper = 'widget';

/// Writes `.dart_tool/shutter/test/<run-id>/`: the harness, the design library
/// adapters, one helper library per source library (and one for the
Expand All @@ -65,9 +65,9 @@ String writeGeneratedTest(GeneratorConfig config) {
helpers.add('l$i');
}
if (request.widget case final widget?) {
File(p.join(dir, 'sources', '$widgetHelper.dart'))
File(p.join(dir, 'sources', '$_widgetHelper.dart'))
.writeAsStringSync(widget.helperSource());
helpers.add(widgetHelper);
helpers.add(_widgetHelper);
}

final testPath = p.join(dir, 'shutter_test.dart');
Expand Down
21 changes: 10 additions & 11 deletions lib/src/engine/harness_text.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/// Source of `.dart_tool/shutter/test/<run-id>/shutter_harness.dart`, the runtime
/// half of the `flutter_test` engine. It is plain Flutter test code: it
/// depends on `flutter` and `flutter_test` only, so the target project
/// gains no dependency on shutter. The generated `shutter_test.dart` calls [run]
/// with the scanned entries; each preview becomes one `testWidgets` that
/// writes `<run>/<id>.png` and `<run>/.results/<id>.json`.
/// Source of `.dart_tool/shutter/test/<run-id>/shutter_harness.dart`, the
/// runtime half of the `flutter_test` engine. It is plain Flutter test code:
/// it depends on `flutter` and `flutter_test` only, so the target project
/// gains no dependency on shutter. The generated `shutter_test.dart` calls
/// [run] with the scanned entries; each preview becomes one `testWidgets`
/// that writes `<run>/<id>.png` and `<run>/.results/<id>.json`.
///
/// The end-to-end tests under `test/e2e/` compile and run this text
/// against `example/`.
Expand Down Expand Up @@ -196,8 +196,8 @@ Map<String, Object?> _entryFields(
) => {
'id': id,
'name': name,
if (entry.file != null) 'file': entry.file,
if (entry.line != null) 'line': entry.line,
'file': ?entry.file,
'line': ?entry.line,
};

/// `at` pointing at the declaration; none for an expression.
Expand Down Expand Up @@ -258,9 +258,8 @@ Future<void> _capture(
};
final result = <String, Object?>{
..._entryFields(entry, id, name),
if (preview.brightness != null) 'brightness': preview.brightness!.name,
if (preview.textScaleFactor != null)
'text_scale_factor': preview.textScaleFactor,
'brightness': ?preview.brightness?.name,
'text_scale_factor': ?preview.textScaleFactor,
};
LocalizationsResolver? resolver;
// What undoes a held action: a pressed or hovering pointer, the focus
Expand Down
27 changes: 15 additions & 12 deletions lib/src/engine/interaction.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,21 @@ class const ShotAction({
}) {
/// As given on the command line, and recorded in the manifest:
/// `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',
};
String get label {
final text = switch (this.text) {
final text? => '=$text',
null => '',
};
return '${kind.name} ${by.name}:$value$text';
}

/// The harness's `ShutterAction` for this action, as Dart 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)})',
};
String get source {
final text = switch (this.text) {
final text? => ', text: ${dartString(text)}',
null => '',
};
return '\$shutter.ShutterAction(.${kind.name}, .${by.name}, '
'${dartString(value)}$text)';
}
}
50 changes: 23 additions & 27 deletions lib/src/run/manifest.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,8 @@ 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<String> actions = const [],

/// The whole viewport was captured (`--capture screen`).
final bool screen = false,

/// The `--viewport` the shots were taken in.
final (double, double)? viewport,
/// What the run did besides rendering; see [RunSetup].
final RunSetup setup = plainSetup,
}) {
factory RunManifest.fromJson(Map<String, Object?> json) => RunManifest(
run: json['run'] as String,
Expand All @@ -152,12 +145,14 @@ class const RunManifest({
),
_ => null,
},
actions: [...?(json['actions'] as List<Object?>?)?.cast<String>()],
screen: json['capture'] == 'screen',
viewport: switch (json['viewport']) {
[final num w, final num h] => (w.toDouble(), h.toDouble()),
_ => null,
},
setup: (
actions: [...?(json['actions'] as List<Object?>?)?.cast<String>()],
screen: json['capture'] == 'screen',
viewport: switch (json['viewport']) {
[final num w, final num h] => (w.toDouble(), h.toDouble()),
_ => null,
},
),
);

/// Reads `<dir>/manifest.json`.
Expand All @@ -166,21 +161,22 @@ class const RunManifest({
as Map<String, Object?>,
);

RunSetup get setup => (actions: actions, screen: screen, viewport: viewport);

/// 0 when every shot is ok, 2 when any is an `error`.
int get exitCode => shots.any((s) => s.status == ShotStatus.error) ? 2 : 0;

Map<String, Object?> toJson() => {
'run': run,
if (shell case (:final path, :final sha256)?)
'shell': {'path': path, 'sha256': sha256},
if (actions.isNotEmpty) 'actions': actions,
if (screen) 'capture': 'screen',
if (viewport case (final w, final h)?)
'viewport': [jsonNumber(w), jsonNumber(h)],
'shots': [for (final shot in shots) shot.toJson()],
};
Map<String, Object?> toJson() {
final (:actions, :screen, :viewport) = setup;
return {
'run': run,
if (shell case (:final path, :final sha256)?)
'shell': {'path': path, 'sha256': sha256},
if (actions.isNotEmpty) 'actions': actions,
if (screen) 'capture': 'screen',
if (viewport case (final w, final h)?)
'viewport': [jsonNumber(w), jsonNumber(h)],
'shots': [for (final shot in shots) shot.toJson()],
};
}

/// Writes `<dir>/manifest.json` as indented JSON.
void write(String dir) => File(
Expand Down
12 changes: 5 additions & 7 deletions lib/src/scan/candidate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,13 @@ class Candidate({
/// without being compiled.
final String? error,
}) {
/// First 16 hex of `sha256("<file>|<symbol>|<annotationIndex>")`.
String get staticId => shotStaticId(file, symbol, annotationIndex);
/// Static half of a shot id: the first 16 hex of
/// `sha256("<file>|<symbol>|<annotationIndex>")`. The runtime half (the
/// index of the `Preview` produced by `transform()`) is appended after
/// a `.`.
String get staticId => shortHash('$file|$symbol|$annotationIndex');
}

/// Static half of a shot id. The runtime half (the index of the
/// `Preview` produced by `transform()`) is appended after a `.`.
String shotStaticId(String file, String symbol, int annotationIndex) =>
shortHash('$file|$symbol|$annotationIndex');

/// First 16 hex digits of `sha256(input)`.
String shortHash(String input) =>
sha256.convert(utf8.encode(input)).toString().substring(0, 16);
Expand Down
2 changes: 1 addition & 1 deletion test/cli/misc_commands_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ void main() {
expect(first.stdout, 'wrote lib/preview/shell.dart\n');
expect(
File(p.join(root, 'lib', 'preview', 'shell.dart')).readAsStringSync(),
shellTemplate(const {}).trimLeft(),
shellTemplate(const {}),
);
writeFiles(root, {'lib/preview/shell.dart': '// mine'});
final second = await runCli(['init'], fakeContext(root));
Expand Down
5 changes: 3 additions & 2 deletions test/cli/shot_command_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,9 @@ void main() {
expect(report['capture'], 'screen');
expect(report['viewport'], [390, 844]);
final manifest = RunManifest.read(report['run'] as String);
expect(manifest.actions, labels);
expect((manifest.screen, manifest.viewport), (true, (390.0, 844.0)));
final setup = manifest.setup;
expect(setup.actions, labels);
expect((setup.screen, setup.viewport), (true, (390.0, 844.0)));

for (final (flag, kind) in [
('--hover', ActionKind.hover),
Expand Down
4 changes: 1 addition & 3 deletions test/diff/diff_engine_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ void main() {
run: 'pressed',
shots: [],
shell: (path: 'lib/preview/shell.dart', sha256: '1f2e'),
actions: ['press text:OK'],
screen: true,
viewport: (390, 844),
setup: (actions: ['press text:OK'], screen: true, viewport: (390, 844)),
)..write(dir),
);
final diff = diffRuns(run('plain', const [], const {}), pressed);
Expand Down
14 changes: 10 additions & 4 deletions test/reporters/shot_reporter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,11 @@ shots:
const RunManifest(
run: 'r',
shots: [],
actions: ['tap text:Open', 'press key:save'],
screen: true,
viewport: (390, 844),
setup: (
actions: ['tap text:Open', 'press key:save'],
screen: true,
viewport: (390, 844),
),
),
);
expect(
Expand All @@ -99,7 +101,11 @@ shots:
),
);
final screenOnly = await render(
const RunManifest(run: 'r', shots: [], screen: true),
const RunManifest(
run: 'r',
shots: [],
setup: (actions: [], screen: true, viewport: null),
),
);
expect(screenOnly, contains('shell: default\ncapture: screen\nsummary:'));
});
Expand Down
8 changes: 5 additions & 3 deletions test/run/manifest_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,11 @@ void main() {
const RunManifest(
run: 'r',
shots: [],
actions: ['tap text:Open', 'press key:save'],
screen: true,
viewport: (390, 844.5),
setup: (
actions: ['tap text:Open', 'press key:save'],
screen: true,
viewport: (390, 844.5),
),
).write(dir);
final back = RunManifest.read(dir);
final setup = back.setup;
Expand Down
11 changes: 10 additions & 1 deletion test/scan/candidate_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import 'package:test/test.dart';
void main() {
test('static id is the first 16 hex of sha256(file|symbol|index)', () {
// Ids pair shots across runs, so the formula must not drift.
expect(shotStaticId('lib/a.dart', 'a', 0), 'e815cfe43338aced');
final candidate = Candidate(
file: 'lib/a.dart',
line: 1,
column: 1,
symbol: 'a',
annotationIndex: 0,
annotation: '',
target: '',
);
expect(candidate.staticId, 'e815cfe43338aced');
});
}
2 changes: 1 addition & 1 deletion test/scan/scanner_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class Button extends Widget {
);
expect(c.target, r'$i2.a');
expect(c.error, isNull);
expect(c.staticId, shotStaticId('lib/preview/a.dart', 'a', 0));
expect(c.staticId, shortHash('lib/preview/a.dart|a|0'));
});

test('only Flutter\'s Preview and MultiPreview make previews; a '
Expand Down
Loading