xcresult_logs.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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 subprocess
  25. import sys
  26. from lib import command_trace
  27. _logger = logging.getLogger('xcresult')
  28. def main():
  29. args = sys.argv[1:]
  30. if not args:
  31. sys.stdout.write(__doc__)
  32. sys.exit(1)
  33. logging.basicConfig(format='%(message)s', level=logging.DEBUG)
  34. flags = parse_xcodebuild_flags(args)
  35. # If the result bundle path is specified in the xcodebuild flags, use that
  36. # otherwise, deduce
  37. xcresult_path = flags.get('-resultBundlePath')
  38. if xcresult_path is None:
  39. project = project_from_workspace_path(flags['-workspace'])
  40. scheme = flags['-scheme']
  41. xcresult_path = find_xcresult_path(project, scheme)
  42. log_id = find_log_id(xcresult_path)
  43. log = export_log(xcresult_path, log_id)
  44. sys.stdout.write(log)
  45. # Most flags on the xcodebuild command-line are uninteresting, so only pull
  46. # flags with known behavior with names in this set.
  47. INTERESTING_FLAGS = {
  48. '-resultBundlePath',
  49. '-scheme',
  50. '-workspace',
  51. }
  52. def parse_xcodebuild_flags(args):
  53. """Parses the xcodebuild command-line.
  54. Extracts flags like -workspace and -scheme that dictate the location of the
  55. logs.
  56. """
  57. result = {}
  58. key = None
  59. for arg in args:
  60. if arg.startswith('-'):
  61. if arg in INTERESTING_FLAGS:
  62. key = arg
  63. elif key is not None:
  64. result[key] = arg
  65. key = None
  66. return result
  67. def project_from_workspace_path(path):
  68. """Extracts the project name from a workspace path.
  69. Args:
  70. path: The path to a .xcworkspace file
  71. Returns:
  72. The project name from the basename of the path. For example, if path were
  73. 'Firestore/Example/Firestore.xcworkspace', returns 'Firestore'.
  74. """
  75. root, ext = os.path.splitext(os.path.basename(path))
  76. if ext == '.xcworkspace':
  77. _logger.debug('Using project %s from workspace %s', root, path)
  78. return root
  79. raise ValueError('%s is not a valid workspace path' % path)
  80. def find_xcresult_path(project, scheme):
  81. """Finds an xcresult bundle for the given project and scheme.
  82. Args:
  83. project: The project name, like 'Firestore'
  84. scheme: The Xcode scheme that was tested
  85. Returns:
  86. The path to the newest xcresult bundle that matches.
  87. """
  88. project_path = find_project_path(project)
  89. bundle_dir = os.path.join(project_path, 'Logs/Test')
  90. prefix = 'Run-' + scheme + '-'
  91. _logger.debug('Logging for xcresult bundles in %s', bundle_dir)
  92. xcresult = find_newest_matching_prefix(bundle_dir, prefix)
  93. if xcresult is None:
  94. raise LookupError(
  95. 'Could not find xcresult bundle for %s in %s' % (scheme, bundle_dir))
  96. _logger.debug('Found xcresult: %s', xcresult)
  97. return xcresult
  98. def find_project_path(project):
  99. """Finds the newest project output within Xcode's DerivedData.
  100. Args:
  101. project: A project name; the Foo in Foo.xcworkspace
  102. Returns:
  103. The path containing the newest project output.
  104. """
  105. path = os.path.expanduser('~/Library/Developer/Xcode/DerivedData')
  106. prefix = project + '-'
  107. # DerivedData has directories like Firestore-csljdukzqbozahdjizcvrfiufrkb. Use
  108. # the most recent one if there are more than one such directory matching the
  109. # project name.
  110. result = find_newest_matching_prefix(path, prefix)
  111. if result is None:
  112. raise LookupError(
  113. 'Could not find project derived data for %s in %s' % (project, path))
  114. _logger.debug('Using project derived data in %s', result)
  115. return result
  116. def find_newest_matching_prefix(path, prefix):
  117. """Lists the given directory and returns the newest entry matching prefix.
  118. Args:
  119. path: A directory to list
  120. prefix: The starting part of any filename to consider
  121. Returns:
  122. The path to the newest entry in the directory whose basename starts with
  123. the prefix.
  124. """
  125. entries = os.listdir(path)
  126. result = None
  127. for entry in entries:
  128. if entry.startswith(prefix):
  129. fq_entry = os.path.join(path, entry)
  130. if result is None:
  131. result = fq_entry
  132. else:
  133. result_mtime = os.path.getmtime(result)
  134. entry_mtime = os.path.getmtime(fq_entry)
  135. if entry_mtime > result_mtime:
  136. result = fq_entry
  137. return result
  138. def find_log_id(xcresult_path):
  139. """Finds the id of the last action's logs.
  140. Args:
  141. xcresult_path: The path to an xcresult bundle.
  142. Returns:
  143. The id of the log output, suitable for use with xcresulttool get --id.
  144. """
  145. parsed = xcresulttool_json('get', '--path', xcresult_path)
  146. actions = parsed['actions']['_values']
  147. action = actions[-1]
  148. result = action['actionResult']['logRef']['id']['_value']
  149. _logger.debug('Using log id %s', result)
  150. return result
  151. def export_log(xcresult_path, log_id):
  152. """Exports the log data with the given id from the xcresult bundle.
  153. Args:
  154. xcresult_path: The path to an xcresult bundle.
  155. log_id: The id that names the log output (obtained by find_log_id)
  156. Returns:
  157. The logged output, as a string.
  158. """
  159. contents = xcresulttool_json('get', '--path', xcresult_path, '--id', log_id)
  160. result = []
  161. collect_log_output(contents, result)
  162. return ''.join(result)
  163. def collect_log_output(activity_log, result):
  164. """Recursively collects emitted output from the activity log.
  165. Args:
  166. activity_log: Parsed JSON of an xcresult activity log.
  167. result: An array into which all log data should be appended.
  168. """
  169. output = activity_log.get('emittedOutput')
  170. if output:
  171. result.append(output['_value'])
  172. else:
  173. subsections = activity_log.get('subsections')
  174. if subsections:
  175. for subsection in subsections['_values']:
  176. collect_log_output(subsection, result)
  177. def xcresulttool(*args):
  178. """Runs xcresulttool and returns its output as a string."""
  179. cmd = ['xcrun', 'xcresulttool']
  180. cmd.extend(args)
  181. command_trace.log(cmd)
  182. return subprocess.check_output(cmd)
  183. def xcresulttool_json(*args):
  184. """Runs xcresulttool and its output as parsed JSON."""
  185. args = list(args) + ['--format', 'json']
  186. contents = xcresulttool(*args)
  187. return json.loads(contents)
  188. if __name__ == '__main__':
  189. main()