Skip to content

Commit fae5c43

Browse files
authored
Extract log output from xcodebuild runs after failures (#4867)
1 parent 4b70358 commit fae5c43

File tree

4 files changed

+314
-4
lines changed

4 files changed

+314
-4
lines changed

.github/workflows/firestore.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ on:
2727
- 'CMakeLists.txt'
2828
- 'cmake/**'
2929

30-
# Build scripts
30+
# Build scripts to which Firestore is sensitive
31+
#
3132
# Note that this doesn't include check scripts because changing those will
3233
# already trigger the check workflow.
3334
- 'scripts/binary_to_array.py'
@@ -39,6 +40,7 @@ on:
3940
- 'scripts/setup_*'
4041
- 'scripts/sync_project.rb'
4142
- 'scripts/test_quickstart.sh'
43+
- 'scripts/xcresult_logs.py'
4244

4345
# This workflow
4446
- '.github/workflows/firestore.yml'

scripts/build.sh

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,17 +115,74 @@ function RunXcodebuild() {
115115
xcpretty_cmd+=(-f $(xcpretty-travis-formatter))
116116
fi
117117

118-
xcodebuild "$@" | tee xcodebuild.log | "${xcpretty_cmd[@]}"; result=$?
118+
result=0
119+
xcodebuild "$@" | tee xcodebuild.log | "${xcpretty_cmd[@]}" || result=$?
119120
if [[ $result == 65 ]]; then
121+
ExportLogs "$@"
122+
120123
echo "xcodebuild exited with 65, retrying" 1>&2
121124
sleep 5
122125

123-
xcodebuild "$@" | tee xcodebuild.log | "${xcpretty_cmd[@]}"; result=$?
126+
result=0
127+
xcodebuild "$@" | tee xcodebuild.log | "${xcpretty_cmd[@]}" || result=$?
124128
fi
125129
if [[ $result != 0 ]]; then
130+
126131
echo "xcodebuild exited with $result; raw log follows" 1>&2
132+
OpenFold Raw log
127133
cat xcodebuild.log
128-
exit $result
134+
CloseFold
135+
136+
ExportLogs "$@"
137+
return $result
138+
fi
139+
}
140+
141+
# Exports any logs output captured in the xcresult
142+
function ExportLogs() {
143+
OpenFold XCResult
144+
145+
exporter="${scripts_dir}/xcresult_logs.py"
146+
python "$exporter" "$@"
147+
148+
CloseFold
149+
}
150+
151+
current_group=none
152+
current_fold=0
153+
154+
# Prints a command for CI environments to group log output in the logs
155+
# presentation UI.
156+
function OpenFold() {
157+
description="$*"
158+
current_group="$(echo "$description" | tr '[A-Z] ' '[a-z]_')"
159+
160+
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then
161+
echo "::group::description"
162+
163+
elif [[ -n "${TRAVIS:-}" ]]; then
164+
# Travis wants groups to be numbered.
165+
current_group="${current_group}.${current_fold}"
166+
let current_fold++
167+
168+
# Show description in yellow.
169+
echo "travis_fold:start:${current_group}\033[33;1m${description}\033[0m"
170+
171+
else
172+
echo "===== $description Start ====="
173+
fi
174+
}
175+
176+
# Closes the current fold opened by `OpenFold`.
177+
function CloseFold() {
178+
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then
179+
echo "::endgroup::"
180+
181+
elif [[ -n "${TRAVIS:-}" ]]; then
182+
echo "travis_fold:end:${current_group}"
183+
184+
else
185+
echo "===== $description End ====="
129186
fi
130187
}
131188

scripts/if_changed.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ fi
159159
check_changes '^.travis.yml'
160160
check_changes '^Gemfile.lock'
161161
check_changes '^scripts/(build|install_prereqs|pod_lib_lint).(rb|sh)'
162+
check_changes '^scripts/xcresult_logs.py'
162163

163164
if [[ "$run" == true ]]; then
164165
"$@"

scripts/xcresult_logs.py

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

0 commit comments

Comments
 (0)