Skip to content

Commit 539863d

Browse files
committed
Minimal screenshot tool.
1 parent 882aea9 commit 539863d

File tree

4 files changed

+519
-0
lines changed

4 files changed

+519
-0
lines changed

pubspec.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ dev_dependencies:
2727
dart_style: ^3.0.0
2828
lints: ">=5.0.0 <7.0.0"
2929
matcher: ^0.12.15
30+
puppeteer: ^3.18.0
3031
sass: ^1.87.0
32+
shelf: ^1.4.2
33+
shelf_static: ^1.1.3
3134
test: ^1.24.2
3235
test_descriptor: ^2.0.1
3336
test_process: ^2.0.3
@@ -36,3 +39,6 @@ dev_dependencies:
3639

3740
executables:
3841
dartdoc: null
42+
43+
dependency_overrides:
44+
archive: ^4.0.0

test/end2end/dartdoc_test.dart

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:io';
6+
57
import 'package:analyzer/file_system/file_system.dart';
68
import 'package:dartdoc/src/dartdoc.dart' show Dartdoc, DartdocResults;
79
import 'package:dartdoc/src/dartdoc_options.dart';
@@ -15,9 +17,13 @@ import 'package:dartdoc/src/package_config_provider.dart';
1517
import 'package:dartdoc/src/package_meta.dart';
1618
import 'package:dartdoc/src/warnings.dart';
1719
import 'package:path/path.dart' as path;
20+
import 'package:shelf/shelf_io.dart';
21+
import 'package:shelf_static/shelf_static.dart';
1822
import 'package:test/test.dart';
1923

2024
import '../src/utils.dart';
25+
import 'screenshot_utils.dart';
26+
import 'test_browser.dart';
2127

2228
final _resourceProvider = pubPackageMetaProvider.resourceProvider;
2329
final _pathContext = _resourceProvider.pathContext;
@@ -51,6 +57,8 @@ void main() {
5157
// Set up the pub metadata for our test packages.
5258
runPubGet(testPackageToolError.path);
5359
runPubGet(_testSkyEnginePackage.path);
60+
61+
if (isScreenshotDirSet) {}
5462
});
5563

5664
setUp(() async {
@@ -203,6 +211,41 @@ void main() {
203211
expect(level2.exists, isTrue);
204212
expect(level2.readAsStringSync(),
205213
contains('<link rel="canonical" href="$prefix/ex/Apple/m.html">'));
214+
215+
if (isScreenshotDirSet) {
216+
final server = await _setupStaticHttpServer(tempDir.path);
217+
final browser = TestBrowser(origin: 'http://localhost:${server.port}');
218+
final allPaths = Directory(tempDir.path)
219+
.listSync(recursive: true, followLinks: true)
220+
.map((f) => path.relative(f.path, from: tempDir.path))
221+
.where((p) => p.endsWith('.html'))
222+
.toList();
223+
final paths = [
224+
'index.html',
225+
'ex/index.html',
226+
'ex/HtmlInjection-class.html',
227+
'ex/IntSet/sum.html',
228+
];
229+
assert(paths.every(allPaths.contains));
230+
try {
231+
await browser.startBrowser();
232+
final session = await browser.createSession();
233+
await session.withPage<void>(fn: (page) async {
234+
for (final p in paths) {
235+
await page.gotoOrigin('/$p');
236+
final prefix = p.replaceAll('.html', '').replaceAll('.', '-');
237+
await page.takeScreenshots(selector: 'body', prefix: prefix);
238+
}
239+
});
240+
} finally {
241+
await server.close();
242+
await browser.close();
243+
}
244+
}
206245
});
207246
}, timeout: Timeout.factor(12));
208247
}
248+
249+
Future<HttpServer> _setupStaticHttpServer(String path) async {
250+
return await serve(createStaticHandler(path), 'localhost', 0);
251+
}

