#!/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 'aps-environment' "$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 'com.apple.developer.devicecheck.appattest-environment' "$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 '$(APP_DISPLAY_NAME)' "$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'