• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2
3#
4# Copyright 2024, The Android Open Source Project
5#
6# Licensed under the Apache License, Version 2.0 (the "License");
7# you may not use this file except in compliance with the License.
8# You may obtain a copy of the License at
9#
10#     http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17#
18
19import http.server
20import socketserver
21import json
22import re
23import urllib.parse
24from os import path
25import socket
26import argparse
27import os
28import subprocess
29import sys
30import tempfile
31import webbrowser
32import mimetypes
33import hashlib
34import shutil
35import secrets
36import datetime
37import glob
38import gzip
39
40
41from collections import defaultdict
42
43
44def main():
45    parser = argparse.ArgumentParser(
46        "Watches a connected device for golden file updates."
47    )
48
49    parser.add_argument(
50        "--port",
51        default=find_free_port(),
52        type=int,
53        help="Port to run test at watcher web UI on.",
54    )
55
56    parser.add_argument(
57        "--atest",
58        default=False,
59        help="Watches atest output",
60    )
61
62    parser.add_argument(
63         "--studioTest",
64         default=False,
65         help="Watch for artifacs generated when a deviceless test is run via SysUi Studio",
66    )
67
68    parser.add_argument(
69        "--serial",
70        default=os.environ.get("ANDROID_SERIAL"),
71        help="The ADB device serial to pull goldens from.",
72    )
73
74    parser.add_argument(
75        "--android_build_top",
76        default=os.environ.get("ANDROID_BUILD_TOP"),
77        help="The root directory of the android checkout.",
78    )
79
80    parser.add_argument(
81        "--client_url",
82        default="http://motion.teams.x20web.corp.google.com/",
83        help="The URL where the client app is deployed.",
84    )
85
86    args = parser.parse_args()
87
88    if args.android_build_top is None or not os.path.exists(args.android_build_top):
89        print("ANDROID_BUILD_TOP not set. Have you sourced envsetup.sh?")
90        sys.exit(1)
91
92    global android_build_top
93    android_build_top = args.android_build_top
94
95    with tempfile.TemporaryDirectory() as tmpdir:
96        global golden_watcher, this_server_address
97
98        if args.atest:
99            print("ATEST is running.")
100            user = os.environ.get("USER")
101            golden_watcher = AtestGoldenWatcher(
102                tmpdir, f"/tmp/atest_result_{user}/LATEST/"
103            )
104
105        elif args.studioTest:
106            print("Running for devicess sysui studio test")
107            golden_watcher = StudioGoldenWatcher(
108                tmpdir, f"/tmp/motion/"
109            )
110        else:
111            serial = args.serial
112            if not serial:
113                devices_response = subprocess.run(
114                    ["adb", "devices"], check=True, capture_output=True
115                ).stdout.decode("utf-8")
116                lines = [s for s in devices_response.splitlines() if s.strip()]
117
118                if len(lines) == 1:
119                    print("no adb devices found")
120                    sys.exit(1)
121
122                if len(lines) > 2:
123                    print("multiple adb devices found, specify --serial")
124                    sys.exit(1)
125
126                serial = lines[1].split("\t")[0]
127
128            adb_client = AdbClient(serial)
129            if not adb_client.run_as_root():
130                sys.exit(1)
131            golden_watcher = GoldenFileWatcher(tmpdir, adb_client)
132
133        this_server_address = f"http://localhost:{args.port}"
134
135        with socketserver.TCPServer(
136            ("localhost", args.port), WatchWebAppRequestHandler, golden_watcher
137        ) as httpd:
138            uiAddress = f"{args.client_url}?token={secret_token}&port={args.port}"
139            print(f"Open UI at {uiAddress}")
140            webbrowser.open(uiAddress)
141            try:
142                httpd.serve_forever()
143            except KeyboardInterrupt:
144                httpd.shutdown()
145                print("Shutting down")
146
147
148GOLDEN_ACCESS_TOKEN_HEADER = "Golden-Access-Token"
149GOLDEN_ACCESS_TOKEN_LOCATION = os.path.expanduser("~/.config/motion-golden/.token")
150
151secret_token = None
152android_build_top = None
153golden_watcher = None
154this_server_address = None
155
156
157class WatchWebAppRequestHandler(http.server.BaseHTTPRequestHandler):
158
159    def __init__(self, *args, **kwargs):
160        self.root_directory = path.abspath(path.dirname(__file__))
161        super().__init__(*args, **kwargs)
162
163    def verify_access_token(self):
164        token = self.headers.get(GOLDEN_ACCESS_TOKEN_HEADER)
165        if not token or token != secret_token:
166            self.send_response(403, "Bad authorization token!")
167            return False
168
169        return True
170
171    def do_OPTIONS(self):
172        self.send_response(200)
173        self.send_header("Allow", "GET,POST,PUT")
174        self.add_standard_headers()
175        self.end_headers()
176        self.wfile.write(b"GET,POST,PUT")
177
178    def do_GET(self):
179
180        parsed = urllib.parse.urlparse(self.path)
181
182        if parsed.path == "/service/list":
183            self.service_list_goldens()
184            return
185        elif parsed.path.startswith("/golden/"):
186            requested_file_start_index = parsed.path.find("/", len("/golden/") + 1)
187            requested_file = parsed.path[requested_file_start_index + 1 :]
188            self.serve_file(golden_watcher.temp_dir, requested_file)
189            return
190        elif parsed.path.startswith("/expected/"):
191            golden_id = parsed.path[len("/expected/") :]
192
193            goldens = golden_watcher.cached_goldens.values()
194            for golden in goldens:
195                if golden.id != golden_id:
196                    continue
197
198                self.serve_file(
199                    android_build_top, golden.golden_repo_path, "application/json"
200                )
201                return
202
203        self.send_error(404)
204
205    def do_POST(self):
206        if not self.verify_access_token():
207            return
208
209        content_type = self.headers.get("Content-Type")
210
211        # refuse to receive non-json content
212        if content_type != "application/json":
213            self.send_response(400)
214            return
215
216        length = int(self.headers.get("Content-Length"))
217        message = json.loads(self.rfile.read(length))
218
219        parsed = urllib.parse.urlparse(self.path)
220        if parsed.path == "/service/refresh":
221            self.service_refresh_goldens(message["clear"])
222        else:
223            self.send_error(404)
224
225    def do_PUT(self):
226        if not self.verify_access_token():
227            return
228
229        parsed = urllib.parse.urlparse(self.path)
230        query_params = urllib.parse.parse_qs(parsed.query)
231
232        if parsed.path == "/service/update":
233            self.service_update_golden(query_params["id"][0])
234        else:
235            self.send_error(404)
236
237    def serve_file(self, root_directory, file_relative_to_root, mime_type=None):
238        resolved_path = path.abspath(path.join(root_directory, file_relative_to_root))
239
240        if path.commonprefix(
241            [resolved_path, root_directory]
242        ) == root_directory and path.isfile(resolved_path):
243            self.send_response(200)
244            self.send_header(
245                "Content-type", mime_type or mimetypes.guess_type(resolved_path)[0]
246            )
247            self.add_standard_headers()
248            self.end_headers()
249            with open(resolved_path, "rb") as f:
250                self.wfile.write(f.read())
251
252        else:
253            self.send_error(404)
254
255    def service_list_goldens(self):
256        if not self.verify_access_token():
257            return
258
259        goldens_list = []
260
261        for golden in golden_watcher.cached_goldens.values():
262
263            golden_data = {}
264            golden_data["id"] = golden.id
265            golden_data["result"] = golden.result
266            golden_data["label"] = golden.golden_identifier
267            golden_data["goldenRepoPath"] = golden.golden_repo_path
268            golden_data["updated"] = golden.updated
269            golden_data["testClassName"] = golden.test_class_name
270            golden_data["testMethodName"] = golden.test_method_name
271            golden_data["testTime"] = golden.test_time
272
273            golden_data["actualUrl"] = (
274                f"{this_server_address}/golden/{golden.checksum}/{golden.local_file[len(golden_watcher.temp_dir) + 1 :]}"
275            )
276            expected_file = path.join(android_build_top, golden.golden_repo_path)
277            if os.path.exists(expected_file):
278                golden_data["expectedUrl"] = (
279                    f"{this_server_address}/expected/{golden.id}"
280                )
281
282            golden_data["videoUrl"] = (
283                f"{this_server_address}/golden/{golden.checksum}/{golden.video_location}"
284            )
285
286            goldens_list.append(golden_data)
287
288        self.send_json(goldens_list)
289
290    def service_refresh_goldens(self, clear):
291        if clear:
292            golden_watcher.clean()
293        golden_watcher.refresh_golden_files()
294        self.service_list_goldens()
295
296    def service_update_golden(self, id):
297        goldens = golden_watcher.cached_goldens.values()
298        for golden in goldens:
299            if golden.id != id:
300                print("skip", golden.id)
301                continue
302
303            dst = path.join(android_build_top, golden.golden_repo_path)
304            if not path.exists(path.dirname(dst)):
305                os.makedirs(path.dirname(dst))
306
307            shutil.copyfile(golden.local_file, dst)
308
309            golden.updated = True
310            self.send_json({"result": "OK"})
311            return
312
313        self.send_error(400)
314
315    def send_json(self, data):
316        # Replace this with code that generates your JSON data
317        data_encoded = json.dumps(data).encode("utf-8")
318        self.send_response(200)
319        self.send_header("Content-type", "application/json")
320        self.add_standard_headers()
321        self.end_headers()
322        self.wfile.write(data_encoded)
323
324    def add_standard_headers(self):
325        self.send_header("Access-Control-Allow-Origin", "*")
326        self.send_header("Access-Control-Allow-Methods", "POST, PUT, GET, OPTIONS")
327        self.send_header(
328            "Access-Control-Allow-Headers",
329            GOLDEN_ACCESS_TOKEN_HEADER
330            + ", Content-Type, Content-Length, Range, Accept-ranges",
331        )
332        # Accept-ranges: bytes is needed for chrome to allow seeking the
333        # video. At this time, won't handle ranges on subsequent gets,
334        # but that is likely OK given the size of these videos and that
335        # its local only.
336        self.send_header("Accept-ranges", "bytes")
337
338
339class GoldenFileWatcher:
340
341    def __init__(self, temp_dir, adb_client):
342        self.temp_dir = temp_dir
343        self.adb_client = adb_client
344
345        # name -> CachedGolden
346        self.cached_goldens = {}
347        self.refresh_golden_files()
348
349    def clean(self):
350        self.cached_goldens = {}
351
352    def refresh_golden_files(self):
353        command = f"find /data/user/0/ -type f -name *.actual.json"
354        updated_goldens = self.run_adb_command(["shell", command]).splitlines()
355        print(f"Updating goldens - found {len(updated_goldens)} files")
356
357        for golden_remote_file in updated_goldens:
358            local_file = self.adb_pull(golden_remote_file)
359
360            golden = CachedGolden(golden_remote_file, local_file)
361            if golden.video_location:
362                self.adb_pull_image(golden.device_local_path, golden.video_location)
363
364            self.cached_goldens[golden_remote_file] = golden
365
366    def adb_pull(self, remote_file):
367        baseName = os.path.basename(remote_file)
368        filename, ext = os.path.splitext(baseName)
369        remoteFilenameHash = hashlib.md5(remote_file.encode("utf-8")).hexdigest()
370        local_file = os.path.join(self.temp_dir, f'{filename}_{remoteFilenameHash}{ext}')
371        self.run_adb_command(["pull", remote_file, local_file])
372        self.run_adb_command(["shell", "rm", remote_file])
373        return local_file
374
375    def adb_pull_image(self, remote_dir, remote_file):
376        remote_path = os.path.join(remote_dir, remote_file)
377        local_path = os.path.join(self.temp_dir, remote_file)
378        os.makedirs(os.path.dirname(local_path), exist_ok=True)
379        self.run_adb_command(["pull", remote_path, local_path])
380        self.run_adb_command(["shell", "rm", remote_path])
381        return local_path
382
383    def run_adb_command(self, args):
384        return self.adb_client.run_adb_command(args)
385
386
387class StudioGoldenWatcher:
388
389    def __init__(self, temp_dir, latest_dir):
390        self.temp_dir = temp_dir
391        self.latest_dir = latest_dir
392        self.cached_goldens = {}
393        self.refresh_golden_files()
394
395    def refresh_golden_files(self):
396        for filename in glob.iglob(
397            f"{self.latest_dir}//**/*.actual.json", recursive=True
398        ):
399            baseName = os.path.basename(filename)
400            baseFilename, ext = os.path.splitext(baseName)
401            timeHash = hashlib.md5(datetime.datetime.now().isoformat().encode("utf-8")).hexdigest()
402            local_file = os.path.join(self.temp_dir, f'copy_{baseFilename}_{timeHash}{ext}')
403            self.copy_file(filename, local_file)
404            golden = CachedGolden(filename, local_file)
405            self.cached_goldens[filename] = golden
406
407    def copy_file(self, source, target):
408        os.makedirs(os.path.dirname(target), exist_ok=True)
409        shutil.copyfile(source, target)
410
411    def clean(self):
412        self.cached_goldens = {}
413
414class AtestGoldenWatcher:
415
416    def __init__(self, temp_dir, atest_latest_dir):
417        self.temp_dir = temp_dir
418        self.atest_latest_dir = atest_latest_dir
419
420        # name -> CachedGolden
421        self.cached_goldens = {}
422        self.refresh_golden_files()
423
424    def clean(self):
425        self.cached_goldens = {}
426
427    def refresh_golden_files(self):
428
429        # Atest writes the files with a wide variety of filenames. Examples
430        # log/stub/local_atest/inv_8184127433410125702/light_portrait_pagingRight.actual.json_4383267726505225616.txt.gz
431        # log/invocation_3042186109657619915/inv_5155363728971335727/recordMotion_captureCrossfade.actual_10536896158799342698.json
432        # log/stub/local_atest/inv_6860054371355660320/light_portrait_noOverscrollRight.actual_118505410949600545.json.gz
433
434        # log/stub/local_atest/inv_6860054371355660320/light_portrait_noOverscrollRight.actual_12613191689435798576.mp4
435        # log/invocation_3042186109657619915/inv_5155363728971335727/recordMotion_captureCrossfade.actual_1988198704080929506.mp4
436        # log/stub/local_atest/inv_8184127433410125702/light_portrait_pagingRight.actual.mp4_1617964025478041468.txt.gz
437
438        pattern_type = (
439            r".*/(?P<name>.*)\.actual((\.(?P<ext1>[a-zA-Z0-9]+)_(?P<hash1>\d+)\.txt)|(_(?P<hash2>\d+)\.(?P<ext2>[a-zA-Z0-9]+)))(?P<compressed>\.gz)?"
440        )
441
442        # Output from on-device runs
443        # Modifying the search regex to handle files not ending with json as given above.
444        for filename in glob.iglob(
445            f"{self.atest_latest_dir}//**/*.actual*json*", recursive=True
446        ):
447
448            match = re.search(pattern_type, filename)
449
450            if not match:
451                continue
452
453            golden_name = match.group("name")
454            ext = match.group("ext1") or match.group("ext2")
455            hash = match.group("hash1") or match.group("hash2")
456            is_compressed = match.group("compressed") == ".gz"
457
458            local_file = os.path.join(self.temp_dir, f"{golden_name}_{hash}.actual.json")
459            self.copy_file(filename, local_file, is_compressed)
460            golden = CachedGolden(filename, local_file)
461
462            if golden.video_location:
463                for video_filename in glob.iglob(
464                    f"{self.atest_latest_dir}/**/{golden_name}.actual*.mp4*",
465                    recursive=True,
466                ):
467
468                    local_video_file = os.path.join(
469                        self.temp_dir, golden.video_location
470                    )
471                    video_is_compressed = video_filename.endswith(".gz")
472                    self.copy_file(
473                        video_filename, local_video_file, video_is_compressed
474                    )
475
476                    break
477
478            self.cached_goldens[filename] = golden
479
480    def copy_file(self, source, target, is_compressed):
481        os.makedirs(os.path.dirname(target), exist_ok=True)
482
483        if is_compressed:
484            with gzip.open(source, "rb") as f_in:
485                with open(target, "wb") as f_out:
486                    shutil.copyfileobj(f_in, f_out)
487        else:
488            shutil.copyfile(source, target)
489
490class CachedGolden:
491
492    def __init__(self, remote_file, local_file):
493        self.id = hashlib.md5(remote_file.encode("utf-8")).hexdigest()
494        self.remote_file = remote_file
495        self.local_file = local_file
496        self.updated = False
497        self.test_time = datetime.datetime.now().isoformat()
498        # Checksum is the time the test data was loaded, forcing unique URLs
499        # every time the golden is reloaded
500        self.checksum = hashlib.md5(self.test_time.encode("utf-8")).hexdigest()
501
502        motion_golden_data = None
503        with open(local_file, "r") as json_file:
504            motion_golden_data = json.load(json_file)
505        metadata = motion_golden_data["//metadata"]
506
507        self.result = metadata["result"]
508        self.golden_repo_path = metadata["goldenRepoPath"]
509        self.golden_identifier = metadata["goldenIdentifier"]
510        self.test_class_name = metadata["testClassName"]
511        self.test_method_name = metadata["testMethodName"]
512        self.device_local_path = metadata["deviceLocalPath"]
513        self.video_location = None
514        if "videoLocation" in metadata:
515            self.video_location = metadata["videoLocation"]
516
517        with open(local_file, "w") as json_file:
518            del motion_golden_data["//metadata"]
519            json.dump(motion_golden_data, json_file, indent=2)
520
521
522class AdbClient:
523    def __init__(self, adb_serial):
524        self.adb_serial = adb_serial
525
526    def run_as_root(self):
527        root_result = self.run_adb_command(["root"])
528        if "restarting adbd as root" in root_result:
529            self.wait_for_device()
530            return True
531        if "adbd is already running as root" in root_result:
532            return True
533
534        print(f"run_as_root returned [{root_result}]")
535
536        return False
537
538    def wait_for_device(self):
539        self.run_adb_command(["wait-for-device"])
540
541    def run_adb_command(self, args):
542        command = ["adb"]
543        command += ["-s", self.adb_serial]
544        command += args
545        return subprocess.run(command, check=True, capture_output=True).stdout.decode(
546            "utf-8"
547        )
548
549
550def find_free_port():
551    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
552        s.bind(("", 0))  # Bind to a random free port provided by the OS
553        return s.getsockname()[1]  # Get the port number
554
555
556def get_token() -> str:
557    try:
558        with open(GOLDEN_ACCESS_TOKEN_LOCATION, "r") as token_file:
559            token = token_file.readline()
560            return token
561    except IOError:
562        token = secrets.token_hex(32)
563        os.makedirs(os.path.dirname(GOLDEN_ACCESS_TOKEN_LOCATION), exist_ok=True)
564        try:
565            with open(GOLDEN_ACCESS_TOKEN_LOCATION, "w") as token_file:
566                token_file.write(token)
567            os.chmod(GOLDEN_ACCESS_TOKEN_LOCATION, 0o600)
568        except IOError:
569            print(
570                "Unable to save persistent token {} to {}".format(
571                    token, GOLDEN_ACCESS_TOKEN_LOCATION
572                )
573            )
574        return token
575
576
577if __name__ == "__main__":
578    secret_token = get_token()
579    main()
580