xcresult_logs.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. #!/usr/bin/env python
  2. # Copyright 2020 Google LLC
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. """Prints logs from test runs captured in Apple .xcresult bundles.
  16. USAGE: xcresult_logs.py -workspace <path> -scheme <scheme> [other flags...]
  17. xcresult_logs.py finds and displays the log output associated with an xcodebuild
  18. invocation. Pass your entire xcodebuild command-line as arguments to this script
  19. and it will find the output associated with the most recent invocation.
  20. """
  21. import json
  22. import logging
  23. import os
  24. import re
  25. import shutil
  26. import subprocess
  27. import sys
  28. from lib import command_trace
  29. _logger = logging.getLogger('xcresult')
  30. def main():
  31. args = sys.argv[1:]
  32. if not args:
  33. sys.stdout.write(__doc__)
  34. sys.exit(1)
  35. logging.basicConfig(format='%(message)s', level=logging.DEBUG)
  36. flags = parse_xcodebuild_flags(args)
  37. # If the result bundle path is specified in the xcodebuild flags, use that
  38. # otherwise, deduce
  39. xcresult_path = flags.get('-resultBundlePath')
  40. if xcresult_path is None:
  41. project = project_from_workspace_path(flags['-workspace'])
  42. scheme = flags['-scheme']
  43. xcresult_path = find_xcresult_path(project, scheme)
  44. version = find_xcode_major_version()
  45. if version <= 10:
  46. files = find_legacy_log_files(xcresult_path)
  47. cat_files(files, sys.stdout)
  48. else:
  49. # Xcode 11 and up ship xcresult tool which standardizes the xcresult format
  50. # but also makes it harder to deal with.
  51. log_id = find_log_id(xcresult_path)
  52. log = export_log(xcresult_path, log_id)
  53. # Avoid a potential UnicodeEncodeError raised by sys.stdout.write() by
  54. # doing a relaxed encoding ourselves.
  55. if hasattr(sys.stdout, 'buffer'):
  56. log_encoded = log.encode('utf8', errors='backslashreplace')
  57. sys.stdout.flush()
  58. sys.stdout.buffer.write(log_encoded)
  59. else:
  60. log_encoded = log.encode('ascii', errors='backslashreplace')
  61. log_decoded = log_encoded.decode('ascii', errors='strict')
  62. sys.stdout.write(log_decoded)
  63. # Most flags on the xcodebuild command-line are uninteresting, so only pull
  64. # flags with known behavior with names in this set.
  65. INTERESTING_FLAGS = {
  66. '-resultBundlePath',
  67. '-scheme',
  68. '-workspace',
  69. }
  70. def parse_xcodebuild_flags(args):
  71. """Parses the xcodebuild command-line.
  72. Extracts flags like -workspace and -scheme that dictate the location of the
  73. logs.
  74. """
  75. result = {}
  76. key = None
  77. for arg in args:
  78. if arg.startswith('-'):
  79. if arg in INTERESTING_FLAGS:
  80. key = arg
  81. elif key is not None:
  82. result[key] = arg
  83. key = None
  84. return result
  85. def project_from_workspace_path(path):
  86. """Extracts the project name from a workspace path.
  87. Args:
  88. path: The path to a .xcworkspace file
  89. Returns:
  90. The project name from the basename of the path. For example, if path were
  91. 'Firestore/Example/Firestore.xcworkspace', returns 'Firestore'.
  92. """
  93. root, ext = os.path.splitext(os.path.basename(path))
  94. if ext == '.xcworkspace':
  95. _logger.debug('Using project %s from workspace %s', root, path)
  96. return root
  97. raise ValueError('%s is not a valid workspace path' % path)
  98. def find_xcresult_path(project, scheme):
  99. """Finds an xcresult bundle for the given project and scheme.
  100. Args:
  101. project: The project name, like 'Firestore'
  102. scheme: The Xcode scheme that was tested
  103. Returns:
  104. The path to the newest xcresult bundle that matches.
  105. """
  106. project_path = find_project_path(project)
  107. bundle_dir = os.path.join(project_path, 'Logs/Test')
  108. prefix = re.compile('([^-]*)-' + re.escape(scheme) + '-')
  109. _logger.debug('Logging for xcresult bundles in %s', bundle_dir)
  110. xcresult = find_newest_matching_prefix(bundle_dir, prefix)
  111. if xcresult is None:
  112. raise LookupError(
  113. 'Could not find xcresult bundle for %s in %s' % (scheme, bundle_dir))
  114. _logger.debug('Found xcresult: %s', xcresult)
  115. return xcresult
  116. def find_project_path(project):
  117. """Finds the newest project output within Xcode's DerivedData.
  118. Args:
  119. project: A project name; the Foo in Foo.xcworkspace
  120. Returns:
  121. The path containing the newest project output.
  122. """
  123. path = os.path.expanduser('~/Library/Developer/Xcode/DerivedData')
  124. prefix = re.compile(re.escape(project) + '-')
  125. # DerivedData has directories like Firestore-csljdukzqbozahdjizcvrfiufrkb. Use
  126. # the most recent one if there are more than one such directory matching the
  127. # project name.
  128. result = find_newest_matching_prefix(path, prefix)
  129. if result is None:
  130. raise LookupError(
  131. 'Could not find project derived data for %s in %s' % (project, path))
  132. _logger.debug('Using project derived data in %s', result)
  133. return result
  134. def find_newest_matching_prefix(path, prefix):
  135. """Lists the given directory and returns the newest entry matching prefix.
  136. Args:
  137. path: A directory to list
  138. prefix: A regular expression that matches the filenames to consider
  139. Returns:
  140. The path to the newest entry in the directory whose basename starts with
  141. the prefix.
  142. """
  143. entries = os.listdir(path)
  144. result = None
  145. for entry in entries:
  146. if prefix.match(entry):
  147. fq_entry = os.path.join(path, entry)
  148. if result is None:
  149. result = fq_entry
  150. else:
  151. result_mtime = os.path.getmtime(result)
  152. entry_mtime = os.path.getmtime(fq_entry)
  153. if entry_mtime > result_mtime:
  154. result = fq_entry
  155. return result
  156. def find_legacy_log_files(xcresult_path):
  157. """Finds the log files produced by Xcode 10 and below."""
  158. result = []
  159. for root, dirs, files in os.walk(xcresult_path, topdown=True):
  160. for file in files:
  161. if file.endswith('.txt'):
  162. file = os.path.join(root, file)
  163. result.append(file)
  164. # Sort the files by creation time.
  165. result.sort(key=lambda f: os.stat(f).st_ctime)
  166. return result
  167. def cat_files(files, output):
  168. """Reads the contents of all the files and copies them to the output.
  169. Args:
  170. files: A list of filenames
  171. output: A file-like object in which all the data should be copied.
  172. """
  173. for file in files:
  174. with open(file, 'r') as fd:
  175. shutil.copyfileobj(fd, output)
  176. def find_log_id(xcresult_path):
  177. """Finds the id of the last action's logs.
  178. Args:
  179. xcresult_path: The path to an xcresult bundle.
  180. Returns:
  181. The id of the log output, suitable for use with xcresulttool get --id.
  182. """
  183. parsed = xcresulttool_json('get', '--path', xcresult_path)
  184. actions = parsed['actions']['_values']
  185. action = actions[-1]
  186. result = action['actionResult']['logRef']['id']['_value']
  187. _logger.debug('Using log id %s', result)
  188. return result
  189. def export_log(xcresult_path, log_id):
  190. """Exports the log data with the given id from the xcresult bundle.
  191. Args:
  192. xcresult_path: The path to an xcresult bundle.
  193. log_id: The id that names the log output (obtained by find_log_id)
  194. Returns:
  195. The logged output, as a string.
  196. """
  197. contents = xcresulttool_json('get', '--path', xcresult_path, '--id', log_id)
  198. result = []
  199. collect_log_output(contents, result)
  200. return ''.join(result)
  201. def collect_log_output(activity_log, result):
  202. """Recursively collects emitted output from the activity log.
  203. Args:
  204. activity_log: Parsed JSON of an xcresult activity log.
  205. result: An array into which all log data should be appended.
  206. """
  207. output = activity_log.get('emittedOutput')
  208. if output:
  209. result.append(output['_value'])
  210. else:
  211. subsections = activity_log.get('subsections')
  212. if subsections:
  213. for subsection in subsections['_values']:
  214. collect_log_output(subsection, result)
  215. def find_xcode_major_version():
  216. """Determines the major version number of Xcode."""
  217. cmd = ['xcodebuild', '-version']
  218. command_trace.log(cmd)
  219. result = str(subprocess.check_output(cmd))
  220. version = result.split('\n', 1)[0]
  221. version = re.sub(r'Xcode ', '', version)
  222. version = re.sub(r'\..*', '', version)
  223. return int(version)
  224. def xcresulttool(*args):
  225. """Runs xcresulttool and returns its output as a string."""
  226. cmd = ['xcrun', 'xcresulttool']
  227. cmd.extend(args)
  228. command_trace.log(cmd)
  229. return subprocess.check_output(cmd)
  230. def xcresulttool_json(*args):
  231. """Runs xcresulttool and its output as parsed JSON."""
  232. args = list(args) + ['--format', 'json']
  233. contents = xcresulttool(*args)
  234. return json.loads(contents)
  235. if __name__ == '__main__':
  236. main()