test/end2end/screenshot_utils.dart

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:io';
6+
7+
import 'package:path/path.dart' as p;
8+
import 'package:puppeteer/puppeteer.dart';
9+
10+
// Default screen with 16:10 ratio.
11+
final desktopDeviceViewport = DeviceViewport(width: 1280, height: 800);
12+
13+
final _screenshotDir = Platform.environment['SCREENSHOT_DIR'];
14+
final isScreenshotDirSet = _screenshotDir != null && _screenshotDir!.isNotEmpty;
15+
16+
// Set this variable to enable screenshot files to be updated with new takes.
17+
// The default is to throw an exception to prevent accidental overrides from
18+
// separate tests.
19+
final _allowScreeshotUpdates = Platform.environment['SCREENSHOT_UPDATE'] == '1';
20+
21+
// Note: The default values are the last, so we don't need reset
22+
// the original values after taking the screenshots.
23+
final _themes = ['dark', 'light'];
24+
final _viewports = {
25+
'mobile': DeviceViewport(width: 400, height: 800),
26+
'tablet': DeviceViewport(width: 768, height: 1024),
27+
'desktop': desktopDeviceViewport,
28+
};
29+
30+
extension ScreenshotPageExt on Page {
31+
Future<void> writeScreenshotToFile(String path) async {
32+
await File(path).writeAsBytes(await screenshot());
33+
}
34+
35+
/// Takes screenshots **if** `SCREENSHOT_DIR` environment variable is set.
36+
///
37+
/// Iterates over viewports and themes, and generates screenshot files with the
38+
/// following pattern:
39+
/// - `SCREENSHOT_DIR/$prefix-desktop-dark.png`
40+
/// - `SCREENSHOT_DIR/$prefix-desktop-light.png`
41+
/// - `SCREENSHOT_DIR/$prefix-mobile-dark.png`
42+
/// - `SCREENSHOT_DIR/$prefix-mobile-light.png`
43+
/// - `SCREENSHOT_DIR/$prefix-tablet-dark.png`
44+
/// - `SCREENSHOT_DIR/$prefix-tablet-light.png`
45+
Future<void> takeScreenshots({
46+
required String selector,
47+
required String prefix,
48+
}) async {
49+
final handle = await $(selector);
50+
await handle.takeScreenshots(prefix);
51+
}
52+
}
53+
54+
extension ScreenshotElementHandleExt on ElementHandle {
55+
/// Takes screenshots **if** `SCREENSHOT_DIR` environment variable is set.
56+
///
57+
/// Iterates over viewports and themes, and generates screenshot files with the
58+
/// following pattern:
59+
/// - `SCREENSHOT_DIR/$prefix-desktop-dark.png`
60+
/// - `SCREENSHOT_DIR/$prefix-desktop-light.png`
61+
/// - `SCREENSHOT_DIR/$prefix-mobile-dark.png`
62+
/// - `SCREENSHOT_DIR/$prefix-mobile-light.png`
63+
/// - `SCREENSHOT_DIR/$prefix-tablet-dark.png`
64+
/// - `SCREENSHOT_DIR/$prefix-tablet-light.png`
65+
Future<void> takeScreenshots(String prefix) async {
66+
final body = await page.$('body');
67+
final bodyClassAttr = (await body
68+
.evaluate<String>('el => el.getAttribute("class")')) as String;
69+
final bodyClasses = [
70+
...bodyClassAttr.split(' '),
71+
'--ongoing-screenshot',
72+
];
73+
74+
for (final vp in _viewports.entries) {
75+
await page.setViewport(vp.value);
76+
77+
for (final theme in _themes) {
78+
final newClasses = [
79+
...bodyClasses.where((c) => !c.endsWith('-theme')),
80+
'$theme-theme',
81+
];
82+
await body.evaluate<String>('(el, v) => el.setAttribute("class", v)',
83+
args: [newClasses.join(' ')]);
84+
85+
// The presence of the element is verified, continue only if screenshots are enabled.
86+
if (!isScreenshotDirSet) continue;
87+
88+
// Arbitrary delay in the hope that potential ongoing updates complete.
89+
await Future<void>.delayed(Duration(milliseconds: 500));
90+
91+
final path = p.join(_screenshotDir!, '$prefix-${vp.key}-$theme.png');
92+
await _writeScreenshotToFile(path);
93+
}
94+
}
95+
96+
// restore the original body class attributes
97+
await body.evaluate<String>('(el, v) => el.setAttribute("class", v)',
98+
args: [bodyClassAttr]);
99+
}
100+
101+
Future<void> _writeScreenshotToFile(String path) async {
102+
final file = File(path);
103+
final exists = file.existsSync();
104+
if (exists && !_allowScreeshotUpdates) {
105+
throw Exception('Screenshot update is detected in: $path');
106+
}
107+
await file.parent.create(recursive: true);
108+
await File(path).writeAsBytes(await screenshot());
109+
}
110+
}

0 commit comments

Comments
 (0)