22# Copyright 2023 The DevTools Authors. All rights reserved.
33# Use of this source code is governed by a BSD-style license that can be
44# found in the LICENSE file.
5- """This tool aims to assist you with screenshot changes.
6-
7- Whenever your changes impact the screenshots in the Interaction tests you will
8- need to update (or add) those screenshots for all the supported platforms.
9-
10- Assuming that you committed your current changes and uploaded the CL, you will
11- first need to trigger a group of special builders that will try to detect any
12- such screenshot changes. Use:
13- \x1b [32m update_goldens.py trigger \x1b [0m
14- for that.
15-
16- After you wait for those builders to finish you will want to get the proposed
17- changes on your development environment. Simply run:
18- \x1b [32m update_goldens.py update \x1b [0m
19- If there are still builders that did not finish you will be notified and asked
20- to wait a little longer.
21-
22- Finally inspect the screenshot changes that are now present (if any) on your
23- development machine. If they look as expected add, commit and upload. You
24- should not get any additional screenshot changes if you repeat the steps above,
25- provided you did not perform any additional changes in the code.
5+ """The purpose of this CLI tool is to help you manage changes to screenshots in
6+ tests across multiple platforms.
7+
8+ For more information, see test/interactions/README.md.
9+
10+ If you've made changes that impact the screenshots, you'll need to update them
11+ for all supported platforms. Assuming you've committed your changes and
12+ uploaded the CL, you'll need to trigger a dry run in Gerrit or execute the
13+ command:
14+ \x1b [32m git cl try \x1b [0m
15+
16+ After waiting for the dry run to complete, you can execute the command:
17+ \x1b [32m update_goldens.py \x1b [0m
18+
19+ Any failing test will generate updated screenshots, which will be downloaded and
20+ applied to your local change. Inspect the status of your working copy for any
21+ such screenshot updates. If you have new screenshots and they look as expected,
22+ add, commit, and upload the changes. If you repeat the steps above without
23+ making any additional changes to the code, you should not have any more
24+ screenshot tests failing.
2625"""
2726
2827import argparse
2928import json
3029import os
3130import re
32- import tempfile
3331import time
32+ import shutil
33+ import ssl
3434import subprocess
3535import sys
36+ import urllib .request
3637
38+ ssl ._create_default_https_context = ssl ._create_unverified_context
3739
3840class ProjectConfig :
3941 def __init__ (self ,
@@ -81,84 +83,142 @@ def __init__(self,
8183 'applied to your local repo.\n To quickly see what is new just run "git ' \
8284 'status".'
8385
84-
8586def main (project_config , * args ):
8687 parser = build_parser ()
8788 options = parser .parse_args (* args )
88- if not options .command :
89- parser .print_help ()
90- sys .exit (1 )
91- options .func (project_config , options )
89+ update (project_config , options )
9290
9391
9492def build_parser ():
9593 """Parse command line arguments."""
9694 parser = argparse .ArgumentParser (
9795 formatter_class = argparse .RawTextHelpFormatter , epilog = __doc__ )
98- sp = parser .add_subparsers (dest = 'command' )
99-
100- trigger_help = 'Triggers screenshot builders for the current patchset.'
101- trigger_parser = sp .add_parser ('trigger' ,
102- description = trigger_help ,
103- help = trigger_help )
104- trigger_parser .add_argument (
105- '--ignore-triggered' ,
106- action = 'store_true' ,
107- help = 'Ignore any existing results or triggered builders on the '
108- 'current patch.' )
109- trigger_parser .set_defaults (func = trigger )
110-
111- update_help = 'Downloads the screenshots from the builders and applies ' \
112- 'them locally.'
113- update_parser = sp .add_parser ('update' ,
114- description = update_help ,
115- help = update_help )
116- mutually_exclusive = update_parser .add_mutually_exclusive_group ()
117- mutually_exclusive .add_argument (
118- '--patchset' ,
119- help = 'The patchset number from where to download screenshot changes. '
120- 'If not provided it defaults to the latest patchset.' )
121- update_parser .add_argument (
122- '--ignore-failed' ,
123- action = 'store_true' ,
124- help = 'Ignore results comming from failed builders.' )
125- mutually_exclusive .add_argument (
126- '--retry' ,
127- action = 'store_true' ,
128- help = 'Re-trigger failed builders (when dealing with flakes).' )
129- update_parser .add_argument ('--wait-sec' , type = int ,
96+
97+ parser .add_argument ('--wait-sec' , type = int ,
13098 help = 'Wait and retry update every specified number of seconds. ' \
13199 'Minimum value is 30s to avoid overwhelming Gerrit.' )
132- update_parser .set_defaults (func = update )
133-
134- help_help = 'Show help.'
135- help_parser = sp .add_parser ('help' , description = help_help , help = help_help )
136- help_parser .add_argument ('name' ,
137- nargs = '?' ,
138- help = 'Command to show help for' )
139- help_parser .set_defaults (func = get_help (parser , sp ))
140-
100+ parser .set_defaults (func = update )
141101 parser .add_argument ('--verbose' ,
142102 action = 'store_true' ,
143103 help = 'Show more debugging info' )
144104
145- return parser
146-
147-
148- def trigger (project_config , options ):
149- check_results_exist (project_config , options .ignore_triggered )
150- trigger_screenshots (project_config , options .verbose )
105+ #Deprecated options. These are no longer used, but are kept here
106+ #to avoid breaking existing scripts."""
107+ parser .add_argument ('--patchset' ,
108+ help = 'Deprecated. Not used by this tool.' )
109+ parser .add_argument ('--ignore-failed' ,
110+ help = 'Deprecated. Not used by this tool.' )
111+ parser .add_argument ('--retry' , help = 'Deprecated. Not used by this tool.' )
151112
113+ return parser
152114
153115def update (project_config , options ):
154116 test_clean_git ()
155- test_gcs_connectivity (project_config )
156117 wait_sec = options .wait_sec
157118 if wait_sec :
158119 wait_sec = max (wait_sec , 30 )
159- apply_patch_to_local (project_config , options .patchset , wait_sec ,
160- options .ignore_failed , options .retry , options .verbose )
120+ query_rdb_for_screenshots (project_config , options .patchset , wait_sec ,
121+ options .ignore_failed , options .retry ,
122+ options .verbose )
123+
124+
125+ def query_rdb_for_screenshots (project_config , patchset , wait_sec ,
126+ ignore_failed , retry , verbose ):
127+ """Download and apply the patches from the builders."""
128+ results = screenshot_results (project_config , patchset )
129+ check_not_empty (results )
130+ check_all_platforms (project_config , results )
131+ retry , should_wait = check_all_success (project_config , results , wait_sec ,
132+ ignore_failed , retry )
133+ if retry :
134+ # Avoiding to force the user to run a 'trigger' command
135+ trigger_screenshots (project_config , retry )
136+ sys .exit (0 )
137+ if not should_wait :
138+ download_generated_imgs (results )
139+ print (INFO_PATCHES_APPLIED )
140+ sys .exit (0 )
141+ print (f'Waiting { wait_sec } seconds ...' )
142+ time .sleep (wait_sec )
143+ query_rdb_for_screenshots (project_config , patchset , wait_sec ,
144+ ignore_failed , retry , verbose )
145+
146+
147+ def download_generated_imgs (try_results ):
148+ for _ , try_result in try_results .items ():
149+ if try_result ['status' ] == 'FAILURE' :
150+ unexpected_results = get_unexpected_results (try_result ['id' ])
151+ for result in unexpected_results ['testResults' ]:
152+ result = get_result_with_tags (result ['name' ])
153+ screenshot_path = get_screenshot_path (result )
154+ if not screenshot_path :
155+ continue
156+ artifacts = list_artifacts (result ['name' ])
157+ for artifact in artifacts ['artifacts' ]:
158+ if artifact ['artifactId' ] in ['actual_image' , 'generated' ]:
159+ download_individual_screenshot (screenshot_path ,
160+ artifact ['fetchUrl' ])
161+
162+
163+ def get_unexpected_results (invocation_suffix ):
164+ invocation_id = f'build-{ invocation_suffix } '
165+ return _rdb_rpc (
166+ 'QueryTestResults' , {
167+ "invocations" : [f'invocations/{ invocation_id } ' ],
168+ "predicate" : {
169+ "excludeExonerated" : True ,
170+ "expectancy" : "VARIANTS_WITH_ONLY_UNEXPECTED_RESULTS" ,
171+ }
172+ })
173+
174+
175+ def get_result_with_tags (name ):
176+ return _rdb_rpc ('GetTestResult' , {"name" : name })
177+
178+
179+ def list_artifacts (name ):
180+ return _rdb_rpc ('ListArtifacts' , {
181+ "parent" : name ,
182+ })
183+
184+
185+ def download_individual_screenshot (screenshot_path , fetchUrl ):
186+ with urllib .request .urlopen (fetchUrl ) as response :
187+ os .makedirs (os .path .dirname (screenshot_path ), exist_ok = True )
188+ with open (screenshot_path , 'w+b' ) as screenshot_file :
189+ shutil .copyfileobj (response , screenshot_file )
190+
191+
192+ def _rdb_rpc (service , request_payload ):
193+ results_command = ['rdb' , 'rpc' , 'luci.resultdb.v1.ResultDB' ]
194+ results_command .append (service )
195+ p = subprocess .Popen (results_command ,
196+ stdin = subprocess .PIPE ,
197+ stdout = subprocess .PIPE ,
198+ stderr = subprocess .PIPE ,
199+ text = True )
200+
201+ stdout , stderr = p .communicate (json .dumps (request_payload ))
202+ if p .returncode != 0 :
203+ # rdb doesn't return unique status codes for different errors, so we have to
204+ # just match on the output.
205+ if 'interactive login is required' in stderr :
206+ print ("Authentication is required to fetch test metadata.\n " +
207+ "Please run:\n \t rdb auth-login\n and try again" )
208+ else :
209+ print (f'rdb rpc { service } failed with: { stderr } ' )
210+ sys .exit (1 )
161211
212+ return json .loads (stdout )
213+
214+
215+ def get_screenshot_path (individual_result ):
216+ for tag in individual_result ['tags' ]:
217+ if tag ['key' ] == 'run_phase' and tag ['value' ] != 'default' :
218+ return None
219+ if tag ['key' ] == 'screenshot_path' :
220+ return tag ['value' ].replace ('\\ ' , '/' )
221+ return None
162222
163223def get_help (parser , subparsers ):
164224 def _help (options ):
@@ -203,43 +263,6 @@ def test_clean_git():
203263 sys .exit (0 )
204264
205265
206- def test_gcs_connectivity (project_config ):
207- """Test if gcloud needs to be configured for current user."""
208- process = subprocess .Popen (gcstorage_cmd ('ls' , project_config .gs_root ),
209- stdout = subprocess .PIPE ,
210- stderr = subprocess .PIPE )
211- _ , stderr = process .communicate ()
212- if process .returncode != 0 :
213- print (stderr .decode ('utf-8' ), '\n ' )
214- print (WARNING_GCS_CONNECTIVITY )
215- sys .exit (0 )
216-
217-
218- def apply_patch_to_local (project_config , patchset , wait_sec , ignore_failed ,
219- retry , verbose ):
220- """Download and apply the patches from the builders."""
221- results = screenshot_results (project_config , patchset )
222- check_not_empty (results )
223- check_all_platforms (project_config , results )
224- retry , should_wait = check_all_success (project_config , results , wait_sec ,
225- ignore_failed , retry )
226- if retry :
227- # Avoiding to force the user to run a 'trigger' command
228- trigger_screenshots (project_config , retry )
229- sys .exit (0 )
230- if not should_wait :
231- with tempfile .TemporaryDirectory () as patch_dir :
232- patches = download_patches (project_config , results , patch_dir ,
233- verbose )
234- git_apply_patch (patches , verbose )
235- print (INFO_PATCHES_APPLIED )
236- sys .exit (0 )
237- print (f'Waiting { wait_sec } seconds ...' )
238- time .sleep (wait_sec )
239- apply_patch_to_local (project_config , patchset , wait_sec , ignore_failed ,
240- retry , verbose )
241-
242-
243266def screenshot_results (project_config , patchset = None ):
244267 """Select only screenshot builders results."""
245268 results = read_try_results (patchset )
@@ -314,7 +337,7 @@ def check_not_empty(results):
314337 if results :
315338 return
316339 print ('No screenshot test results found! ' +
317- 'Make sure to run `update_goldens.py trigger` first.' )
340+ 'Make sure to run CQ against your change first.' )
318341 sys .exit (1 )
319342
320343
@@ -384,48 +407,6 @@ def builder_status(results, builders):
384407 return '\n ' .join (f'{ b } : { results [b ]["status" ]} ' for b in builders )
385408
386409
387- def download_patches (project_config , results , destination_dir , verbose ):
388- """Interact with GS and retrieve the patches. Since we have build results
389- from successfull screenshot builds we know that they uploaded patches in
390- the expected location in the cloud.
391- """
392- patches = []
393- for builder , result in results .items ():
394- gs_path = [
395- project_config .gs_folder , builder ,
396- str (result ['cl' ]),
397- str (result ['patch' ]), 'screenshot.patch'
398- ]
399- gs_location = '/' .join (gs_path )
400- patch_platform = builder .split ('_' )[- 2 ]
401- local_path = os .path .join (destination_dir , patch_platform + '.patch' )
402- if verbose :
403- print ('Downloading patch file from: ' + gs_location )
404- run_command (gcstorage_cmd ('cp' , gs_location , local_path ), verbose )
405- patches .append (local_path )
406- return patches
407-
408-
409- def git_apply_patch (patches , verbose ):
410- """Apply downloaded patches to the local repo."""
411- screenshot_patches = [p for p in patches if check_patch (p )]
412- if screenshot_patches :
413- run_command (
414- ['git' , 'apply' , * screenshot_patches ], verbose ,
415- 'Unable to apply this patch. Maybe run "git clean" before retry.' )
416- else :
417- print ('No other changes found.' )
418- sys .exit (0 )
419-
420-
421- def check_patch (patch ):
422- """Check if a particular patch file is empty."""
423- if os .stat (patch ).st_size == 0 :
424- print ('Ignoring empty patch:%s\n ' % patch )
425- return False
426- return True
427-
428-
429410def run_command (command , verbose , message = None ):
430411 """Run command and deal with return code and output from the subprocess"""
431412 process = subprocess .Popen (command ,
@@ -444,14 +425,8 @@ def run_command(command, verbose, message=None):
444425 sys .exit (1 )
445426
446427
447- def gcstorage_cmd (* args ):
448- return ["gcloud" , "storage" ] + list (args )
449-
450-
451428if __name__ == '__main__' :
452- print (
453- "Deprecation warning: this script is deprecated and will be removed.\n "
454- "Please use `update_goldens_v2.py` instead.\n "
455- "Make sure you familiarize yourself with the new script via "
456- "`update_goldens_v2.py --help.`\n "
457- "Note: removal pending deprecation in other projects." )
429+ main (
430+ ProjectConfig (platforms = ['linux' ],
431+ builder_prefix = 'dtf' ,
432+ ignore_failed_builders = True ), sys .argv [1 :])
0 commit comments