Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions scripts/build-ios-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ GROUP_ID="com.codenameone.examples"
ARTIFACT_ID="hello-codenameone-ios"
MAIN_NAME="HelloCodenameOne"
PACKAGE_NAME="$GROUP_ID"
IOS_BUNDLE_ID="com.codenameone.examples.HelloCodenameOne"

SOURCE_PROJECT="$REPO_ROOT/Samples/SampleProjectTemplate"
if [ ! -d "$SOURCE_PROJECT" ]; then
Expand Down Expand Up @@ -125,6 +126,7 @@ set_property() {

set_property "codename1.packageName" "$PACKAGE_NAME"
set_property "codename1.mainName" "$MAIN_NAME"
set_property "codename1.ios.appid" "$IOS_BUNDLE_ID"

# Ensure trailing newline
tail -c1 "$SETTINGS_FILE" | read -r _ || echo >> "$SETTINGS_FILE"
Expand Down Expand Up @@ -214,6 +216,9 @@ export XCODEPROJ
bia_log "Using Xcode project: $XCODEPROJ"

# --- Ensure UITests target + CI scheme (save_as gets a PATH, not a Project) ---
export CN1_APP_BUNDLE_ID="${CN1_APP_BUNDLE_ID:-$IOS_BUNDLE_ID}"
export CN1_MAIN_NAME="$MAIN_NAME"

ruby -rrubygems -rxcodeproj -e '
require "fileutils"
proj_path = ENV["XCODEPROJ"] or abort("XCODEPROJ env not set")
Expand All @@ -222,6 +227,21 @@ proj = Xcodeproj::Project.open(proj_path)
app_target = proj.targets.find { |t| t.product_type == "com.apple.product-type.application" } || proj.targets.first
ui_name = "HelloCodenameOneUITests"
ui_target = proj.targets.find { |t| t.name == ui_name }
bundle_id = ENV.fetch("CN1_APP_BUNDLE_ID", "").strip
bundle_id = "com.codenameone.examples.HelloCodenameOne" if bundle_id.empty?
ui_bundle = "#{bundle_id}.uitests"
main_name = ENV.fetch("CN1_MAIN_NAME", "HelloCodenameOne")

if app_target
%w[Debug Release].each do |cfg|
xc = app_target.build_configuration_list[cfg]
next unless xc
bs = xc.build_settings
bs["PRODUCT_BUNDLE_IDENTIFIER"] = bundle_id
bs["PRODUCT_NAME"] ||= main_name
bs["INFOPLIST_KEY_CFBundleDisplayName"] ||= main_name
end
end

unless ui_target
ui_target = proj.new_target(:ui_test_bundle, ui_name, :ios, "18.0")
Expand Down Expand Up @@ -253,9 +273,9 @@ ui_target.add_file_references([file_ref]) unless ui_target.source_build_phase.fi
bs["GENERATE_INFOPLIST_FILE"] = "YES"
bs["CODE_SIGNING_ALLOWED"] = "NO"
bs["CODE_SIGNING_REQUIRED"] = "NO"
bs["PRODUCT_BUNDLE_IDENTIFIER"] ||= "com.codenameone.examples.uitests"
bs["PRODUCT_BUNDLE_IDENTIFIER"] = ui_bundle
bs["PRODUCT_NAME"] ||= ui_name
bs["TEST_TARGET_NAME"] ||= app_target&.name || "HelloCodenameOne"
bs["TEST_TARGET_NAME"] ||= app_target&.name || main_name
# Optional but harmless on simulators; avoids other edge cases:
bs["ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES"] = "YES"
bs["TARGETED_DEVICE_FAMILY"] ||= "1,2"
Expand Down Expand Up @@ -350,10 +370,11 @@ if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "workspace=$WORKSPACE"
echo "scheme=$SCHEME"
echo "bundle_id=$CN1_APP_BUNDLE_ID"
} >> "$GITHUB_OUTPUT"
fi

