Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions test/e2e/actions_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
@Tags(['e2e'])
library;

import 'package:shutter/src/run/manifest.dart';
import 'package:test/test.dart';
import 'package:yaml/yaml.dart';

import '../helpers.dart';
import 'e2e_helpers.dart';

void main() {
test('actions change the shot; a target that matches none or two is an '
'error', () async {
final root = await exampleCopy();
Future<(String, Map<String, Shot>)> shoot(List<String> args) =>
shootIn(root, args);
writeFiles(root, {
'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': <Object?>[],
'after': ['${action[0].substring(2)} ${action[1]}'],
});
final entry = (report['entries'] as YamlList).cast<YamlMap>().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',
);
});
}
115 changes: 115 additions & 0 deletions test/e2e/capture_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
@Tags(['e2e'])
library;

import 'dart:io';

import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
import 'package:shutter/src/run/manifest.dart';
import 'package:test/test.dart';

import '../helpers.dart';
import 'e2e_helpers.dart';

/// A dropdown whose menu opens below its 60-high preview.
const menuPreview = '''
import 'package:flutter/material.dart';
import 'package:flutter/widget_previews.dart';

@Preview(name: 'State / menu', size: Size(200, 60))
Widget menu() => Align(
alignment: Alignment.topLeft,
child: DropdownButton<String>(
value: 'a',
items: const [
DropdownMenuItem(value: 'a', child: Text('Alpha')),
DropdownMenuItem(value: 'b', child: Text('Beta')),
],
onChanged: (_) {},
),
);
''';

void main() {
test('--capture screen holds the viewport and what opens above the '
'preview', () async {
final root = await exampleCopy();
Future<(String, Map<String, Shot>)> shoot(List<String> args) =>
shootIn(root, args);
writeFiles(root, {
'lib/preview/menu_state_preview.dart': menuPreview,
'lib/preview/route_preview.dart': routePreview,
'lib/preview/box_preview.dart': '''
import 'package:flutter/widget_previews.dart';
import 'package:flutter/widgets.dart';

@Preview(name: 'Box', size: Size(100, 50))
Widget box() => const ColoredBox(color: Color(0xFF0000FF));
''',
});
// The screen is drawn at the same scale as the preview: a 100x50 box
// at the top left of a 200x100 viewport fills a quarter of the image.
final (boxRun, boxShots) = await shoot([
'lib/preview/box_preview.dart',
'--capture',
'screen',
'--viewport',
'200x100',
]);
final boxImage = img.decodePng(
File(p.join(boxRun, boxShots['Box']!.png!)).readAsBytesSync(),
)!;
expect((boxImage.width, boxImage.height), (400, 200));
bool blue(int x, int y) {
final pixel = boxImage.getPixel(x, y);
return (pixel.r, pixel.g, pixel.b) == (0, 0, 255);
}

expect(blue(199, 99), isTrue);
expect(blue(201, 50), isFalse);
expect(blue(50, 101), isFalse);

// The menu's second item lies below the 60-high preview, in the
// screen only.
const menu = 'lib/preview/menu_state_preview.dart';
final screen = ['--capture', 'screen', '--viewport', '200x240'];
final (closed, closedShots) = await shoot([menu, ...screen]);
expect(closedShots['State / menu']!.size, (200.0, 240.0));
final (open, openShots) = await shoot([
menu,
...screen,
'--tap',
'type:DropdownButton<String>',
]);
final opened = openShots['State / menu']!;
expect(opened.status, ShotStatus.ok, reason: opened.error);
final image = img.decodePng(
File(p.join(open, opened.png!)).readAsBytesSync(),
)!;
expect((image.width, image.height), (400, 480));
final closedImage = img.decodePng(
File(p.join(closed, closedShots['State / menu']!.png!)).readAsBytesSync(),
)!;
var differing = 0;
for (var y = 120; y < 480; y++) {
for (var x = 0; x < 400; x++) {
final (a, b) = (image.getPixel(x, y), closedImage.getPixel(x, y));
if ((a.r, a.g, a.b, a.a) != (b.r, b.g, b.b, b.a)) differing++;
}
}
expect(differing, greaterThan(0));

// A page a tap pushes is in the screen; its transition takes 450 ms,
// still under way at the default 300, over by 700.
const route = ['lib/preview/route_preview.dart', '--tap', 'text:Go'];
Future<String> settled(String ms) async =>
(await shoot([...route, '--capture', 'screen', '--settle', ms])).$1;
final at300 = await settled('300');
final pushed = RunManifest.read(at300).shots.single;
expect(pushed.status, ShotStatus.ok, reason: pushed.error);
expect(pushed.size, (200.0, 100.0));
final (at700, at800) = (await settled('700'), await settled('800'));
expect((await shutter(root, ['diff', at300, at700])).exitCode, 1);
expect((await shutter(root, ['diff', at700, at800])).exitCode, 0);
});
}
71 changes: 71 additions & 0 deletions test/e2e/e2e_helpers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import 'dart:io';

import 'package:path/path.dart' as p;
import 'package:shutter/src/cli/context.dart';
import 'package:shutter/src/project/flutter_sdk.dart';
import 'package:shutter/src/run/manifest.dart';
import 'package:test/test.dart';
import 'package:yaml/yaml.dart';

import '../helpers.dart';

/// Copies `example/` (sources only) into a temporary project and
/// resolves its dependencies.
Future<String> exampleCopy() async {
final root = tempDir();
for (final entity in Directory('example').listSync(recursive: true)) {
final rel = p.relative(entity.path, from: 'example');
if (entity is! File ||
rel.startsWith('.dart_tool') ||
rel.startsWith('build')) {
continue;
}
File(p.join(root, rel))
..createSync(recursive: true)
..writeAsBytesSync(entity.readAsBytesSync());
}
final pubGet = await Process.run(flutter, [
'pub',
'get',
], workingDirectory: root);
expect(pubGet.exitCode, 0, reason: '${pubGet.stdout}${pubGet.stderr}');
return root;
}

final String flutter = FlutterSdk.require(environment: Platform.environment)
.executable();

Future<CliResult> shutter(String root, List<String> args) =>
runCli(args, ShutterContext(workingDirectory: root));

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<String, Shot>)> shootIn(
String root,
List<String> 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<void>(
builder: (_) => const Scaffold(body: Text('Next page')),
),
),
child: const Text('Go'),
),
);
''';
Loading
Loading