Inc 2: the two destructive lifecycle paths stop guessing (L4, L5, L9), and the README stops deferring (P8) - #16
Merged
Conversation
…DME stops deferring
L4 -- emulator_erase.is_avd_running answered "not running" three ways it had
not established, and each one leads straight into `unlink`:
- The whole scan sat in one `try`, so an AdbCommandError from the FIRST
emulator's console query returned False before any later emulator was
examined. The try is now per-emulator; a failed query is recorded and the
scan continues, because a later emulator may be a positive identification.
- Only the literal state `device` counted, so an emulator still booting
(`offline`) was invisible and its AVD was wiped underneath it. Any non-
`device` state on an `emulator-` serial now counts as running -- its console
cannot be asked which AVD it is, so it may be this one. The row filter also
stops being `"emulator" in line and "device" in line`, which with
`adb devices -l` matches a row whose state is anything at all, because every
row carries a `device:emu64a16k` descriptor.
- The name test was a substring, so `--name Pixel` was refused while an
unrelated `Pixel_9` ran. It is equality on run_emu's unframed payload.
A failed `adb devices` no longer answers False either: it raises
RunningCheckError, which names `adb kill-server` and `--force`. The refusal
message is a disjunction ("is this AVD, or could not be identified") because
that is what the check establishes; claiming more is the defect being fixed.
L5 -- emulator_delete `--old N` ranked by an mtime that fell back to 0.0 on
OSError, sorting every AVD it could not find to the end of a newest-first list,
which is the end `--old` deletes. It now fails with AvdHomeError naming the
directory it searched and the two variables that relocate it. The AVD home is
resolved the way avdmanager resolves it -- ANDROID_AVD_HOME, else
ANDROID_SDK_HOME/.android/avd, else ~/.android/avd -- in one function,
common.sdk_tools.resolve_avd_home, since emulator_erase held a second copy of
the same two-branch resolver. `--old 0` keeps nothing, so it silently meant
`--all`; it is now a usage error (exit 2) pointing at `--all --yes`.
L9 -- snapshots are deliberately NOT deleted by an erase, but a later
`snapshot.py --load` can undo the factory state, so a successful erase says
"snapshots kept; use snapshot.py --delete <name> to remove them". A test checks
that flag against snapshot.py's own argparse rather than against memory.
P8 -- README drops "will be finalized once feature parity work lands", gains
the two-command update (the plugin update alone does nothing and fails
silently), a clone fallback with the real URL, and a Prerequisites list
including the trap that put `$ANDROID_HOME` on PATH instead of
`$ANDROID_HOME/emulator`. SKILL.md's `<repository-url>` placeholder is gone.
Six guards in test_packaging_contract.py hold it, reading fenced code blocks
rather than whole files so prose about placeholders is not a false positive.
1563 passed / 24 xfailed before, 1587 passed / 24 xfailed after. No xfail
marker covered these findings, so none was deleted.
…tions that install something
R3 is the round's real finding, and it is L4 again in three more scripts. Four
places asked the same question -- "which AVD is this serial running?" -- each
with its own console call, its own timeout and its own idea of what a failure
means:
emulator_boot _get_avd_name_for_serial caught AdbError -> None
emulator_erase is_avd_running caught AdbCommandError -> False
emulator_selector _avd_name_for_serial caught EmuConsoleError -> None
emulator_shutdown get_avd_name_for_serial caught bare Exception -> None
Four answers, and every one collapsed "I could not ask" into "it is not that
AVD". That collapse authorised a wipe (L4), a second instance of a live AVD
(boot's already-booted short-circuit filtered out every emulator not in state
`device`), a ranking that silently dropped what it could not read, and a
`--name X` shutdown reporting "No running emulator found" over a device nobody
had managed to query.
common/emu_console.py now holds one `identify_emulator`, whose failure is a
value with the serial, the adb state and the reason in it, plus `probe_emulators`
and a tri-state `avd_running` (RUNNING / NOT_RUNNING / UNKNOWN, where UNKNOWN
beats NOT_RUNNING and loses to RUNNING). The four callers now differ where they
should -- in what they DO about unknown:
- erase refuses, naming the serial and its state; --force still erases.
- boot refuses and warns on stderr: an unidentified emulator may be this AVD,
and two instances of one AVD corrupt its userdata.
- selector ranks without it but lists it -- `unidentified_emulators` under
--json, a stderr warning otherwise -- so the ranking never claims a
completeness it does not have.
- shutdown --name exits non-zero saying which serial could not be queried,
instead of a confident "nothing to shut down".
KNOWN_CONSOLE_CALLS goes from nine sites to six; the four that collapsed are
recorded in a comment above it.
R1 -- emulator_erase's CLI handler sat outside the arg parsing, so it could not
know --json had been asked for: a RunningCheckError gave exit 1, prose on
stderr and an EMPTY stdout. Parsing happens first now and `_fail` answers in the
mode it was asked in, including for the --name usage error.
R2 -- the clone fallback installed the REPOSITORY ROOT at
~/.claude/skills/android-emulator-skill. SKILL.md lives two levels down, so that
directory is not a skill Claude Code can load. Both documents now clone to a
workspace path and symlink the inner directory, and the guard resolves the
documented source against this repository rather than matching a substring.
R4 -- emulator_selector.read_avd_config hard-coded ~/.android/avd, so on a host
that relocates the AVD tree every config read returned {} and every AVD lost its
API level, profile and ABI from the ranking. It uses resolve_avd_home().
R5 -- the unknown-emulator refusal recommended `emulator_shutdown --all` first,
which cannot console-kill an offline emulator. Restarting the emulator or the
adb connection comes first now.
R6 -- the packaged README lists Pillow as optional, as the root README does.
R8 -- the claim of "six fenced-block guards, all red on main" was wrong on all
three counts. There are five guard functions; two read prose, which is correct
for a claim but was not stated; and two placeholder parametrisations were green
on main. Each guard now declares which stream it reads (`_code_block_lines` for
commands, a new `_prose_lines` for claims), the deferral guard joins wrapped
prose before matching -- a line-at-a-time version passed against a README that
had the sentence in it -- and the PR body carries a per-test mutation transcript.
1613 passed on 701ed7d, 1648 passed after. No xfail markers exist on this tree.
fluxxion82
force-pushed
the
fix/inc-2-erase-delete-readme
branch
from
September 3, 2026 10:22
3f59fe1 to
bcea86e
Compare
`test_a_successful_erase_says_snapshots_were_kept` stubbed `is_avd_running`, but after the R3 rewiring `erase()` consults `running_check` -- so the real probe ran. It passed here, where adb is on PATH, and failed on the CI runner, where it is not. The exit status is the symptom. The defect is a unit test reaching the adb boundary at all, so the fix is not only the right seam: `subprocess.run` is now stubbed to raise, and the test fails loudly if anything under it tries to talk to a device again.
…shape
F2 is the round's finding and it is the same collapse one layer out. The two
gaps EmulatorProbe already covered were "this emulator is not in state device"
and "its console would not answer". The third is `adb devices` itself failing,
and all three scripts handled it differently and all three were wrong:
emulator_selector caught every RuntimeError and ranked EVERY AVD as idle
without a word; emulator_boot and emulator_shutdown let it escape as a bare
RuntimeError -- get_connected_devices re-wraps a failed listing as one, which
is not an AdbError, so it reached the user as a traceback from the module
family whose whole job is turning adb failures into remedies.
common/emu_console.py now has EmulatorSurvey and survey_emulators: the listing
is a VALUE, phrased once as "running state unavailable: <reason>". The listing
callable is passed in, so each script keeps the seam its tests stub. Callers
differ where they should: selector ranks what it can and discloses the gap
(`warnings` in JSON, an indented line in text, stderr in both); boot and
shutdown refuse with a structured non-zero failure. describe_gap appends the
emulator remedy only to the unidentified case -- an adb_exec error already
names its own, and "restart the emulator" is not the fix for "adb is not on
PATH".
F1 -- emulator_shutdown's adb handler sat outside the arg parsing, so
`--all --json` exited 1 with prose on stderr and an EMPTY stdout: R1's defect,
still live here. The test asserted that old behaviour for the JSON
parametrisation, which is why it stayed green; it now requires
`{"error": ...}` on stdout for --json and prose only for the text modes.
emulator_boot had the same shape and got the same fix.
F3 -- the refusal told you to shut down an emulator whose console is the thing
not answering. It now leads with the shared remedy and says to terminate the
stale emulator process itself.
F5 -- SKILL.md promised every JSON failure is `{"error": ...}` while a missing
AVD emitted `{"success": false, ...}`. Ordinary failures in erase, shutdown and
boot go through the one `_fail` helper -- the `(error, *, json_mode)` spelling
emulator_boot and emulator_selector have had since #15, rather than the second
one I had added. Batches keep their per-AVD summary and carry `error` alongside
it, in emulator_delete too so the two destructive scripts agree. Three
"Error: " prefixes embedded in returned messages are gone; the helper adds it.
F4 -- the invented tool output is gone: `emulator -list-avds` and the framed
`emu avd name` reply come from the recordings, the latter with only the AVD
name substituted so the console's OK framing is the recorded article. That
paid off KNOWN_VIOLATIONS' `test_emulator_shutdown.py::_FakeResult` -- the
ratchet caught the stale entry itself. 6 -> 5. No config.ini is recorded on any
profile and none can be captured here, so the read-from-disk test reuses this
file's single existing literal, the one the freeze already covers; recording
one is the next candidate.
F6 -- the census docstring said nine; it is six.
1613 passed on 701ed7d, 1652 passed here.
Merged
fluxxion82
added a commit
that referenced
this pull request
Sep 3, 2026
* release: v0.7.0 The agent loop is now verified from the skill's own printed output: a test reads what screen_mapper prints and feeds each printed name back into navigator, so "implemented but unreachable" fails instead of passing. Merged for this release: #8 Inc -1: emulator_shutdown --serial could power off an attached phone, and two required checks a docs-only PR cannot satisfy. #9 Inc 0: the red agent-loop spec and the Quick-Start-equals-e2e guard. #10 Inc 0: three AST shape guards (quoting, emulator console, bounds), the 23-mode runtime exit-code sweep, and a call-count floor against a blind detector. #11 Inc 0: both fixture-policy ratchets, so the detector sees hoisted constants and hierarchy consumers it used to miss. #12 Inc 0: recordings on two API levels, a real Gradle JUnit XML, the am start -W pair, and the display-override tests. #13 Inc 1: quote every value that crosses the device shell (all 19 sites), and stop reporting a failed lookup as an empty one. #14 Inc 1: the loop closes on the documented path -- every name screen_mapper prints is findable and taps inside the control it names, a bare --tap is refused, and --launch waits for the activity. #15 Inc 1/2: exit codes that mean something, a missing SDK tool that says so rather than returning an empty list, the IME guard, and one emulator console every adb emu call goes through. #16 Inc 2: the two destructive lifecycle paths stop guessing -- one tri-state "which AVD is this serial" probe for every caller -- and the READMEs stop deferring. Version bumped in the four manifests (SKILL.md frontmatter and its Scripts heading, plugin.json, marketplace.json, pyproject.toml). SKILL.md's Status & Roadmap now states what v0.7.0 verifies and the gaps that remain, and stale test prose describing xfail markers that no longer exist is corrected (no assertions changed). * docs(SKILL.md): two overclaims in the v0.7.0 status text Both found in review, and both are the defect class this release exists to correct: a status line asserting more than the tests establish. "Every failing mode exits non-zero and prints {"error": ...} under --json" welded two claims together, and only the first is verified. The runtime sweep asserts exit status, never the payload shape. Probing all nineteen --json modes the sweep drives, under its own fake toolchain: every one exits 1, but only app_launcher, screen_mapper, anr_watcher, app_state_capture, device_list and the emulator_* lifecycle scripts print a bare {"error": ...}. snapshot, sms and container print success and error together; privacy_manager prints {"success": false, "message": ...}; and navigator, log_monitor, crash_triage and status_bar refuse at device resolution with prose on stderr and no JSON at all. The bullet now states the exit-status guarantee, says the sweep does not assert the shape, and names both sides. "The JSON failure shape is not uniform" is added under Known gaps. "checked against bounds resolved independently of the hierarchy" inverted what the test does. The rectangle is resolved FROM the hierarchy, by the test's own parser reading the same dump -- independently of navigator, not of the hierarchy. That is the whole point of the check, so the sentence now says navigator is checked against the screen rather than against itself. No script changed. 1652 passed, 1 skipped; Black, Ruff and the plugin manifest validation are clean. * docs: narrow three more status claims, and guard the Scripts heading Review round 2. No script changed. R1. "A missing SDK tool says so" was true of the five scripts that route through common/sdk_tools.py and false of emulator_create, which still returns [] at six sites when avdmanager or sdkmanager is missing or fails, so --list-devices and --list-images print nothing and exit 0. The bullet now names the five, notes that device_list keeps a missing avdmanager as a warning because it only decorates AVDs the emulator already listed, and emulator_create is a Known gap. R3. "names every control on the screen" was false: the default report slices each bucket to BUTTONS_PREVIEW (15) and appends "... (N total)". Now "names up to 15 controls per bucket in the default report (the full inventory is in --json)". R4. tests/test_packaging_contract.py only read SKILL.md's frontmatter, and so do validate-version.yml and release.yml, so the "## Scripts (vX.Y.Z)" heading could drift a whole release behind with every guard green. It is now a fifth parametrized case in the agreement test. Mutation, against a cp snapshot rather than git: heading set to v0.6.0 fails with "SKILL.md `## Scripts` heading says 0.6.0, pyproject.toml says 0.7.0"; restored from the snapshot, 5 passed. R2. Confirmed the previous round's wording stands, and extended the Known-gaps line to name both remaining groups as measured: the {"success": false, "message": ...} scripts (with snapshot, sms and container emitting both keys), and navigator, log_monitor, crash_triage and status_bar, which refuse an unreachable device before any JSON is built and print prose on stderr. R6. SKILL.md said a known defect "is pinned with xfail(strict=True)"; none is. Stated as the policy, plus "None is pinned in v0.7.0". R7. accessibility_audit.py:10-12 now says outright that contrast is not claimed, so the gap keeps the fact and drops the "despite its own description" rationale. R5, stale prose, docstrings and comments only, zero assertions touched: test_exit_code_sweep.py's header said device_list "still answers" an empty success (fixed in v0.7.0; the shape survives in emulator_create, which the sweep does not cover) and its table comment said "the six marked ones" (eight were marked, none is today); test_quick_start_contract.py described C1 and L3 in the present tense; test_shape_guards.py claimed 47 device-shell calls where the detector reports 45 -- corrected, and labelled an observation rather than a contract, since only the floors are asserted -- and two enumeration docstrings still described the sites they held before L7 and C5/C7 emptied them. 1653 passed, 1 skipped; Black, Ruff and the plugin manifest validation clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Inc 2 of the v0.7.0 plan: L4, L5, L9, P8 — plus Codex round 1 (R1–R6, R8).
Rebased onto
701ed7d(#14). Counts: 1613 passed / 0 xfailed onorigin/main, 1652 passed / 0 xfailed here. Measured bycp-snapshotting the changed files,git checkout 701ed7d --them, running, and restoring from the snapshots. Noxfail(strict=True)marker covered any of these findings, and #14 removed the last markers on this tree, so none was deleted.Codex round 2
All six addressed. 1613 passed on
701ed7d→ 1652 passed here.KNOWN_VIOLATIONS6 → 5 (F4 paid one off).F2 (P1) — the listing failing is the third gap
EmulatorProbealready covered two gaps: an emulator not in statedevice, and one whose console will not answer.adb devicesitself failing is a third, and all three scripts handled it differently and all three were wrong:emulator_selectorexcept RuntimeError: return []— ranked every AVD as idle, silentlyemulator_bootRuntimeError→ tracebackemulator_shutdownThe escape is worth spelling out:
get_connected_devicesre-wraps a failed listing as a plainRuntimeError, which is not anAdbError, somain()'s handler never saw it — a stack trace, from the one module family whose whole purpose is turning adb failures into remedies.common/emu_console.pynow hasEmulatorSurveyandsurvey_emulators. The listing is a value, phrased once asrunning state unavailable: <reason>, and the listing callable is passed in so each script keeps the seam its own tests already stub. Callers differ where they should:warningsin the JSON document, an indented!line in the text listing, stderr in both.--nameand--allalike.One correctness note beyond the brief:
describe_gap()appendsUNKNOWN_EMULATOR_REMEDYonly to the unidentified case. Anadb_execerror already names its own remedy, and bolting "restart the emulator, or reset the adb connection" onto "adb is not on PATH" is advice that does not work.Mutations (
cpsnapshot → edit → pytest →cprestore):survey_emulatorsswallows the failure (unavailable=None)if survey.unavailable:disabledtest_boot_refuses_when_the_emulators_cannot_be_listed,test_cli_reports_an_adb_error_without_a_tracebackUNKNOWNbranch disabledtest_shutdown_by_name_refuses_when_an_emulator_could_not_be_queried,..._when_the_emulators_cannot_be_listed,test_cli_reports_an_adb_error_without_a_traceback[by-name]New tests read
adb_device_not_foundviarecorded_anywhere, as instructed.F1 (P1) —
--all --jsonexited 1 with an empty stdoutemulator_shutdown.main()'s adb handler sat outside the arg parsing — R1's defect, still live. Parsing happens first now and the handler answers in the mode it was asked in.emulator_boothad the identical shape and got the identical fix.The test is inverted as instructed:
test_cli_reports_an_adb_error_without_a_tracebacknow requires{"error": ...}on stdout and an empty stderr for the--jsonparametrisation, prose on stderr for the text ones. Its docstring says why it stayed green — it asserted prose for the JSON case too.Mutation:
_failback to prose-only →[all-json]andtest_shutdown_by_name_when_the_emulators_cannot_be_listedgo red.F3 (P1) — the remedy needed the console that was not answering
Leads with the shared
UNKNOWN_EMULATOR_REMEDY, then says explicitly to terminate the stale emulator process (pkill -f 'qemu-system.*<avd>', or Device Manager) —emulator_shutdowncannot reach a console that is not answering.Mutation: revert to "Shut that emulator down with emulator_shutdown.py" →
test_boot_refuses_while_an_emulator_cannot_be_identifiedred.F4 (P1) — invented tool output, and a debt that was already paid
emulator -list-avdsnow comes fromrecorded.text("emulator_list_avds")in both selector tests. The framed console reply comes fromrecorded.text("emu_avd_name")with only the AVD name substituted, through a_framed_replyhelper whose docstring says why: theOKframing is the part nobody would write by hand and the part that defeated the already-booted check for a whole release (S5).That paid off
KNOWN_VIOLATIONS'test_emulator_shutdown.py::_FakeResult— and the ratchet caught it itself:test_the_debt_list_does_not_rotfailed with "listed as known violations but no longer violate anything". Entry deleted, the payoff recorded in the PAID OFF SINCE block. 6 → 5.config.ini: no profile records one and none can be captured here, so per your instruction the read-from-disk test reuses this file's single existing literal — hoisted to a module constant so it stays visible to the detector under the existingparse_config_inifreeze rather than becoming a second invention. Recording an AVDconfig.iniis the next recording candidate.F5 (P2) — one failure shape, and the promise made true
Ordinary failures in
emulator_erase,emulator_shutdownandemulator_bootgo through one_failhelper — the(error, *, json_mode)spellingemulator_bootandemulator_selectorhave had since #15. My round-1_fail(args, message)was a second helper that shadowed boot's existing one; that is gone.Batches keep their per-AVD summary and carry
erroralongside it when any AVD failed, inemulator_deletetoo so the two destructive scripts do not disagree. Three"Error: "prefixes embedded in returned messages are removed — the helper adds exactly one.Mutation: delete erase's
if not success: _fail(...)→ three tests red, including the newtest_an_ordinary_failure_answers_with_the_documented_error_key.F6 (P2)
Docstring corrected, with a note that
KNOWN_CONSOLE_CALLSis the count that matters because a number in prose goes stale silently — which that one did. No behavioural test, so no mutation: the enumeration is its own check.A fourth existing-test assertion changed
test_emulator_shutdown.py::test_cli_exits_non_zero_when_refusing_a_physical_deviceasserted the L1 refusal appears on stdout. Under F5 a refusal is a failure and goes to stderr. Its invariants — exit 1, the serial named, no adb command issued — are unchanged and still asserted, plus a newassert not captured.outso a refusal cannot be printed as a result again.Honest note on the F6 mutation run
The batched mutation run hit the 2-minute tool timeout mid-
f6-stale-count, after the edit and before the restore, and because the runner snapshots before asserting, the snapshot was the mutated file. I repaired the docstring by hand and verified withgit diff. The runner's snapshot-then-assert order is the wrong way round for a mutation that does not apply; the other twelve restored correctly.Codex round 1
R3 (P1) — "unknown means not running" in three more scripts
This is L4 again, and the fix is one function rather than four. Four places asked the same question — which AVD is this serial running? — each with its own console call, its own timeout, and its own idea of what a failure means:
emulator_boot_get_avd_name_for_serialAdbErrorNoneemulator_eraseis_avd_runningAdbCommandError/EmuConsoleErrorFalseemulator_selector_avd_name_for_serialEmuConsoleErroronlyNoneemulator_shutdownget_avd_name_for_serialExceptionNoneFour answers to one question, and every one collapsed "I could not ask" into "it is not that AVD". Each collapse authorised something: a wipe (L4); a second instance of a live AVD (boot's already-booted check filtered out every emulator not in state
device, so one mid-boot was invisible); a ranking that silently dropped what it could not read; and--name Xreporting "No running emulator found" over a device nobody had managed to query.common/emu_console.pynow holds:identify_emulator(serial, state)— the onlyrun_emu("avd", "name")call in the skill. Never raises for a console or command failure: that outcome is the answer, carried asreasonalongside the serial and adb's state column. A non-devicestate short-circuits without asking, because a console that is not up cannot answer.probe_emulators(devices=None)— one probe per attached emulator. The optionaldevicesargument lets a caller pass a listing it already has, which keeps the listing on that caller's own seam (where its tests stub it) and avoids querying adb twice.avd_running(name)→RunningAnswerwith aRunningVerdictofRUNNING/NOT_RUNNING/UNKNOWN. UNKNOWN beats NOT_RUNNING and loses to RUNNING — that ordering is the safety property.The four callers now differ where they should, in what they do about
UNKNOWN:--forcestill erases.emulator_boothas no--force, and I did not add one; flag me if you want the escape hatch.unidentified_emulatorsin the--jsondocument, an indented!line in the text listing, and a stderr warning in every mode, so a--jsoncaller still gets a clean stdout.--nameexits non-zero saying which serial could not be queried and why, via a newresolve_target_by_avd_namereturning(serial, reason).resolve_serial_by_avd_nameis kept as its lossy wrapper.Console-call enumeration delta
tests/test_shape_guards.KNOWN_CONSOLE_CALLS, 9 → 6:common/emu_console.py::identify_emulatorcommon/emu_console.py::console_availableemulator_boot.py::_get_avd_name_for_serialemulator_erase.py::is_avd_runningemulator_selector.py::_avd_name_for_serialemulator_shutdown.py::get_avd_name_for_serialemulator_shutdown.py::shutdownlocation.py::_run_geo_fixsms.py::sendsnapshot.py::_consoleThe four removed are recorded in a comment above the list with what each of them used to catch, so the enumeration keeps the history the guard exists to protect. The guard failed on the first run of the rewrite and named exactly those four — the delta is its output, not my summary of it.
Three existing tests changed, all in the same direction
Called out individually because none is an
xfailspec and each pinned the behaviour R3 says is wrong:test_emulator_erase.py::test_a_device_error_is_not_answered_as_not_running— assertedpytest.raises(DeviceNotFoundError)out oferase(). A device error on one emulator's console is now anUNKNOWNverdict instead. Strictly more information reaches the agent: the refusal names which serial went unanswered and quotes adb's own message and remedy, where the exception said only that something had. The safety assertion (userdata still on disk) is unchanged and the test now also asserts the serial and the remedy appear.test_emulator_selector.py::test_a_device_error_is_not_ranked_as_not_running— asserted the same raise out ofrunning_avd_names(), which made a suggestion fatal over a condition it cannot fix. Its invariant ("an unanswered check must not read as idle") is unchanged and still asserted — no name is invented, and the emulator is reported unidentified. The thing it was really protecting (a second copy of a live AVD being booted) is now refused byemulator_boot, which is the only place that can actually prevent it.test_emulator_selector.py::test_cli_reports_an_adb_error_without_a_traceback→test_the_cli_names_an_emulator_it_could_not_identify.--suggestno longer exits 1 over one unauthorized emulator; it ranks what it can and warns. The remedy and no-traceback assertions are kept verbatim, and selector's CLI failure boundary is still covered bytest_the_cli_exits_non_zero_when_avd_discovery_failsandtest_a_json_caller_gets_the_failure_in_the_json.Also:
test_emulator_erase.py::test_running_check_uses_adb_command_mappingnow expectsadb devices -l, because the listing goes throughdevice_utils.get_connected_devicesrather than this script's own row parser.R1 (P1) —
--jsonpromised{"error": ...}and printed nothingmain()caughtAdbErroroutside the arg parsing, so it could not know--jsonhad been asked for:--name Pixel_9 --jsonunder aRunningCheckErrorgave exit 1, prose on stderr, and an empty stdout — nothing at all on the stream an agent parses. Parsing now happens first and_fail(args, message)answers in the mode it was asked in. The--name is requiredusage error had the same shape (argparse help, no JSON) and is fixed with it.New test
test_every_failing_json_mode_prints_an_error_document, parametrised over--name … --json,--all --jsonand a bare--json.R2 (P1) — the clone fallback installed something that is not a skill
git clone <url> ~/.claude/skills/android-emulator-skillputs the repository root there, but the skill isandroid-emulator-skill/skills/android-emulator-skill/— two levels down. Claude Code loads nothing. BothREADME.mdandSKILL.mdnow clone to a workspace path and symlink (orcp -R) the inner directory into~/.claude/skills/or.claude/skills/.The guard resolves it rather than matching a string:
_clone_installationsparses the fenced block for agit clone <url> <dest>and each laterln -s/cp -Rwhose source starts with that destination, strips the destination prefix to get a path relative to the repository, and assertsREPO_ROOT / relative / "SKILL.md"is a file.R4 (P1) —
read_avd_confighard-coded~/.android/avdNow
resolve_avd_home(). On a host that relocates the AVD tree every config read returned{}, so every AVD lost its API level, device profile and ABI from the ranking — a ranking computed from no inputs and printed as a recommendation. Tested withANDROID_AVD_HOMEset.R5 (P2) — the refusal recommended a remedy that cannot work
An
offlineemulator cannot be console-killed, soemulator_shutdown.py --allwas not merely second-best, it was the thing that will not work.UNKNOWN_EMULATOR_REMEDY("restart the emulator, or reset the adb connection withadb kill-server && adb start-server") comes first now, carried byRunningAnswer.describe_unknown()so all four scripts quote the same fix.R6 (P2) — Pillow is optional in the packaged README too
R8 (P2) — the guard claim was wrong on all three counts
Codex is right and my PR body was wrong. There are five guard functions, not six (I counted parametrisations). Two read prose, not fenced blocks. And two placeholder parametrisations were green on
main— my earlier "all six red" transcript mutated the root README into a statemainnever had, which is evidence of nothing.What changed:
_code_block_linesis the commands a reader copies — a<placeholder>there is a defect. A new_prose_linesis the claims the document makes — a deferral sentence is a defect there, a placeholder is not. Neither reads the whole file.Per-guard mutation transcript
Runner:
cpsnapshot → edit →pytest -x→cprestore, asserting the anchor text occurs exactly once so a stale mutation fails loudly instead of silently testing nothing. Nevergit checkout --.mainhad)SKILL.md: real URL →git clone <repository-url>test_no_documented_command_still_carries_a_placeholder[skill]SKILL.md documents commands nobody can run: ['git clone <repository-url> ~/src/android-emulator-skill']README.md: deferral sentence restored, wrapped across two linestest_the_repo_readme_does_not_defer_its_install_instructionsREADME defers its own instructions: 'Plugin install instructions will be finalized once feature parity work lands:'README.md:claude plugin marketplace updateline deletedtest_the_readme_documents_both_halves_of_an_updateREADME does not show 'claude plugin marketplace update'README.md: clone URL → a different ownertest_the_clone_url_points_at_this_repositorys_ownerassert False … any(<genexpr>)README.md: link the clone root into.claude/skills(main's own instruction)test_the_clone_fallback_installs_a_directory_that_is_actually_a_skill[repo]where False = (PosixPath('<repo>') / 'SKILL.md').is_fileREADME.md: the$ANDROID_HOME/emulatorwarning generalised awaytest_the_readme_names_the_emulator_path_trapre.search('not\s+?$ANDROID_HOME?[,.\s]', prose)→NoneOnly #1 and #5 correspond to text
mainactually had; #2–#4 and #6 are anti-vacuity checks on guards protecting text this PR adds. Stated rather than blurred, which is the correction R8 asked for.Code mutation transcript (R1, R3, R4, R5)
mainhad)_failalways writes prose to stderr (handler outside arg parsing)test_every_failing_json_mode_prints_an_error_document[name]json.decoder.JSONDecodeError: Expecting value: line 1 column 1probe_emulatorsfilters tostate == "device"(what all four callers did)test_boot_refuses_while_an_emulator_cannot_be_identified'emulator-5554' in 'Boot error: a second emulator was launched over an unidentified one'emulator_boot:unidentified = []test_boot_refuses_while_an_emulator_cannot_be_identifiedPopenstub raises, so the launch is proved, not inferredemulator_shutdown: theUNKNOWNbranch deletedtest_shutdown_by_name_refuses_when_an_emulator_could_not_be_queried'emulator-5554' in "Error: No running emulator found for AVD name 'Pixel_9'"emulator_selector:self.unidentified = ()test_suggest_lists_an_emulator_it_could_not_identify[p.serial for p in selector.unidentified] == [serials[0]]read_avd_configback toPath.home() / ".android" / "avd"test_read_avd_config_honours_the_resolved_avd_home$ANDROID_AVD_HOMEemulator_shutdown --allfirsttest_a_device_error_is_not_answered_as_not_running'emulator-5554' in 'Refusing to erase Pixel_9: … Shut the emulators down (emulator_shutdown.py --all).'#8 and #9 both land on the same test from different directions — the shared filter and the caller's use of it — which is what makes the pair worth keeping.
Every R3 test is driven from
recorded.text("adb_devices_multiple")(two emulators) andrecorded.text("emu_avd_name"). Theofflinestate is the recorded row with its state column rewritten, and each such test says so in its docstring: no profile has recorded a device in that state (seetest_fixture_policy.KNOWN_VIOLATIONS'parse_adb_devicesentry), and provoking one means killing an emulator mid-boot on the recording host.KNOWN_VIOLATIONSis unchanged.R7
Noted as going to the v0.8.0 list, not actioned here.
The original findings
L4 —
is_avd_runningsaid "not running" four ways it had not establishedscripts/emulator_erase.py. Every one leads into theunlinkloop.trywrapped the loop, so anAdbCommandErrorfrom the first emulator returnedFalsewith later emulators never examined.devicecounted, so an emulator still booting (offline) was invisible. The row filter also stopped being"emulator" in line and "device" in line— againstadb devices -levery row carries adevice:emu64a16kdescriptor, so that predicate is true of a row in any state.--name Pixelwas refused while an unrelatedPixel_9ran. Now equality on the unframed payload. This hole leans the opposite way:name in payloadis a strict superset ofname == payload, so it over-refuses and can never cause a wipe equality would prevent. Its test asserts the erase of a differently-named AVD now proceeds, with a companion asserting the true match still refuses. The brief's "never reachunlinkin any of them" holds for (a) and (b) but is inverted for (c).adb devicesreturnedFalse— the check never ran at all. NowRunningCheckError, namingadb kill-serverand--force.The three holes' mutation transcripts (equality → substring; state column → main's line filter, whose failure message is the defect verbatim,
AVD erased: Pixel_9 (deleted 2 files); per-emulator failure → main's scan abort) are in the commit history of this branch and reproduce unchanged; the L4 logic now lives inavd_runningand its mutations are #8 above.L5 —
--old Ndeleted the AVDs it could not find_mtimereturned0.0onOSError, so in a newest-first sort every AVD the script could not locate went to the end — which is the end--olddeletes.--old 3on a host whose AVD home is elsewhere proposed deleting the lot, and the only symptom was that it appeared to work. NowAvdHomeError(subclassesSdkToolError, so every mode reports it the one way the L8 fix established) naming the directory searched and the two variables that relocate it.AVD home is resolved as
avdmanagerresolves it:ANDROID_AVD_HOME, elseANDROID_SDK_HOME/.android/avd, else~/.android/avd. Note the asymmetry — the first is the directory of.avddirectories, the second is the parent of.android.grepfound the same two-branch resolver inemulator_erase.get_avd_homeand (via R4)emulator_selector.read_avd_config; all three call onecommon.sdk_tools.resolve_avd_home().Also, from the same finding's evidence:
--old 0keeps nothing, so it silently meant--all. Now a usage error (exit 2) pointing at--all --yes.Mutations:
_mtime→return 0.0reddens both L5 tests; dropping theANDROID_SDK_HOMEbranch reddens[sdk_home]; removing the--old 0guard reddens both parametrisations.L9 — snapshots kept, and now said so
A snapshot is a whole guest machine, not user data, so deleting one during an erase would throw away state somebody recorded deliberately — but a later
snapshot.py --loadcan undo the "factory state" this script promises. One line, in the module docstring, on every successful erase (text and--json, single and--all), and in the SKILL.md entry.<name>is inserted into the brief's wording becausesnapshot.py --deletetakes aNAMEmetavar;test_the_snapshot_note_names_a_flag_snapshot_py_actually_hasparsessnapshot.py's argparse withastto confirm the flag exists.P8 — install instructions somebody can run
Deferral sentence deleted; both install commands kept; the
@fluxxion82suffix explained asmarketplace.json'snamefield; the two-command update with its silent-failure note; a clone fallback that installs the right directory (R2); and a Prerequisites list —platform-tools;cmdline-toolswith the reason the legacytools/bin/avdmanagercannot substitute (NoClassDefFoundError: javax/xml/bind/annotation/XmlSchemaon Java 11+); Java 21; Python 3.12+; Pillow optional; and the$ANDROID_HOMEvs$ANDROID_HOME/emulatortrap. No script table. The Status paragraph, two lines above the deleted sentence, still claimed parity was pending and was corrected with it.Other enumerations
KNOWN_CONSOLE_CALLSKNOWN_EMU_BYPASSES()KNOWN_VIOLATIONS(fixture policy)KNOWN_BOUNDS_SITES()(emptied by #14)SKILL.md ↔ argparse parity is green.
--namewas added toemulator_shutdown's documented options — it exists and was simply missing from the list.Lane failure on the first round-1 push, and what it was
The mocked check went red on
test_a_successful_erase_says_snapshots_were_keptwhile the same test passed here. It stubbedis_avd_running, but after the R3 rewiringerase()consultsrunning_check— so the real probe ran, found adb on my PATH and nothing on the runner's.The exit status was the symptom; the defect is a unit test reaching the adb boundary at all. Fixed at the right seam, and
subprocess.runis now stubbed to raise in that test, so anything under it that tries to talk to a device fails loudly rather than depending on whose machine it is.Verification
Run on the tree rebased onto
origin/main@701ed7dimmediately before pushing. No adb, emulator, skill script orpytest -m emulatorwas run at any point — a physical phone is attached to this host.