Skip to content

Commit aec2f61

Browse files
committed
Recursively export xcresult screenshot attachments
1 parent 3140029 commit aec2f61

File tree

1 file changed

+180
-1
lines changed

1 file changed

+180
-1
lines changed

scripts/run-ios-ui-tests.sh

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,186 @@ rm -rf "$EXPORT_DIR"
174174
mkdir -p "$EXPORT_DIR"
175175

176176
ri_log "Exporting screenshot attachments from $RESULT_BUNDLE"
177-
if ! xcrun xcresulttool export --legacy --path "$RESULT_BUNDLE" --type file --output-path "$EXPORT_DIR" >/dev/null; then
177+
if ! python3 - "$RESULT_BUNDLE" "$EXPORT_DIR" <<'PY'
178+
import json
179+
import os
180+
import re
181+
import subprocess
182+
import sys
183+
from collections import deque
184+
185+
186+
def ri_log(message: str) -> None:
187+
print(f"[run-ios-ui-tests] {message}", file=sys.stderr)
188+
189+
190+
def run_xcresult(args):
191+
cmd = ["xcrun", "xcresulttool", *args]
192+
try:
193+
result = subprocess.run(
194+
cmd,
195+
check=True,
196+
stdout=subprocess.PIPE,
197+
stderr=subprocess.PIPE,
198+
)
199+
except subprocess.CalledProcessError as exc:
200+
ri_log(
201+
"xcresulttool command failed: {}\n{}".format(
202+
" ".join(cmd), exc.stderr.decode("utf-8", "ignore").strip()
203+
)
204+
)
205+
raise
206+
return result.stdout.decode("utf-8")
207+
208+
209+
def get_json(bundle_path, object_id=None):
210+
args = ["get", "--path", bundle_path, "--format", "json"]
211+
if object_id:
212+
args.extend(["--id", object_id])
213+
output = run_xcresult(args)
214+
return json.loads(output or "{}")
215+
216+
217+
def collect_nodes(node):
218+
attachments = []
219+
refs = []
220+
stack = [node]
221+
while stack:
222+
current = stack.pop()
223+
if isinstance(current, dict):
224+
attachment_block = current.get("attachments")
225+
if isinstance(attachment_block, dict):
226+
for item in attachment_block.get("_values", []):
227+
if isinstance(item, dict):
228+
attachments.append(item)
229+
for key, value in current.items():
230+
if key.endswith("Ref") and isinstance(value, dict):
231+
ref_id = value.get("id")
232+
if isinstance(ref_id, str) and ref_id:
233+
refs.append(ref_id)
234+
elif key.endswith("Refs") and isinstance(value, dict):
235+
for entry in value.get("_values", []):
236+
if isinstance(entry, dict):
237+
ref_id = entry.get("id")
238+
if isinstance(ref_id, str) and ref_id:
239+
refs.append(ref_id)
240+
if isinstance(value, (dict, list)):
241+
stack.append(value)
242+
elif isinstance(current, list):
243+
stack.extend(current)
244+
return attachments, refs
245+
246+
247+
def is_image_attachment(attachment):
248+
uti = (attachment.get("uniformTypeIdentifier") or "").lower()
249+
filename = (attachment.get("filename") or attachment.get("name") or "").lower()
250+
if filename.endswith((".png", ".jpg", ".jpeg")):
251+
return True
252+
if "png" in uti or "jpeg" in uti:
253+
return True
254+
return False
255+
256+
257+
def sanitize_filename(name):
258+
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
259+
return safe or "attachment"
260+
261+
262+
def ensure_extension(name, uti):
263+
base, ext = os.path.splitext(name)
264+
if ext:
265+
return name
266+
uti = uti.lower()
267+
if "png" in uti:
268+
return f"{name}.png"
269+
if "jpeg" in uti:
270+
return f"{name}.jpg"
271+
return name
272+
273+
274+
def export_attachment(bundle_path, attachment, destination_dir, used_names):
275+
payload = attachment.get("payloadRef") or {}
276+
attachment_id = payload.get("id")
277+
if not attachment_id:
278+
return
279+
name = attachment.get("filename") or attachment.get("name") or attachment_id
280+
name = ensure_extension(name, attachment.get("uniformTypeIdentifier") or "")
281+
name = sanitize_filename(name)
282+
candidate = name
283+
counter = 1
284+
while candidate in used_names:
285+
base, ext = os.path.splitext(name)
286+
candidate = f"{base}_{counter}{ext}"
287+
counter += 1
288+
used_names.add(candidate)
289+
output_path = os.path.join(destination_dir, candidate)
290+
run_xcresult(
291+
[
292+
"export",
293+
"--legacy",
294+
"--path",
295+
bundle_path,
296+
"--id",
297+
attachment_id,
298+
"--type",
299+
"file",
300+
"--output-path",
301+
output_path,
302+
]
303+
)
304+
ri_log(f"Exported attachment {candidate}")
305+
306+
307+
def main():
308+
if len(sys.argv) != 3:
309+
ri_log("Expected bundle path and destination directory arguments")
310+
return 1
311+
312+
bundle_path, destination_dir = sys.argv[1:3]
313+
os.makedirs(destination_dir, exist_ok=True)
314+
315+
root = get_json(bundle_path)
316+
attachments, refs = collect_nodes(root)
317+
queue = deque(refs)
318+
seen_refs = set()
319+
seen_attachment_ids = set()
320+
321+
def handle_attachments(items):
322+
for attachment in items:
323+
payload = attachment.get("payloadRef") or {}
324+
attachment_id = payload.get("id")
325+
if not attachment_id or attachment_id in seen_attachment_ids:
326+
continue
327+
if not is_image_attachment(attachment):
328+
continue
329+
seen_attachment_ids.add(attachment_id)
330+
export_attachment(bundle_path, attachment, destination_dir, exported_names)
331+
332+
exported_names = set()
333+
handle_attachments(attachments)
334+
335+
while queue:
336+
ref_id = queue.popleft()
337+
if ref_id in seen_refs:
338+
continue
339+
seen_refs.add(ref_id)
340+
data = get_json(bundle_path, ref_id)
341+
items, nested_refs = collect_nodes(data)
342+
handle_attachments(items)
343+
for nested in nested_refs:
344+
if nested not in seen_refs:
345+
queue.append(nested)
346+
347+
if not exported_names:
348+
ri_log("No screenshot attachments were exported from xcresult bundle")
349+
350+
return 0
351+
352+
353+
if __name__ == "__main__":
354+
sys.exit(main())
355+
PY
356+
then
178357
ri_log "xcresulttool export failed"
179358
exit 11
180359
fi

0 commit comments

Comments
 (0)