Skip to content

Commit e657cf0

Browse files
committed
Extract xcresult export helper
1 parent aec2f61 commit e657cf0

File tree

2 files changed

+200
-180
lines changed

2 files changed

+200
-180
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
#!/usr/bin/env python3
2+
"""Export image attachments from an xcresult bundle."""
3+
4+
from __future__ import annotations
5+
6+
import json
7+
import os
8+
import re
9+
import subprocess
10+
import sys
11+
from collections import deque
12+
from typing import Deque, Dict, Iterable, List, Optional, Sequence, Set, Tuple
13+
14+
15+
def ri_log(message: str) -> None:
16+
"""Mirror the bash script's logging prefix."""
17+
print(f"[run-ios-ui-tests] {message}", file=sys.stderr)
18+
19+
20+
def run_xcresult(args: Sequence[str]) -> str:
21+
cmd = ["xcrun", "xcresulttool", *args]
22+
try:
23+
result = subprocess.run(
24+
cmd,
25+
check=True,
26+
stdout=subprocess.PIPE,
27+
stderr=subprocess.PIPE,
28+
)
29+
except subprocess.CalledProcessError as exc:
30+
stderr = exc.stderr.decode("utf-8", "ignore").strip()
31+
ri_log(f"xcresulttool command failed: {' '.join(cmd)}\n{stderr}")
32+
raise
33+
return result.stdout.decode("utf-8")
34+
35+
36+
def get_json(bundle_path: str, object_id: Optional[str] = None) -> Dict:
37+
args = ["get", "--path", bundle_path, "--format", "json"]
38+
if object_id:
39+
args.extend(["--id", object_id])
40+
output = run_xcresult(args)
41+
return json.loads(output or "{}")
42+
43+
44+
def collect_nodes(node) -> Tuple[List[Dict], List[str]]:
45+
attachments: List[Dict] = []
46+
refs: List[str] = []
47+
stack: List = [node]
48+
while stack:
49+
current = stack.pop()
50+
if isinstance(current, dict):
51+
attachment_block = current.get("attachments")
52+
if isinstance(attachment_block, dict):
53+
for item in attachment_block.get("_values", []):
54+
if isinstance(item, dict):
55+
attachments.append(item)
56+
for key, value in current.items():
57+
if key.endswith("Ref") and isinstance(value, dict):
58+
ref_id = value.get("id")
59+
if isinstance(ref_id, str) and ref_id:
60+
refs.append(ref_id)
61+
elif key.endswith("Refs") and isinstance(value, dict):
62+
for entry in value.get("_values", []):
63+
if isinstance(entry, dict):
64+
ref_id = entry.get("id")
65+
if isinstance(ref_id, str) and ref_id:
66+
refs.append(ref_id)
67+
if isinstance(value, (dict, list)):
68+
stack.append(value)
69+
elif isinstance(current, list):
70+
stack.extend(current)
71+
return attachments, refs
72+
73+
74+
def is_image_attachment(attachment: Dict) -> bool:
75+
uti = (attachment.get("uniformTypeIdentifier") or "").lower()
76+
filename = (attachment.get("filename") or attachment.get("name") or "").lower()
77+
if filename.endswith((".png", ".jpg", ".jpeg")):
78+
return True
79+
if "png" in uti or "jpeg" in uti:
80+
return True
81+
return False
82+
83+
84+
def sanitize_filename(name: str) -> str:
85+
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
86+
return safe or "attachment"
87+
88+
89+
def ensure_extension(name: str, uti: str) -> str:
90+
base, ext = os.path.splitext(name)
91+
if ext:
92+
return name
93+
uti = uti.lower()
94+
if "png" in uti:
95+
return f"{name}.png"
96+
if "jpeg" in uti:
97+
return f"{name}.jpg"
98+
return name
99+
100+
101+
def export_attachment(
102+
bundle_path: str, attachment: Dict, destination_dir: str, used_names: Set[str]
103+
) -> None:
104+
payload = attachment.get("payloadRef") or {}
105+
attachment_id = payload.get("id")
106+
if not attachment_id:
107+
return
108+
name = attachment.get("filename") or attachment.get("name") or attachment_id
109+
name = ensure_extension(name, attachment.get("uniformTypeIdentifier") or "")
110+
name = sanitize_filename(name)
111+
candidate = name
112+
counter = 1
113+
while candidate in used_names:
114+
base, ext = os.path.splitext(name)
115+
candidate = f"{base}_{counter}{ext}"
116+
counter += 1
117+
used_names.add(candidate)
118+
output_path = os.path.join(destination_dir, candidate)
119+
run_xcresult(
120+
[
121+
"export",
122+
"--legacy",
123+
"--path",
124+
bundle_path,
125+
"--id",
126+
attachment_id,
127+
"--type",
128+
"file",
129+
"--output-path",
130+
output_path,
131+
]
132+
)
133+
ri_log(f"Exported attachment {candidate}")
134+
135+
136+
def handle_attachments(
137+
bundle_path: str,
138+
items: Iterable[Dict],
139+
destination_dir: str,
140+
used_names: Set[str],
141+
seen_attachment_ids: Set[str],
142+
) -> None:
143+
for attachment in items:
144+
payload = attachment.get("payloadRef") or {}
145+
attachment_id = payload.get("id")
146+
if not attachment_id or attachment_id in seen_attachment_ids:
147+
continue
148+
if not is_image_attachment(attachment):
149+
continue
150+
seen_attachment_ids.add(attachment_id)
151+
export_attachment(bundle_path, attachment, destination_dir, used_names)
152+
153+
154+
def export_bundle(bundle_path: str, destination_dir: str) -> bool:
155+
os.makedirs(destination_dir, exist_ok=True)
156+
157+
root = get_json(bundle_path)
158+
attachments, refs = collect_nodes(root)
159+
queue: Deque[str] = deque(refs)
160+
seen_refs: Set[str] = set()
161+
seen_attachment_ids: Set[str] = set()
162+
exported_names: Set[str] = set()
163+
164+
handle_attachments(
165+
bundle_path, attachments, destination_dir, exported_names, seen_attachment_ids
166+
)
167+
168+
while queue:
169+
ref_id = queue.popleft()
170+
if ref_id in seen_refs:
171+
continue
172+
seen_refs.add(ref_id)
173+
data = get_json(bundle_path, ref_id)
174+
items, nested_refs = collect_nodes(data)
175+
handle_attachments(
176+
bundle_path, items, destination_dir, exported_names, seen_attachment_ids
177+
)
178+
for nested in nested_refs:
179+
if nested not in seen_refs:
180+
queue.append(nested)
181+
182+
if not exported_names:
183+
ri_log("No screenshot attachments were exported from xcresult bundle")
184+
return False
185+
return True
186+
187+
188+
def main(argv: Sequence[str]) -> int:
189+
if len(argv) != 3:
190+
ri_log("Expected bundle path and destination directory arguments")
191+
return 1
192+
_, bundle_path, destination_dir = argv
193+
success = export_bundle(bundle_path, destination_dir)
194+
return 0 if success else 2
195+
196+
197+
if __name__ == "__main__":
198+
raise SystemExit(main(sys.argv))

scripts/run-ios-ui-tests.sh

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

176176
ri_log "Exporting screenshot attachments from $RESULT_BUNDLE"
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
177+
EXPORT_HELPER="$SCRIPT_DIR/ios/export_xcresult_attachments.py"
178+
if ! python3 "$EXPORT_HELPER" "$RESULT_BUNDLE" "$EXPORT_DIR"; then
357179
ri_log "xcresulttool export failed"
358180
exit 11
359181
fi

0 commit comments

Comments
 (0)