v / cmd / tools / vgret.v
562 lines · 505 sloc · 19.27 KB · e2e5cf8db56f3562c7baa735061690be936bdf3e
Raw
1// Copyright (c) 2021 Lars Pontoppidan. All rights reserved.
2// Use of this source code is governed by an MIT license
3// that can be found in the LICENSE file.
4//
5// vgret (V Graphics REgression Tool) aids in generating screenshots of various graphical `gg`
6// based V applications, in a structured directory hierarchy, with the intent of either:
7// * Generate a directory structure of screenshots/images to test against
8// (which, as an example, could later be pushed to a remote git repository)
9// * Test for *visual* differences between two, structurally equal, directories
10//
11// vgret uses features and applications that is currently only available on Linux based distros:
12// idiff - to programmatically find *visual* differences between two images:
13// - Ubuntu: `sudo apt install openimageio-tools`
14// - Arch: `sudo pacman -S openimageio`
15// Xvfb - to start a virtual X server framebuffer:
16// - Ubuntu: `sudo apt install xvfb`
17// - Arch: `sudo pacman -S xorg-server-xvfb`
18//
19// For developers:
20// For a quick overview of the generated images you can use `montage` from imagemagick to generate a "Contact Sheet":
21// montage -verbose -label '%f' -font Helvetica -pointsize 10 -background '#000000' -fill 'gray' -define jpeg:size=200x200 -geometry 200x200+2+2 -auto-orient $(fd -t f . /path/to/vgret/out/dir) /tmp/montage.jpg
22// - Ubuntu: `sudo apt install imagemagick`
23// - Arch: `sudo pacman -S imagemagick`
24//
25// To generate the reference images locally - or for uploading to a remote repo like `gg-regression-images`
26// You can do the following:
27// 1. `export DISPLAY=:99` # Start all graphical apps on DISPLAY 99
28// 2. `Xvfb $DISPLAY -screen 0 1280x1024x24 &` # Starts a virtual X11 screen buffer
29// 3. `v gret -v /tmp/gg-regression-images` # Generate reference images to /tmp/gg-regression-images
30// 4. `v gret -v /tmp/test /tmp/gg-regression-images` # Test if the tests can pass locally by comparing to a fresh imageset
31// 5. Visually check the images (you can get an overview by running the `montage` command above)
32// 6. Upload to GitHub or keep locally for more testing/tweaking
33//
34// It's a known factor that the images generated on a local machine won't match the images generated on a remote machine by 100%.
35// They will most likely differ by a small percentage - the comparison tool can be tweaked to accept these subtle changes,
36// at the expense of slightly more inaccurate test results. For non-animated apps the percentage should be > 0.01.
37// You can emulate or test these inaccuracies to some extend locally by simply running the test from a terminal using
38// your physical X11 session display (Usually DISPLAY=:0).
39//
40// Read more about the options of `idiff` here: https://openimageio.readthedocs.io/en/latest/idiff.html
41//
42import os
43import flag
44import time
45import toml
46
47const tool_name = 'vgret'
48const tool_version = '0.0.2'
49const tool_description = '\n Dump and/or compare rendered frames of graphical apps
50 both external and `gg` based apps is supported.
51
52Examples:
53 Generate screenshots to `/tmp/test`
54 v gret /tmp/test
55 Generate and compare screenshots in `/tmp/src` to existing screenshots in `/tmp/dst`
56 v gret /tmp/src /tmp/dst
57 Compare screenshots in `/tmp/src` to existing screenshots in `/tmp/dst`
58 v gret --compare-only /tmp/src /tmp/dst
59'
60
61const tmp_dir = os.join_path(os.vtmp_dir(), tool_name)
62const runtime_os = os.user_os()
63const v_root = os.real_path(@VMODROOT)
64
65const supported_hosts = ['linux']
66const supported_capture_methods = ['gg_record', 'generic_screenshot']
67// External tool executables
68const v_exe = os.getenv('VEXE')
69const idiff_exe = os.find_abs_path_of_executable('idiff') or { '' }
70
71const embedded_toml = $embed_file('vgret.defaults.toml', .zlib)
72const default_toml = embedded_toml.to_string()
73const empty_toml_array = []toml.Any{}
74const empty_toml_map = map[string]toml.Any{}
75
76struct Config {
77 path string
78mut:
79 apps []AppConfig
80}
81
82struct CompareOptions {
83mut:
84 method string = 'idiff'
85 flags []string
86}
87
88struct CaptureRegion {
89mut:
90 x int
91 y int
92 width int
93 height int
94}
95
96fn (cr CaptureRegion) is_empty() bool {
97 return cr.width == 0 && cr.height == 0
98}
99
100struct CaptureOptions {
101mut:
102 method string = 'gg_record'
103 wait_ms int // used by "generic_screenshot" to wait X milliseconds *after* execution of the app
104 flags []string
105 regions []CaptureRegion
106 env map[string]string
107}
108
109fn (co CaptureOptions) validate() ! {
110 if co.method !in supported_capture_methods {
111 return error('capture method "${co.method}" is not supported. Supported methods are: ${supported_capture_methods}')
112 }
113}
114
115struct AppConfig {
116 compare CompareOptions
117 capture CaptureOptions
118 path string
119 abs_path string
120mut:
121 screenshots_path string
122 screenshots []string
123}
124
125struct Options {
126 verbose bool
127 compare_only bool
128 root_path string
129mut:
130 config Config
131}
132
133fn (opt Options) verbose_execute(cmd string) os.Result {
134 opt.verbose_eprintln('Running `${cmd}`')
135 return os.execute(cmd)
136}
137
138fn (opt Options) verbose_eprintln(msg string) {
139 if opt.verbose {
140 eprintln(msg)
141 }
142}
143
144fn main() {
145 if runtime_os !in supported_hosts {
146 eprintln('${tool_name} is currently only supported on ${supported_hosts} hosts')
147 exit(1)
148 }
149 if os.args.len == 1 {
150 eprintln('Usage: ${tool_name} PATH \n${tool_description}\n${tool_name} -h for more help...')
151 exit(1)
152 }
153
154 mut fp := flag.new_flag_parser(os.args[1..])
155 fp.application(tool_name)
156 fp.version(tool_version)
157 fp.description(tool_description)
158 fp.arguments_description('PATH [PATH]')
159 fp.skip_executable()
160
161 show_help := fp.bool('help', `h`, false, 'Show this help text.')
162
163 // Collect tool options
164 mut opt := Options{
165 verbose: fp.bool('verbose', `v`, false, "Be verbose about the tool's progress.")
166 compare_only: fp.bool('compare-only', `c`, false,
167 "Don't generate screenshots - only compare input directories")
168 root_path: fp.string('root-path', `r`, v_root, 'Root path of the comparison')
169 }
170
171 toml_conf := fp.string('toml-config', `t`, default_toml,
172 'Path or string with TOML configuration')
173 arg_paths := fp.finalize()!
174 if show_help {
175 println(fp.usage())
176 exit(0)
177 }
178
179 if arg_paths.len == 0 {
180 println(fp.usage())
181 println('\nError missing arguments')
182 exit(1)
183 }
184
185 if !os.exists(tmp_dir) {
186 os.mkdir_all(tmp_dir)!
187 }
188
189 opt.config = new_config(opt.root_path, toml_conf) or { panic(err) }
190
191 gen_in_path := arg_paths[0]
192 if arg_paths.len >= 1 {
193 generate_screenshots(mut opt, gen_in_path) or { panic(err) }
194 }
195 if arg_paths.len > 1 {
196 target_path := arg_paths[1]
197 path := opt.config.path
198 all_paths_in_use := [path, gen_in_path, target_path]
199 for path_in_use in all_paths_in_use {
200 if !os.is_dir(path_in_use) {
201 eprintln('`${path_in_use}` is not a directory')
202 exit(1)
203 }
204 }
205 if path == target_path || gen_in_path == target_path || gen_in_path == path {
206 eprintln('Compare paths can not be the same directory `${path}`/`${target_path}`/`${gen_in_path}`')
207 exit(1)
208 }
209 compare_screenshots(opt, gen_in_path, target_path)!
210 }
211}
212
213fn generate_screenshots(mut opt Options, output_path string) ! {
214 path := opt.config.path
215
216 dst_path := output_path.trim_right('/')
217
218 if !os.is_dir(path) {
219 return error('`${path}` is not a directory')
220 }
221
222 for mut app_config in opt.config.apps {
223 file := app_config.path
224 app_path := app_config.abs_path
225
226 mut rel_out_path := ''
227 if os.is_file(app_path) {
228 rel_out_path = os.dir(file)
229 } else {
230 rel_out_path = file
231 }
232
233 if app_config.capture.method == 'gg_record' {
234 opt.verbose_eprintln('Compiling shaders (if needed) for `${file}`')
235 sh_result :=
236 opt.verbose_execute('${os.quoted_path(v_exe)} shader ${os.quoted_path(app_path)}')
237 if sh_result.exit_code != 0 {
238 opt.verbose_eprintln('Skipping shader compile for `${file}` v shader failed with:\n${sh_result.output}')
239 continue
240 }
241 }
242
243 if !os.exists(dst_path) {
244 opt.verbose_eprintln('Creating output path `${dst_path}`')
245 os.mkdir_all(dst_path) or { return error('Failed making directory `${dst_path}`') }
246 }
247
248 screenshot_path := os.join_path(dst_path, rel_out_path)
249 if !os.exists(screenshot_path) {
250 os.mkdir_all(screenshot_path) or {
251 return error('Failed making screenshot path `${screenshot_path}`')
252 }
253 }
254
255 app_config.screenshots_path = screenshot_path
256 app_config.screenshots = take_screenshots(opt, app_config) or {
257 return error('Failed taking screenshots of `${app_path}`:\n${err.msg()}')
258 }
259 }
260}
261
262fn compare_screenshots(opt Options, output_path string, target_path string) ! {
263 mut fails := map[string]string{}
264 mut warns := map[string]string{}
265 for app_config in opt.config.apps {
266 screenshots := app_config.screenshots
267 opt.verbose_eprintln('Comparing ${screenshots.len} screenshots in `${output_path}` with `${target_path}`')
268 for screenshot in screenshots {
269 relative_screenshot := screenshot.all_after(output_path + os.path_separator)
270
271 src := screenshot
272 target := os.join_path(target_path, relative_screenshot)
273 opt.verbose_eprintln('Comparing `${src}` with `${target}` with ${app_config.compare.method}')
274
275 if app_config.compare.method == 'idiff' {
276 if idiff_exe == '' {
277 return error('${tool_name} need the `idiff` tool installed. It can be installed on Ubuntu with `sudo apt install openimageio-tools`')
278 }
279 diff_file := os.join_path(os.vtmp_dir(), os.file_name(src).all_before_last('.') +
280 '.diff.tif')
281 flags := app_config.compare.flags.join(' ')
282 diff_cmd := '${os.quoted_path(idiff_exe)} ${flags} -abs -od -o ${os.quoted_path(diff_file)} -abs ${os.quoted_path(src)} ${os.quoted_path(target)}'
283 result := opt.verbose_execute(diff_cmd)
284 if result.exit_code == 0 {
285 opt.verbose_eprintln('OUTPUT: \n${result.output}')
286 }
287 if result.exit_code != 0 {
288 eprintln('OUTPUT: \n${result.output}')
289 if result.exit_code == 1 {
290 warns[src] = target
291 } else {
292 fails[src] = target
293 }
294 }
295 }
296 }
297 }
298
299 if warns.len > 0 {
300 eprintln('--- WARNINGS ---')
301 eprintln('The following files had warnings when compared to their targets')
302 for warn_src, warn_target in warns {
303 eprintln('${warn_src} ~= ${warn_target}')
304 }
305 }
306 if fails.len > 0 {
307 eprintln('--- ERRORS ---')
308 eprintln('The following files did not match their targets')
309 for fail_src, fail_target in fails {
310 eprintln('${fail_src} != ${fail_target}')
311 }
312 first := fails.keys()[0]
313 fail_copy := os.join_path(os.vtmp_dir(), 'fail.' + first.all_after_last('.'))
314 os.cp(first, fail_copy)!
315 eprintln('First failed file `${first}` is copied to `${fail_copy}`')
316
317 diff_file := os.join_path(os.vtmp_dir(), os.file_name(first).all_before_last('.') +
318 '.diff.tif')
319 diff_copy := os.join_path(os.vtmp_dir(), 'diff.tif')
320 if os.is_file(diff_file) {
321 os.cp(diff_file, diff_copy)!
322 eprintln('First failed diff file `${diff_file}` is copied to `${diff_copy}`')
323 eprintln('Removing alpha channel from ${diff_copy} ...')
324 final_fail_result_file := os.join_path(os.vtmp_dir(), 'diff.png')
325 opt.verbose_execute('convert ${os.quoted_path(diff_copy)} -alpha off ${os.quoted_path(final_fail_result_file)}')
326 eprintln('Final diff file: `${final_fail_result_file}`')
327 }
328 exit(1)
329 }
330}
331
332fn take_screenshots(opt Options, app AppConfig) ![]string {
333 out_path := app.screenshots_path
334 if !opt.compare_only {
335 opt.verbose_eprintln('Taking screenshot(s) of `${app.path}` to `${out_path}`')
336 match app.capture.method {
337 'gg_record' {
338 for k, v in app.capture.env {
339 rv := v.replace('\$OUT_PATH', out_path)
340 opt.verbose_eprintln('Setting ENV `${k}` = ${rv} ...')
341 os.setenv('${k}', rv, true)
342 }
343
344 flags := app.capture.flags.join(' ')
345 result :=
346 opt.verbose_execute('${os.quoted_path(v_exe)} ${flags} -d gg_record run ${os.quoted_path(app.abs_path)}')
347 if result.exit_code != 0 {
348 return error('Failed taking screenshot of `${app.abs_path}`:\n${result.output}')
349 }
350 }
351 'generic_screenshot' {
352 for k, v in app.capture.env {
353 rv := v.replace('\$OUT_PATH', out_path)
354 opt.verbose_eprintln('Setting ENV `${k}` = ${rv} ...')
355 os.setenv('${k}', rv, true)
356 }
357
358 existing_screenshots := get_app_screenshots(out_path, app)!
359
360 flags := app.capture.flags
361
362 if !os.exists(app.abs_path) {
363 return error('Failed starting app `${app.abs_path}`, the path does not exist')
364 }
365 opt.verbose_eprintln('Running ${app.abs_path} ${flags}')
366 mut p_app := os.new_process(app.abs_path)
367 p_app.set_args(flags)
368 p_app.set_redirect_stdio()
369 p_app.run()
370
371 if !p_app.is_alive() {
372 output := p_app.stdout_read() + '\n' + p_app.stderr_read()
373 return error('Failed starting app `${app.abs_path}` (before screenshot):\n${output}')
374 }
375 if app.capture.wait_ms > 0 {
376 opt.verbose_eprintln('Waiting ${app.capture.wait_ms} before capturing')
377 time.sleep(app.capture.wait_ms * time.millisecond)
378 }
379 if !p_app.is_alive() {
380 output := p_app.stdout_slurp() + '\n' + p_app.stderr_slurp()
381 return error('App `${app.abs_path}` exited (${p_app.code}) before a screenshot could be captured:\n${output}')
382 }
383 // Use ImageMagick's `import` tool to take the screenshot
384 out_file := os.join_path(out_path, os.file_name(app.path) +
385 '_screenshot_${existing_screenshots.len:02}.png')
386 result := opt.verbose_execute('import -window root "${out_file}"')
387 if result.exit_code != 0 {
388 p_app.signal_kill()
389 return error('Failed taking screenshot of `${app.abs_path}` to "${out_file}":\n${result.output}')
390 }
391
392 // When using regions the capture is split up into regions.len
393 // And name the output based on each region's properties
394 if app.capture.regions.len > 0 {
395 for region in app.capture.regions {
396 region_id := 'x${region.x}y${region.y}w${region.width}h${region.height}'
397 region_out_file := os.join_path(out_path, os.file_name(app.path) +
398 '_screenshot_${existing_screenshots.len:02}_region_${region_id}.png')
399 // If the region is empty (w, h == 0, 0) infer a full screenshot,
400 // This allows for capturing both regions *and* the complete screen
401 if region.is_empty() {
402 os.cp(out_file, region_out_file) or {
403 return error('Failed copying original screenshot "${out_file}" to region file "${region_out_file}"')
404 }
405 continue
406 }
407 extract_result :=
408 opt.verbose_execute('convert -extract ${region.width}x${region.height}+${region.x}+${region.y} "${out_file}" "${region_out_file}"')
409 if extract_result.exit_code != 0 {
410 p_app.signal_kill()
411 return error('Failed extracting region ${region_id} from screenshot of `${app.abs_path}` to "${region_out_file}":\n${result.output}')
412 }
413 }
414 // When done, remove the original file that was split into regions.
415 opt.verbose_eprintln('Removing "${out_file}" (region mode)')
416 os.rm(out_file) or {
417 return error('Failed removing original screenshot "${out_file}"')
418 }
419 }
420 p_app.signal_kill()
421 }
422 else {
423 return error('Unsupported capture method "${app.capture.method}"')
424 }
425 }
426 }
427 return get_app_screenshots(out_path, app)!
428}
429
430fn get_app_screenshots(path string, app AppConfig) ![]string {
431 mut screenshots := []string{}
432 shots := os.ls(path) or { return error('Failed listing dir `${path}`') }
433 for shot in shots {
434 if shot.starts_with(os.file_name(app.path).all_before_last('.')) {
435 screenshots << os.join_path(path, shot)
436 }
437 }
438 return screenshots
439}
440
441fn new_config(root_path string, toml_config string) !Config {
442 doc := if os.is_file(toml_config) {
443 toml.parse_file(toml_config) or { return error(err.msg()) }
444 } else {
445 toml.parse_text(toml_config) or { return error(err.msg()) }
446 }
447
448 path := os.real_path(root_path).trim_right('/')
449
450 compare_method := doc.value('compare.method').default_to('idiff').string()
451 compare_flags := doc.value('compare.flags').default_to(empty_toml_array).array().as_strings()
452 default_compare := CompareOptions{
453 method: compare_method
454 flags: compare_flags
455 }
456 capture_method := doc.value('capture.method').default_to('gg_record').string()
457 capture_flags := doc.value('capture.flags').default_to(empty_toml_array).array().as_strings()
458 capture_regions_any := doc.value('capture.regions').default_to(empty_toml_array).array()
459 mut capture_regions := []CaptureRegion{}
460 for capture_region_any in capture_regions_any {
461 region := CaptureRegion{
462 x: capture_region_any.value('x').default_to(0).int()
463 y: capture_region_any.value('y').default_to(0).int()
464 width: capture_region_any.value('width').default_to(0).int()
465 height: capture_region_any.value('height').default_to(0).int()
466 }
467 capture_regions << region
468 }
469 capture_wait_ms := doc.value('capture.wait_ms').default_to(0).int()
470 capture_env := doc.value('capture.env').default_to(empty_toml_map).as_map()
471 mut env_map := map[string]string{}
472 for k, v in capture_env {
473 env_map[k] = v.string()
474 }
475 default_capture := CaptureOptions{
476 method: capture_method
477 wait_ms: capture_wait_ms
478 flags: capture_flags
479 regions: capture_regions
480 env: env_map
481 }
482
483 apps_any := doc.value('apps').default_to(empty_toml_array).array()
484 mut apps := []AppConfig{cap: apps_any.len}
485 for app_any in apps_any {
486 rel_path := app_any.value('path').string().trim_right('/')
487
488 // Merge, per app, overwrites
489 mut merged_compare := CompareOptions{}
490 merged_compare.method =
491 app_any.value('compare.method').default_to(default_compare.method).string()
492 merged_compare_flags :=
493 app_any.value('compare.flags').default_to(empty_toml_array).array().as_strings()
494 if merged_compare_flags.len > 0 {
495 merged_compare.flags = merged_compare_flags
496 } else {
497 merged_compare.flags = default_compare.flags
498 }
499
500 mut merged_capture := CaptureOptions{}
501 merged_capture.method =
502 app_any.value('capture.method').default_to(default_capture.method).string()
503 merged_capture_flags :=
504 app_any.value('capture.flags').default_to(empty_toml_array).array().as_strings()
505 if merged_capture_flags.len > 0 {
506 merged_capture.flags = merged_capture_flags
507 } else {
508 merged_capture.flags = default_capture.flags
509 }
510
511 app_capture_regions_any :=
512 app_any.value('capture.regions').default_to(empty_toml_array).array()
513 mut app_capture_regions := []CaptureRegion{}
514 for capture_region_any in app_capture_regions_any {
515 region := CaptureRegion{
516 x: capture_region_any.value('x').default_to(0).int()
517 y: capture_region_any.value('y').default_to(0).int()
518 width: capture_region_any.value('width').default_to(0).int()
519 height: capture_region_any.value('height').default_to(0).int()
520 }
521 app_capture_regions << region
522 }
523 mut merged_capture_regions := []CaptureRegion{}
524 for default_capture_region in default_capture.regions {
525 if default_capture_region !in app_capture_regions {
526 merged_capture_regions << default_capture_region
527 }
528 }
529 for app_capture_region in app_capture_regions {
530 if app_capture_region !in default_capture.regions {
531 merged_capture_regions << app_capture_region
532 }
533 }
534 merged_capture.regions = merged_capture_regions
535
536 merged_capture.wait_ms =
537 app_any.value('capture.wait_ms').default_to(default_capture.wait_ms).int()
538 merge_capture_env := app_any.value('capture.env').default_to(empty_toml_map).as_map()
539 mut merge_env_map := default_capture.env.clone()
540 for k, v in merge_capture_env {
541 merge_env_map[k] = v.string()
542 }
543 for k, v in merge_env_map {
544 merged_capture.env[k] = v
545 }
546
547 merged_capture.validate()!
548
549 app_config := AppConfig{
550 compare: merged_compare
551 capture: merged_capture
552 path: rel_path
553 abs_path: os.join_path(path, rel_path).trim_right('/')
554 }
555 apps << app_config
556 }
557
558 return Config{
559 apps: apps
560 path: path
561 }
562}
563