bia_log "Emitted outputs -> workspace=$WORKSPACE, scheme=$SCHEME"
bia_log "Emitted outputs -> workspace=$WORKSPACE, scheme=$SCHEME, bundle_id=$CN1_APP_BUNDLE_ID"

# (Optional) dump xcodebuild -list for debugging
ARTIFACTS_DIR="${ARTIFACTS_DIR:-$REPO_ROOT/artifacts}"
Expand Down
285 changes: 285 additions & 0 deletions scripts/ios/export_xcresult_attachments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
#!/usr/bin/env python3
"""Export image attachments from an xcresult bundle."""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
from collections import deque
from typing import Deque, Dict, Iterable, List, Optional, Sequence, Set, Tuple


def ri_log(message: str) -> None:
"""Mirror the bash script's logging prefix."""
print(f"[run-ios-ui-tests] {message}", file=sys.stderr)


def run_xcresult(args: Sequence[str], allow_failure: bool = False) -> Optional[str]:
cmd = ["xcrun", "xcresulttool", *args]
result = subprocess.run(
cmd,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", "ignore").strip()
ri_log(f"xcresulttool command failed: {' '.join(cmd)}\n{stderr}")
if allow_failure:
return None
raise subprocess.CalledProcessError(
result.returncode,
cmd,
output=result.stdout,
stderr=result.stderr,
)
return result.stdout.decode("utf-8")


def get_json(bundle_path: str, object_id: Optional[str] = None) -> Dict:
args = ["get", "--legacy", "--path", bundle_path, "--format", "json"]
if object_id:
args.extend(["--id", object_id])
output = run_xcresult(args, allow_failure=True)
if not output:
return {}
try:
return json.loads(output)
except json.JSONDecodeError:
ri_log(
"Failed to decode xcresult JSON payload; "
"continuing without structured data"
)
return {}


def extract_id(ref: object) -> Optional[str]:
if isinstance(ref, str):
return ref
if isinstance(ref, dict):
if "id" in ref:
return extract_id(ref["id"])
if "_value" in ref:
value = ref["_value"]
return value if isinstance(value, str) else None
return None


def collect_nodes(node) -> Tuple[List[Dict], List[str]]:
attachments: List[Dict] = []
refs: List[str] = []
stack: List = [node]
while stack:
current = stack.pop()
if isinstance(current, dict):
attachment_block = current.get("attachments")
if isinstance(attachment_block, dict):
for item in attachment_block.get("_values", []):
if isinstance(item, dict):
attachments.append(item)
for key, value in current.items():
if key.endswith("Ref") and isinstance(value, dict):
ref_id = extract_id(value)
if isinstance(ref_id, str) and ref_id:
refs.append(ref_id)
elif key.endswith("Refs") and isinstance(value, dict):
for entry in value.get("_values", []):
if isinstance(entry, dict):
ref_id = extract_id(entry)
if isinstance(ref_id, str) and ref_id:
refs.append(ref_id)
if isinstance(value, (dict, list)):
stack.append(value)
elif isinstance(current, list):
stack.extend(current)
return attachments, refs


def _as_text(value) -> Optional[str]:
if isinstance(value, str):
return value
if isinstance(value, dict):
for key in ("_value", "value", "rawValue", "string", "text"):
candidate = value.get(key)
if isinstance(candidate, str):
return candidate
return None


def _looks_like_image(value) -> bool:
text = _as_text(value)
if not text:
return False
lower = text.lower()
if lower.endswith((".png", ".jpg", ".jpeg")):
return True
keywords = ("png", "jpeg", "image", "screenshot")
return any(keyword in lower for keyword in keywords)


def _first_text(mapping: Optional[Dict], keys: Sequence[str]) -> Optional[str]:
if not isinstance(mapping, dict):
return None
for key in keys:
if key in mapping:
text = _as_text(mapping.get(key))
if text:
return text
return None


def is_image_attachment(attachment: Dict) -> bool:
filename = _first_text(attachment, ["filename", "name"])
if _looks_like_image(filename):
return True

uti_candidates: List[str] = []
for key in ("uniformTypeIdentifier", "contentType", "uti"):
text = _as_text(attachment.get(key))
if text:
uti_candidates.append(text)
payload = (
attachment.get("payloadRef")
or attachment.get("inlinePayloadRef")
or attachment.get("payload")
)
if isinstance(payload, dict):
for key in ("contentType", "uti", "uniformTypeIdentifier"):
text = _as_text(payload.get(key))
if text:
uti_candidates.append(text)

return any(_looks_like_image(candidate) for candidate in uti_candidates)


def sanitize_filename(name: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
return safe or "attachment"


def ensure_extension(name: str, uti: Optional[str]) -> str:
base, ext = os.path.splitext(name)
if ext:
return name
uti = (uti or "").lower()
if "png" in uti:
return f"{name}.png"
if "jpeg" in uti:
return f"{name}.jpg"
return name


def export_attachment(
bundle_path: str, attachment: Dict, destination_dir: str, used_names: Set[str]
) -> None:
payload = (
attachment.get("payloadRef")
or attachment.get("inlinePayloadRef")
or attachment.get("payload")
or {}
)
attachment_id = extract_id(payload)
if not attachment_id:
return
name = _first_text(attachment, ["filename", "name"]) or attachment_id
uti_hint = _first_text(attachment, ["uniformTypeIdentifier", "contentType", "uti"])
if not uti_hint and isinstance(payload, dict):
uti_hint = _first_text(payload, ["uniformTypeIdentifier", "contentType", "uti"])
name = ensure_extension(name, uti_hint)
name = sanitize_filename(name)
candidate = name
counter = 1
while candidate in used_names:
base, ext = os.path.splitext(name)
candidate = f"{base}_{counter}{ext}"
counter += 1
used_names.add(candidate)
output_path = os.path.join(destination_dir, candidate)
run_xcresult(
[
"export",
"--legacy",
"--path",
bundle_path,
"--id",
attachment_id,
"--type",
"file",
"--output-path",
output_path,
]
)
ri_log(f"Exported attachment {candidate}")


def handle_attachments(
bundle_path: str,
items: Iterable[Dict],
destination_dir: str,
used_names: Set[str],
seen_attachment_ids: Set[str],
) -> None:
for attachment in items:
payload = (
attachment.get("payloadRef")
or attachment.get("inlinePayloadRef")
or attachment.get("payload")
or {}
)
attachment_id = extract_id(payload)
if not attachment_id or attachment_id in seen_attachment_ids:
continue
if not is_image_attachment(attachment):
continue
seen_attachment_ids.add(attachment_id)
export_attachment(bundle_path, attachment, destination_dir, used_names)


def export_bundle(bundle_path: str, destination_dir: str) -> bool:
os.makedirs(destination_dir, exist_ok=True)

root = get_json(bundle_path)
attachments, refs = collect_nodes(root)
queue: Deque[str] = deque(refs)
seen_refs: Set[str] = set()
seen_attachment_ids: Set[str] = set()
exported_names: Set[str] = set()

handle_attachments(
bundle_path, attachments, destination_dir, exported_names, seen_attachment_ids
)

while queue:
ref_id = queue.popleft()
if ref_id in seen_refs:
continue
seen_refs.add(ref_id)
data = get_json(bundle_path, ref_id)
items, nested_refs = collect_nodes(data)
handle_attachments(
bundle_path, items, destination_dir, exported_names, seen_attachment_ids
)
for nested in nested_refs:
if nested not in seen_refs:
queue.append(nested)

if not exported_names:
ri_log("No screenshot attachments were exported from xcresult bundle")
return False
return True


def main(argv: Sequence[str]) -> int:
if len(argv) != 3:
ri_log("Expected bundle path and destination directory arguments")
return 1
_, bundle_path, destination_dir = argv
success = export_bundle(bundle_path, destination_dir)
return 0 if success else 2


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
Loading
Loading