From ae56a0accc0ca2cc7f28d55d9abcebd52368ec32 Mon Sep 17 00:00:00 2001 From: GenericJam Date: Sat, 5 Sep 2026 00:52:24 -0600 Subject: [PATCH] Document the user-facing changes from the last few releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things shipped without reaching any user-facing doc. Each was findable only by reading source or a CHANGELOG entry. `mix mob.mutate` had no README entry at all. It rewrites source files in place, so the section says that plainly, along with why it refuses to run on a dirty tree and why roughly a third of Elixir line-deletions land in "did not build" rather than being real kills. `mix mob.deploy`'s exit status changed materially and was documented nowhere. It now fails on a failed device, on every device of a *named* platform being skipped, on a named `--device` that got nothing, and on `--native` building nothing for a platform you asked for — while a plain run with an unrelated phone attached still exits 0. Anyone with CI around this needs to know which of those apply to them. Unknown options are refused rather than ignored, which is a breaking change for anyone who had a typo in a script and never noticed, and `-d` now means `--device` where it previously meant nothing at all. `:ios_bundle_id` existed only in AGENTS.md and a decision record, so the one audience who needs it — someone publishing to TestFlight whose Android applicationId contains an underscore Apple will reject — could not find it. It now sits in the TestFlight guide immediately after the section that tells you to pick a real bundle id, which is where the problem is first met. Also adds `Mob.Test.capabilities/1` to the agent section, since choosing a driving strategy before discovering a probe is unavailable is the entire point of it. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 107 +++++++++++++++++++++++++++++ guides/publishing_to_testflight.md | 32 +++++++++ 2 files changed, 139 insertions(+) diff --git a/README.md b/README.md index 182ad6a..6424b2a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ end | `mix mob.watch_stop` | Stop a running `mix mob.watch` | | `mix mob.devices` | List connected devices and their status | | `mix mob.attest` | Prove a device is running the code you just pushed — compares module digests, not artifacts ([see below](#did-that-deploy-actually-land-mix-mobattest)) | +| `mix mob.mutate` | Mutation-test the lines this branch changed: break the code on purpose and report what nothing noticed ([see below](#do-the-tests-guard-anything-mix-mobmutate)) | | `mix mob.push` | Hot-push only changed modules (no restart) | | `mix mob.enable ...` | Wire up an optional Mob feature — platform-manifest entries, Elixir stubs, dep injections ([see below](#mix-mobenable-feature)) | | `mix mob.add_nif ` | Scaffold a statically-linked NIF — Elixir stub + `mob.exs` `:static_nifs` append + optional native skeleton ([see below](#mix-mobadd_nif-name)) | @@ -128,6 +129,105 @@ a check that could not run is not a check that passed. Modules the device has not loaded yet are reported and are **not** a failure: interactive BEAM loads a module when something first calls it, so most of a bundle is legitimately unloaded at any moment. +### Exit status and argument handling + +`mix mob.deploy` exits non-zero when: + +- any device **failed**, including a partial success where others deployed; +- every device of a platform you **named** was skipped — `mix mob.deploy --ios` + where every iOS device lacked the app. A skip stays non-fatal when it is + incidental, so a plain `mix mob.deploy` with an unrelated phone attached + still exits 0, and one simulator deploying while a stale one is skipped is a + success; +- you named `--device X` and nothing was deployed to it, or no device matched; +- `--native` built nothing for a platform you **named** — a missing `sdk.dir` + in `android/local.properties` under `--android --native`, say. A plain + `mix mob.deploy --native` that skips a platform nobody asked for still + exits 0; +- you **named** a platform and no device of it was connected at all, which is + also what `--ios` on Linux does. + +A `--native` run that built the artifact and found no device to push it to +still exits 0: "build the APK now, attach the phone after" is a legitimate +workflow. + +**Unrecognised options and stray arguments are refused rather than ignored.** +`mix mob.deploy -d ` previously deployed to *every* connected device — +`-d` was not an alias here — and a typo'd `--devcie` did the same, silently. +So did `mix mob.deploy --native ABC123`, a natural fumble of `--device`: it +parsed cleanly and deployed everywhere. All three now fail and name what was +wrong, distinguishing an unrecognised flag from a recognised one with a bad +value. `-d` is aliased to `--device`, matching `mix mob.connect`. + +**`--beam-flags` accepts both spellings.** `OptionParser` will not consume a +dash-prefixed argument as a value, so `mix mob.deploy` joins them before +parsing: + +```bash +mix mob.deploy --beam-flags "-S 4:4 -A 4" +mix mob.deploy --beam-flags="-S 4:4 -A 4" +``` + +`--json` prints a machine-readable result on stdout: an `outcome` mirroring the +exit code, a `message`, and the deployed, failed and skipped devices with their +per-device reasons. Progress goes to stderr, so `mix mob.deploy --json | jq` +receives exactly one document. + +## Do the tests guard anything? (`mix mob.mutate`) + +A green suite says the tests ran, not that they guard anything. This changes +the production code one line at a time, runs the suite, and reports the changes +nothing noticed. + +```bash +mix mob.mutate # lines this branch changed +mix mob.mutate --base main # diff base (default: origin/master) +mix mob.mutate --file lib/foo.ex # every mutable line in a file; repeatable +mix mob.mutate --max 20 # bound a first run +mix mob.mutate --test-command "mix test test/foo_test.exs" +mix mob.mutate --json +``` + +**Every mutant runs the suite once**, so a run costs roughly +`mutations × suite`. Narrow it with `--test-command` and bound it with +`--max` before pointing it at a large diff. `--base` defaults to +`origin/master`; on a repo whose default branch is `main`, or a shallow CI +checkout, pass it explicitly or the run fails rather than silently measuring +nothing. + +It exits non-zero when any mutation survived, so it can gate a change the way +a failing test does. + +``` +89 killed, 6 survived, 57 did not build, 0 unmeasured + +Nothing noticed these changes: + + lib/foo.ex:188 delete: file: file, + lib/foo.ex:196 flip comparison: if count == 0 do + … +``` + +Three operators: delete the line, flip a boolean, flip a comparison. Deletion +is the blunt one and the most informative — it asks whether anything notices +the line exists at all, which is the question a vacuous test fails. + +A mutant that fails to compile is counted apart from a real kill — it died +without any test noticing, so counting it as a win inflates the score. So is a +run that could not be measured at all. + +**It rewrites real source files** and restores them from memory, so an +interrupted run leaves the last mutant on disk. It therefore refuses to start +unless the files it would touch are clean in git, which makes +`git checkout -- ` a complete recovery. + +In default mode that means it will essentially always refuse until you commit +or stash: the lines it targets are the ones you just changed. Commit first, +then mutate. + +Expect roughly a third of the mutants on idiomatic Elixir to land in "did not +build": removing a `def` head or a middle segment of a pipeline is a syntax +error, not a test failure. ## `mix mob.enable ` @@ -602,6 +702,13 @@ Agent (Claude Code) ```elixir node = :"my_app_ios@127.0.0.1" +# What can this build actually be probed with? Ask before choosing an approach +# — on Android a probe is unavailable when the app's generated MobBridge.kt +# lacks the method, and on iOS the whole harness is compiled out of release +# builds. A freshly generated Android app has no synthetic input at all. +Mob.Test.capabilities(node) +#=> %{dist_rpc: true, tap_xy: false, screenshot: true, element_frames: true, ...} + # Inspection Mob.Test.screen(node) #=> MyApp.HomeScreen Mob.Test.assigns(node) #=> %{count: 3, user: %{name: "Alice"}, ...} diff --git a/guides/publishing_to_testflight.md b/guides/publishing_to_testflight.md index 94b916e..51db4c0 100644 --- a/guides/publishing_to_testflight.md +++ b/guides/publishing_to_testflight.md @@ -82,6 +82,38 @@ Examples: Bundle IDs are forever — once Apple registers it under your team you can't transfer it cleanly. Pick something you'll be happy with in 5 years. +#### When iOS and Android cannot share one id + +`mob.exs` has a single `:bundle_id`, which Android uses as its +`applicationId`. The two platforms frequently **cannot** be the same string: + +- Apple rejects underscores; Android's `applicationId` allows them, so a + generated `com.example.my_app` is legal on Android and illegal on iOS. +- `com.example.*` is often already registered to a different Apple team. + +Set `:ios_bundle_id` for those cases. It overrides `:bundle_id` on iOS only, +and Android keeps using `:bundle_id`: + +```elixir +config :mob_dev, + bundle_id: "com.example.my_app", # Android applicationId + ios_bundle_id: "com.beyondagronomy.myapp" # iOS, everywhere +``` + +"Everywhere" means everywhere: the simulator and device builds, code-signing, +the provisioning profile lookup, `mix mob.provision`, `mix mob.deploy`, +`mix mob.connect`, `mix mob.uninstall`, the battery bench, and the release IPA. Before this was +consistent, a deploy could install the app under one id and then push BEAMs at +another — succeeding, or failing with *"App '…' is not installed on this +device"* immediately after a successful install. + +**The build stamps `CFBundleIdentifier` from this value**, so §1.2's iOS plist +edit below is not needed once you set `:ios_bundle_id` — whatever +`ios/Info.plist` holds is overwritten at build time. Set it in one place. + +If you set only `:bundle_id`, nothing changes: `:ios_bundle_id` falls back to +it. + ### 1.2 Update the bundle ID + display name in your project **iOS** — edit `ios/Info.plist`: