Files
buzz/scripts/test-mobile-worktree-overrides.sh
Tom Brow d69b89c43c feat: add internal iOS push MVP
Signed-off-by: Tom Brow <tomb@squareup.com>
2026-08-18 16:23:53 -07:00

629 lines
28 KiB
Bash
Executable File

#!/usr/bin/env bash
# Regression tests for the mobile worktree identity contract:
# - scripts/mobile-worktree-overrides.sh writes debug-only override files in
# a worktree and removes them in the main checkout.
# - install identity is keyed to the worktree DIRECTORY (stable across
# branch switches); the branch (or short SHA when detached) is a
# display-only label sanitized to [A-Za-z0-9._-].
# - the tracked iOS/Android build files keep production identity, only
# consume the overrides in debug configurations, and let a developer's
# AppOverrides.xcconfig / AppOverrides.properties take precedence over the
# worktree defaults.
# - scripts/mobile-worktree-clean.sh only ever targets suffixed installs,
# never the production app ids.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/mobile-worktree-overrides.sh"
clean_script="$repo_root/scripts/mobile-worktree-clean.sh"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
failures=0
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
pass() {
printf 'ok: %s\n' "$1"
}
make_repo() {
# $1: repo dir, $2: initial branch name
local repo="$1" branch="$2"
mkdir -p "$repo/scripts" "$repo/mobile/ios/Flutter" "$repo/mobile/android"
cp "$script" "$repo/scripts/mobile-worktree-overrides.sh"
git -C "$repo" init -q -b "$branch"
git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init
}
make_worktree() {
# $1: source repo, $2: worktree dir, $3: branch to create
local repo="$1" wt="$2" branch="$3"
git -C "$repo" worktree add -q -b "$branch" "$wt"
mkdir -p "$wt/scripts" "$wt/mobile/ios/Flutter" "$wt/mobile/android"
cp "$script" "$wt/scripts/mobile-worktree-overrides.sh"
}
# ── Main checkout: no overrides, stale files removed ─────────────────────────
repo="$tmp/main-checkout"
make_repo "$repo" main
echo stale > "$repo/mobile/ios/Flutter/WorktreeOverrides.xcconfig"
echo stale > "$repo/mobile/android/worktree.properties"
"$repo/scripts/mobile-worktree-overrides.sh" > /dev/null
if [[ -e "$repo/mobile/ios/Flutter/WorktreeOverrides.xcconfig" || -e "$repo/mobile/android/worktree.properties" ]]; then
fail "main checkout must remove stale worktree override files"
else
pass "main checkout removes stale worktree override files"
fi
# ── Worktree: identity from DIRECTORY name, label from branch ────────────────
wt="$tmp/Feature_Work-1"
make_worktree "$repo" "$wt" "tho/Fix_Thing-2"
out="$("$wt/scripts/mobile-worktree-overrides.sh")"
ios="$wt/mobile/ios/Flutter/WorktreeOverrides.xcconfig"
android="$wt/mobile/android/worktree.properties"
[[ -f "$ios" && -f "$android" ]] || fail "worktree must write both override files"
grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile\.feature-work-1$' "$ios" \
&& pass "iOS bundle identifier keys to the sanitized worktree directory name" \
|| fail "iOS bundle identifier must key to the worktree dir, got: $(cat "$ios")"
grep -q '^APP_DISPLAY_NAME = Buzz (Fix_Thing-2)$' "$ios" \
&& pass "iOS display name carries the branch label" \
|| fail "iOS display name wrong: $(cat "$ios")"
grep -q '^label=Fix_Thing-2$' "$android" \
&& pass "Android label carries the branch label" \
|| fail "Android label wrong: $(cat "$android")"
grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \
&& pass "Android applicationIdSuffix keys to the worktree directory name" \
|| fail "Android applicationIdSuffix wrong: $(cat "$android")"
printf '%s' "$out" | grep -q 'Worktree Feature_Work-1' \
&& pass "worktree run reports the worktree name" \
|| fail "worktree run must report the worktree name, got: $out"
# ── Branch switch in the same worktree: identity stable, label follows ───────
git -C "$wt" checkout -q -b "another/branch-name"
"$wt/scripts/mobile-worktree-overrides.sh" > /dev/null
grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile\.feature-work-1$' "$ios" \
&& grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \
&& pass "branch switch keeps the install identity stable (per worktree)" \
|| fail "install identity must not change on branch switch"
grep -q '^label=branch-name$' "$android" \
&& pass "branch switch updates the display label" \
|| fail "display label must follow the branch, got: $(cat "$android")"
# ── Apostrophes / exotic-but-valid refs: label is sanitized ──────────────────
git -C "$wt" checkout -q -b "it's-\$a\"branch"
"$wt/scripts/mobile-worktree-overrides.sh" > /dev/null
grep -q "^label=it-s-a-branch$" "$android" \
&& pass "apostrophes and shell metacharacters are sanitized out of the label" \
|| fail "label must sanitize special chars, got: $(cat "$android")"
grep -Eq "^APP_DISPLAY_NAME = Buzz \([A-Za-z0-9._-]+\)$" "$ios" \
&& pass "iOS display name only contains resource-safe characters" \
|| fail "iOS display name has unsafe characters: $(cat "$ios")"
# ── Detached HEAD: label falls back to the short SHA ──────────────────────────
sha="$(git -C "$wt" rev-parse --short HEAD)"
git -C "$wt" checkout -q --detach
"$wt/scripts/mobile-worktree-overrides.sh" > /dev/null
grep -q "^label=${sha}$" "$android" \
&& pass "detached HEAD labels with the short SHA instead of literal HEAD" \
|| fail "detached HEAD must use short SHA, got: $(cat "$android")"
grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \
&& pass "detached HEAD keeps the per-worktree install identity" \
|| fail "detached HEAD must not change the install identity"
# ── Digit-leading worktree dir gets a letter-prefixed Android segment ────────
wt2="$tmp/2fast"
make_worktree "$repo" "$wt2" "some-branch"
"$wt2/scripts/mobile-worktree-overrides.sh" > /dev/null
grep -q '^applicationIdSuffix=\.w_2fast$' "$wt2/mobile/android/worktree.properties" \
&& pass "digit-leading worktree dir yields a valid Android package segment" \
|| fail "digit-leading dir segment wrong: $(cat "$wt2/mobile/android/worktree.properties")"
# ── Tracked build files: overrides are debug-only, release stays production ──
debug_xcconfig="$repo_root/mobile/ios/Flutter/Debug.xcconfig"
release_xcconfig="$repo_root/mobile/ios/Flutter/Release.xcconfig"
push_xcconfig="$repo_root/mobile/ios/Flutter/PushEnabled.xcconfig"
pbxproj="$repo_root/mobile/ios/Runner.xcodeproj/project.pbxproj"
runner_entitlements="$repo_root/mobile/ios/Runner/Runner.entitlements"
runner_push_entitlements="$repo_root/mobile/ios/Runner/RunnerPush.entitlements"
gradle="$repo_root/mobile/android/app/build.gradle.kts"
manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml"
plist="$repo_root/mobile/ios/Runner/Info.plist"
grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile$' "$debug_xcconfig" \
&& pass "Debug.xcconfig defaults to the dogfood bundle identifier" \
|| fail "Debug.xcconfig must default to xyz.block.buzz.dogfood.mobile"
grep -q 'WorktreeOverrides.xcconfig' "$debug_xcconfig" \
&& pass "Debug.xcconfig includes WorktreeOverrides" \
|| fail "Debug.xcconfig must include WorktreeOverrides.xcconfig"
worktree_line=$(grep -n 'WorktreeOverrides.xcconfig' "$debug_xcconfig" | cut -d: -f1 | head -1)
app_line=$(grep -n 'AppOverrides.xcconfig' "$debug_xcconfig" | grep '#include' | cut -d: -f1 | tail -1)
if [[ -n "$worktree_line" && -n "$app_line" && "$worktree_line" -lt "$app_line" ]]; then
pass "AppOverrides is included after WorktreeOverrides (developer overrides win)"
else
fail "Debug.xcconfig must include AppOverrides.xcconfig after WorktreeOverrides.xcconfig"
fi
grep -q '^ios_prefix="xyz.block.buzz.dogfood.mobile\."$' "$clean_script" \
&& pass "cleanup targets the iOS dogfood worktree prefix" \
|| fail "cleanup must share the iOS dogfood prefix used by worktree overrides"
grep -q 'WorktreeOverrides' "$release_xcconfig" \
&& fail "Release.xcconfig must not include WorktreeOverrides.xcconfig" \
|| pass "Release.xcconfig does not include WorktreeOverrides"
grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.mobile$' "$release_xcconfig" \
&& pass "Release.xcconfig keeps the production bundle identifier" \
|| fail "Release.xcconfig must keep BUNDLE_IDENTIFIER = xyz.block.buzz.mobile"
grep -q '^APP_DISPLAY_NAME = Buzz$' "$release_xcconfig" \
&& pass "Release.xcconfig keeps the production display name" \
|| fail "Release.xcconfig must keep APP_DISPLAY_NAME = Buzz"
# These checks assert declarations in the two tracked xcconfigs only. They do
# not prove resolved build settings. The later gitignored includes
# (WorktreeOverrides.xcconfig and AppOverrides.xcconfig) can override these
# declarations and are explicitly outside this tracked-source assertion. The
# value check and declaration census are complementary: xcconfig is last-wins,
# while the census deliberately flags even a harmless duplicate declaration so
# a human reviews the changed declaration surface.
assert_xcconfig_value() {
# $1: file, $2: anchored value regex, $3: pass/failure description
if grep -qE "$2" "$1"; then
pass "$3"
else
fail "$3"
fi
}
assert_xcconfig_declaration_count() {
# $1: file, $2: key, $3: expected count, $4: configuration label
local file="$1" key="$2" expected="$3" label="$4" count
count=$(grep -cE "^[[:space:]]*$key([[:space:]]*\[[^]]*\])*[[:space:]]*=" "$file" || true)
if [[ "$count" -eq "$expected" ]]; then
if [[ "$expected" -eq 0 ]]; then
pass "$label $key has no tracked declaration sites"
elif [[ "$expected" -eq 1 ]]; then
pass "$label $key has one tracked declaration site"
else
pass "$label $key has $count tracked declaration sites"
fi
elif [[ "$expected" -eq 0 ]]; then
fail "$label $key has $count tracked declaration sites; expected zero"
elif [[ "$expected" -eq 1 ]]; then
fail "$label $key has $count tracked declaration sites; expected exactly one"
else
fail "$label $key has $count tracked declaration sites; expected $expected"
fi
}
assert_single_xcconfig_declaration() {
# $1: file, $2: key, $3: configuration label
assert_xcconfig_declaration_count "$1" "$2" 1 "$3"
}
for config in "$debug_xcconfig" "$release_xcconfig"; do
assert_xcconfig_value "$config" '^BUZZ_PUSH_ENABLED = NO$' \
"$(basename "$config") defaults push capability off"
assert_xcconfig_value "$config" \
'^BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements$' \
"$(basename "$config") uses the push-free Runner entitlements"
assert_xcconfig_value "$config" \
'^EXCLUDED_SOURCE_FILE_NAMES = NotificationService\.appex PushNativeState\.swift PushEndpointGrantStore\.swift$' \
"$(basename "$config") excludes native push sources and extension product"
done
assert_xcconfig_value "$push_xcconfig" '^BUZZ_PUSH_ENABLED = YES$' \
"PushEnabled explicitly enables the capability"
assert_xcconfig_value "$push_xcconfig" \
'^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile$' \
"PushEnabled selects the internal dogfood bundle"
assert_xcconfig_value "$push_xcconfig" \
'^BUZZ_IOS_PUSH_ENVIRONMENT = production$' \
"PushEnabled uses production APNs transport for distribution"
assert_xcconfig_value "$push_xcconfig" \
'^BUZZ_APP_ATTEST_ENVIRONMENT = production$' \
"PushEnabled uses production App Attest"
assert_xcconfig_value "$push_xcconfig" \
'^BUZZ_APP_GROUP_IDENTIFIER = group\.\$\(BUNDLE_IDENTIFIER\)$' \
"PushEnabled derives the App Group from the dogfood bundle"
assert_xcconfig_value "$push_xcconfig" \
'^BUZZ_KEYCHAIN_ACCESS_GROUP = \$\(BUNDLE_IDENTIFIER\)$' \
"PushEnabled derives the Keychain access group from the dogfood bundle"
assert_xcconfig_value "$push_xcconfig" \
'^BUZZ_CODE_SIGN_ENTITLEMENTS = Runner/RunnerPush\.entitlements$' \
"PushEnabled selects push-capable Runner entitlements"
assert_xcconfig_value "$push_xcconfig" \
'^SWIFT_ACTIVE_COMPILATION_CONDITIONS = \$\(inherited\) BUZZ_PUSH_ENABLED$' \
"PushEnabled compiles the native push bridge"
assert_xcconfig_value "$push_xcconfig" '^EXCLUDED_SOURCE_FILE_NAMES =$' \
"PushEnabled restores the extension product and native push sources"
assert_xcconfig_value "$push_xcconfig" \
'^DART_DEFINES = \$\(inherited\),QlVaWl9QVVNIX0VOQUJMRUQ9dHJ1ZQ==$' \
"PushEnabled compiles the Dart push bootstrap"
assert_xcconfig_value "$release_xcconfig" \
'^CODE_SIGN_STYLE = Automatic$' \
"Release code signing style is declared as automatic"
assert_xcconfig_value "$release_xcconfig" \
'^CODE_SIGN_IDENTITY = iPhone Developer$' \
"Release code signing identity is declared as iPhone Developer"
for key in BUNDLE_IDENTIFIER BUZZ_PUSH_ENABLED BUZZ_CODE_SIGN_ENTITLEMENTS EXCLUDED_SOURCE_FILE_NAMES; do
assert_single_xcconfig_declaration "$debug_xcconfig" "$key" "Debug"
assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release"
done
for key in BUZZ_KEYCHAIN_ACCESS_GROUP BUZZ_IOS_PUSH_ENVIRONMENT BUZZ_APP_ATTEST_ENVIRONMENT BUZZ_APP_GROUP_IDENTIFIER; do
assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug"
assert_xcconfig_declaration_count "$release_xcconfig" "$key" 0 "Release"
assert_single_xcconfig_declaration "$push_xcconfig" "$key" "PushEnabled"
done
for key in CODE_SIGN_STYLE CODE_SIGN_IDENTITY; do
assert_xcconfig_declaration_count "$debug_xcconfig" "$key" 0 "Debug"
assert_single_xcconfig_declaration "$release_xcconfig" "$key" "Release"
done
assert_xcconfig_declaration_count \
"$debug_xcconfig" PROVISIONING_PROFILE_SPECIFIER 0 "Debug"
assert_xcconfig_declaration_count \
"$release_xcconfig" PROVISIONING_PROFILE_SPECIFIER 0 "Release"
assert_single_xcconfig_declaration "$debug_xcconfig" APP_DISPLAY_NAME "Debug"
assert_single_xcconfig_declaration "$release_xcconfig" APP_DISPLAY_NAME "Release"
# SWIFT_ACTIVE_COMPILATION_CONDITIONS is checked separately from the closed
# identity census above. This is a tracked-source assertion only; resolved Xcode
# build settings are intentionally outside this Linux-compatible test.
assert_xcconfig_value "$debug_xcconfig" \
'^SWIFT_ACTIVE_COMPILATION_CONDITIONS = \$\(inherited\) DEBUG$' \
"Debug Swift compilation conditions inherit DEBUG"
debug_swift_condition_count=$(grep -cE \
'^[[:space:]]*SWIFT_ACTIVE_COMPILATION_CONDITIONS([[:space:]]*\[[^]]*\])*[[:space:]]*=' \
"$debug_xcconfig" || true)
if [[ "$debug_swift_condition_count" -eq 1 ]]; then
pass "Debug SWIFT_ACTIVE_COMPILATION_CONDITIONS has one tracked declaration site"
else
fail "Debug SWIFT_ACTIVE_COMPILATION_CONDITIONS has $debug_swift_condition_count tracked declaration sites; expected exactly one"
fi
release_swift_condition_count=$(grep -cE \
'^[[:space:]]*SWIFT_ACTIVE_COMPILATION_CONDITIONS([[:space:]]*\[[^]]*\])*[[:space:]]*=' \
"$release_xcconfig" || true)
if [[ "$release_swift_condition_count" -eq 0 ]]; then
pass "Release SWIFT_ACTIVE_COMPILATION_CONDITIONS has no tracked declaration sites"
else
fail "Release SWIFT_ACTIVE_COMPILATION_CONDITIONS has $release_swift_condition_count tracked declaration sites; expected zero"
fi
grep -q '<key>aps-environment</key>' "$runner_entitlements" \
&& fail "push-free Runner entitlements must not contain aps-environment" \
|| pass "push-free Runner entitlements omit aps-environment"
# Split the retired identifiers so the regression test does not match itself.
retired_bundle_id='com.buzz.buzz'"Mobile"
if git -C "$repo_root" grep -q -F "$retired_bundle_id"; then
fail "tracked files must not retain the retired iOS bundle identifier"
else
pass "tracked files do not retain the retired iOS bundle identifier"
fi
grep -q '<key>com.apple.developer.devicecheck.appattest-environment</key>' "$runner_push_entitlements" \
&& pass "push-enabled Runner uses the App Attest entitlement key accepted by Apple" \
|| fail "push-enabled Runner must use com.apple.developer.devicecheck.appattest-environment"
retired_entitlement_key='com.apple.developer.app-attest.'"environment"
if grep -q "$retired_entitlement_key" "$runner_push_entitlements"; then
fail "push-enabled Runner must not retain the invalid App Attest entitlement key"
else
pass "push-enabled Runner omits the invalid App Attest entitlement key"
fi
duplicate_pbx_object_ids=$(awk '
# This bounded source-level smoke check recognizes the current two-tab
# object-key spellings. It is not a general OpenStep uniqueness check:
# measured exclusions include a comment before the key, a presentation
# comment spanning lines, and one-tab indentation (jb_b1/jb_b2/jb_b6).
# The macOS semantic check below owns their resolved build consequences.
function decomment(s, head, tailpart) {
while (match(s, /\/\*/)) {
head = substr(s, 1, RSTART - 1)
tailpart = substr(s, RSTART + 2)
if (!match(tailpart, /\*\//)) return head " "
s = head " " substr(tailpart, RSTART + RLENGTH)
}
return s
}
/^\t\t/ {
line = decomment($0)
if (match(line, /^\t\t"?[[:alnum:]]+"?[[:space:]]*=/)) {
object_id = substr(line, RSTART, RLENGTH)
sub(/^\t\t"?/, "", object_id)
sub(/"?[[:space:]]*=$/, "", object_id)
if (++object_id_count[object_id] == 2) print object_id
}
}
' "$pbxproj" | sort)
if [[ -n "$duplicate_pbx_object_ids" ]]; then
fail "recognized iOS project object identifiers repeat: $(printf '%s\n' "$duplicate_pbx_object_ids" | paste -sd ' ' -)"
else
pass "recognized iOS project object identifiers do not repeat"
fi
signing_map=$(awk '
# PBX comments are separators, not text: strip them before parsing any
# object so a comment cannot hide a duplicate key from the ambiguity count.
function decomment(s, head, tailpart) {
while (match(s, /\/\*/)) {
head = substr(s, 1, RSTART - 1)
tailpart = substr(s, RSTART + 2)
if (!match(tailpart, /\*\//)) { return head " " }
s = head " " substr(tailpart, RSTART + RLENGTH)
}
return s
}
FNR == 1 { pass++ }
# Pass 1 indexes xcconfig paths and follows each PBXNativeTarget to its
# actual configuration-list object. Target names come from object fields,
# not presentation comments.
pass == 1 {
if (/isa[[:space:]]*=[[:space:]]*PBXFileReference/ && /\.xcconfig/) {
declaration = decomment($0)
if (match(declaration, /=[[:space:]]*\{[[:space:]]*isa[[:space:]]*=[[:space:]]*PBXFileReference[[:space:]]*;/)) {
declaration = substr(declaration, RSTART + RLENGTH)
} else {
declaration = ""
}
sub(/\}.*/, "", declaration)
xcconfig_path = "MISSING_PATH"
rest = declaration
path_matches = 0
while (match(rest, /(^|;)[[:space:]]*path[[:space:]]*=[[:space:]]*[^;]+/)) {
candidate = substr(rest, RSTART, RLENGTH)
rest = substr(rest, RSTART + RLENGTH)
sub(/^;?[[:space:]]*path[[:space:]]*=[[:space:]]*/, "", candidate)
gsub(/"/, "", candidate)
sub(/[[:space:]]+$/, "", candidate)
path_matches++
xcconfig_path = candidate
}
# More than one `path =` in one object means a decoy (a quoted value or
# an embedded comment) is shadowing the real key. Never guess which one
# the build uses: fail the row loudly instead.
if (path_matches > 1) xcconfig_path = "AMBIGUOUS_PATH"
xcconfig_paths[$1] = xcconfig_path
}
if (/\/\* Begin PBXNativeTarget section \*\//) {
in_native_targets = 1
next
}
if (/\/\* End PBXNativeTarget section \*\//) {
in_native_targets = 0
next
}
if (!in_native_targets) next
if (/^\t\t[^[:space:]]+ .* = \{$/) {
native_target_id = $1
native_target_name = ""
native_target_list = ""
next
}
if (native_target_id != "" && /^\t\t\tname = /) {
native_target_name = $0
sub(/^.*= */, "", native_target_name)
sub(/;.*/, "", native_target_name)
gsub(/"/, "", native_target_name)
next
}
if (native_target_id != "" && /^\t\t\tbuildConfigurationList = /) {
native_target_list = $3
next
}
if (native_target_id != "" && /^\t\t\};/) {
if (native_target_list != "") {
if (native_target_name == "") native_target_name = "UNNAMED:" native_target_id
if (native_target_list in list_owners) {
list_owners[native_target_list] = "DUPLICATE:" list_owners[native_target_list] "+" native_target_name
} else {
list_owners[native_target_list] = native_target_name
}
}
native_target_id = ""
}
next
}
# Pass 2 maps build-configuration object IDs through only those lists that
# real native targets own. PBXProject and other unowned lists are ignored.
pass == 2 {
if (/\/\* Begin XCConfigurationList section \*\//) {
in_configuration_lists = 1
next
}
if (/\/\* End XCConfigurationList section \*\//) {
in_configuration_lists = 0
next
}
if (!in_configuration_lists) next
if (/^\t\t[^[:space:]]+ .* = \{$/) {
configuration_list_id = $1
configuration_list_owner = configuration_list_id in list_owners ? list_owners[configuration_list_id] : ""
next
}
if (/buildConfigurations = \(/) {
in_list_configurations = 1
next
}
if (in_list_configurations && /\);/) {
in_list_configurations = 0
next
}
if (in_list_configurations && $1 ~ /^[[:alnum:]]+$/ && configuration_list_owner != "") {
if ($1 in targets) targets[$1] = "DUPLICATE:" targets[$1] "+" configuration_list_owner
else targets[$1] = configuration_list_owner
}
next
}
# Pass 3 emits one row for each team-bearing build configuration.
!in_build_configuration && /\/\* (Debug|Release|Profile) \*\/ = \{/ {
in_build_configuration = 1
configuration_id = $1
configuration = $3
base_configuration = "NONE"
team = ""
entitlements = "NONE"
depth = 0
}
in_build_configuration {
if (/baseConfigurationReference =/) {
base_configuration = $3 in xcconfig_paths ? xcconfig_paths[$3] : "UNRESOLVED:" $3
}
if (/DEVELOPMENT_TEAM =/) {
team = $0
sub(/^.*= */, "", team)
sub(/;.*/, "", team)
}
if (/CODE_SIGN_ENTITLEMENTS =/) {
entitlements = $0
sub(/^.*= */, "", entitlements)
sub(/;.*/, "", entitlements)
}
depth += gsub(/\{/, "{") - gsub(/\}/, "}")
if (depth == 0) {
if (team != "") {
target_name = configuration_id in targets ? targets[configuration_id] : "UNMAPPED:" configuration_id
print target_name, configuration, base_configuration, team, entitlements
}
in_build_configuration = 0
}
}
' "$pbxproj" "$pbxproj" "$pbxproj" | sort)
expected_signing_map=$(printf '%s\n' \
'NotificationService Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \
'NotificationService Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \
'NotificationService Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" NotificationService/NotificationService.entitlements' \
'Runner Debug Flutter/Debug.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"' \
'Runner Profile Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"' \
'Runner Release Flutter/Release.xcconfig "$(BUZZ_DEVELOPMENT_TEAM)" "$(BUZZ_CODE_SIGN_ENTITLEMENTS)"')
if [[ "$signing_map" == "$expected_signing_map" ]]; then
pass "Runner and NotificationService signing settings match each build configuration"
else
fail "unexpected iOS signing map: $signing_map"
fi
grep -q '<string>$(APP_DISPLAY_NAME)</string>' "$plist" \
&& pass "Info.plist display name resolves from build settings" \
|| fail "Info.plist CFBundleDisplayName must be \$(APP_DISPLAY_NAME)"
grep -q 'android:label="@string/app_name"' "$manifest" \
&& pass "Android manifest label resolves from resources" \
|| fail "Android manifest label must be @string/app_name"
grep -q 'resValue("string", "app_name", "Buzz")' "$gradle" \
&& pass "Gradle default app_name stays Buzz" \
|| fail "Gradle must declare the default app_name resValue"
grep -q 'worktreeLabel.matches' "$gradle" \
&& pass "Gradle validates the worktree label before use" \
|| fail "Gradle must validate the worktree label against a safe pattern"
grep -q 'AppOverrides.properties' "$gradle" \
&& grep -q 'debugAppName' "$gradle" \
&& grep -q 'debugIdSuffix' "$gradle" \
&& pass "Android developer overrides can replace the debug name and identity" \
|| fail "Gradle must support debug-only AppOverrides.properties"
# Extract a brace-balanced block: everything from the first line matching $2
# to the line where its braces close. Unlike a /start/,/}/ awk range, nested
# blocks cannot end the scan early.
extract_block() {
# $1: file (or - for stdin), $2: start regex
awk -v start="$2" '
!in_block && $0 ~ start { in_block = 1 }
in_block {
print
depth += gsub(/\{/, "{") - gsub(/\}/, "}")
if (depth <= 0) exit
}
' "$1"
}
# Self-test: the extractor must see past a nested block — this is exactly the
# hole the old /release \{/,/\}/ range had.
sneaky=$'buildTypes {\n release {\n if (nested) {\n x = 1\n }\n worktreeSneakyReference()\n }\n}'
printf '%s\n' "$sneaky" | extract_block - 'release \{' | grep -q 'worktreeSneakyReference' \
&& pass "release-block extractor scans past nested braces" \
|| fail "release-block extractor must not stop at the first nested close brace"
# The worktree suffix/label must only appear inside the debug build type.
release_block="$(extract_block "$gradle" 'buildTypes \{' | extract_block - 'release \{')"
printf '%s\n' "$release_block" | grep -Eq 'worktree|debugAppName|debugIdSuffix' \
&& fail "release build type must not reference debug identity overrides" \
|| pass "release build type does not reference debug identity overrides"
git -C "$repo_root" check-ignore -q mobile/ios/Flutter/WorktreeOverrides.xcconfig \
&& pass "iOS override file is gitignored" \
|| fail "mobile/ios/Flutter/WorktreeOverrides.xcconfig must be gitignored"
git -C "$repo_root" check-ignore -q mobile/android/worktree.properties \
&& pass "Android override file is gitignored" \
|| fail "mobile/android/worktree.properties must be gitignored"
git -C "$repo_root" check-ignore -q mobile/android/AppOverrides.properties \
&& pass "Android developer override file is gitignored" \
|| fail "mobile/android/AppOverrides.properties must be gitignored"
grep -Eq '^\s+\./scripts/mobile-worktree-overrides\.sh$' "$repo_root/Justfile" \
&& pass "just mobile-dev applies the worktree identity" \
|| fail "Justfile mobile-dev must run scripts/mobile-worktree-overrides.sh"
grep -Eq '^\s+\./scripts/mobile-worktree-clean\.sh$' "$repo_root/Justfile" \
&& pass "just mobile-clean is wired to the cleanup script" \
|| fail "Justfile mobile-clean must run scripts/mobile-worktree-clean.sh"
# ── Cleanup safety: suffixed installs matched, production ids preserved ──────
stub_bin="$tmp/stub-bin"
mkdir -p "$stub_bin"
cat > "$stub_bin/adb" <<'STUB'
#!/usr/bin/env bash
case "$1 $2" in
"devices ") printf 'List of devices attached\nemulator-5554\tdevice\n' ;;
esac
if [[ "$1" == "devices" ]]; then exit 0; fi
if [[ "$3 $4 $5" == "shell pm list" ]]; then
printf 'package:xyz.block.buzz.mobile\n'
printf 'package:xyz.block.buzz.mobile.feature_work_1\n'
printf 'package:xyz.block.buzz.mobile.w_2fast\n'
printf 'package:com.android.settings\n'
exit 0
fi
if [[ "$3" == "uninstall" ]]; then echo Success; exit 0; fi
exit 0
STUB
chmod +x "$stub_bin/adb"
# No xcrun stub: the iOS pass is skipped when xcrun is absent, which also
# keeps this test honest on Linux CI.
clean_out="$(PATH="$stub_bin:/usr/bin:/bin" bash "$clean_script" --dry-run)"
printf '%s\n' "$clean_out" | grep -q 'xyz\.block\.buzz\.mobile\.feature_work_1' \
&& pass "cleanup targets worktree-suffixed Android installs" \
|| fail "cleanup must list suffixed installs, got: $clean_out"
printf '%s\n' "$clean_out" | grep -q 'xyz\.block\.buzz\.mobile\.w_2fast' \
&& pass "cleanup targets letter-prefixed suffixed installs" \
|| fail "cleanup must list w_-prefixed installs, got: $clean_out"
printf '%s\n' "$clean_out" | grep -q 'mobile\.feature_work_1' || true
if printf '%s\n' "$clean_out" | grep -Eq '(would uninstall|uninstalling).*xyz\.block\.buzz\.mobile$'; then
fail "cleanup must never target the production Android app id"
else
pass "cleanup preserves the production Android app id"
fi
if printf '%s\n' "$clean_out" | grep -q 'com\.android\.settings'; then
fail "cleanup must never target unrelated packages"
else
pass "cleanup ignores unrelated packages"
fi
printf '%s\n' "$clean_out" | grep -q 'dry run:' \
&& pass "cleanup --dry-run reports without uninstalling" \
|| fail "cleanup --dry-run must report a dry-run summary, got: $clean_out"
if [[ "$failures" -gt 0 ]]; then
printf '%d failure(s)\n' "$failures" >&2
exit 1
fi
printf 'all mobile worktree identity contract checks passed\n'