From 0f210e55327214b4aaafd8d27df0e7820108f72f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 17 Aug 2026 15:54:04 +0200 Subject: [PATCH] monitor: backfill to 100% coverage, fixing two defects it exposed The split shipped verbatim-proven and green, but measuring it afterwards showed the tests barely touched it: monitor.sh 44.44%, the policy probes 7.69%, and the health probes never loaded at all. Every _check_* decides whether phone enforcement is reported as healthy, so an untested probe is an enforcement failure that reports itself as fine. The mock stubs adb_root_shell at the function boundary rather than putting a fake adb on PATH: the probes never invoke adb, they call a function from adb_common.sh, so a PATH stub would never be consulted. Dispatch is by pattern, not exact string, because the real commands embed awk programs with escaped $2, nested quotes and 2>/dev/null redirections. Two defects the new tests found: - _mon_escape_json never escaped double quotes. Written as ${escaped//"/\"}, bash reads the bare quote as opening a quoted string, so the pattern silently degraded to `/\` with an empty replacement. Any check message containing a quote produced invalid JSON. - monitor_print_format_warning's multi-line array literal and collect_snapshot's `{ ... } >file` group held 12 lines that no test could ever cover: kcov instruments the continuation lines and the closing brace, but bash only reports a statement at its first line. Reproduced in an isolated 9-line case before changing anything. Assertions are mutation-proofed: 18 mutations of the probes' status decisions, 18 killed. The first run had one survivor -- the repairable flag was asserted nowhere -- now pinned in both directions. monitor.sh 104/104 = 100.00% (was 44.44%) monitor_checks_policy.sh 65/65 = 100.00% (was 7.69%) monitor_checks_health.sh 79/79 = 100.00% (was 0%) test_monitor.sh 82 passed, 0 failed (was 9) Cases live in two sourced files purely to stay under the 250-line cap; test_monitor.sh remains the single entry point, so one coverage command still measures all three subjects. Over-cap count unchanged at 22. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- .../monitor-coverage-backfill-2026-08-17.json | 20 ++ .../monitor-coverage-backfill-2026-08-17.json | 82 ++++++ phone_focus_mode/lib/monitor.sh | 53 ++-- .../lib/tests/monitor_cases_health.sh | 249 ++++++++++++++++++ .../lib/tests/monitor_cases_policy.sh | 178 +++++++++++++ phone_focus_mode/lib/tests/monitor_harness.sh | 210 +++++++++++++++ phone_focus_mode/lib/tests/test_monitor.sh | 164 ++---------- 7 files changed, 784 insertions(+), 172 deletions(-) create mode 100644 docs/superpowers/contracts/monitor-coverage-backfill-2026-08-17.json create mode 100644 docs/superpowers/evidence/monitor-coverage-backfill-2026-08-17.json create mode 100755 phone_focus_mode/lib/tests/monitor_cases_health.sh create mode 100755 phone_focus_mode/lib/tests/monitor_cases_policy.sh create mode 100755 phone_focus_mode/lib/tests/monitor_harness.sh diff --git a/docs/superpowers/contracts/monitor-coverage-backfill-2026-08-17.json b/docs/superpowers/contracts/monitor-coverage-backfill-2026-08-17.json new file mode 100644 index 00000000..a9f93a0d --- /dev/null +++ b/docs/superpowers/contracts/monitor-coverage-backfill-2026-08-17.json @@ -0,0 +1,20 @@ +{ + "title": "Backfill monitor.sh and both probe libraries to 100% line coverage", + "objective": "Close the pre-existing test debt the monitor.sh split made visible: monitor.sh at 44.44%, monitor_checks_policy.sh at 7.69% and monitor_checks_health.sh never loaded at all. Every _check_* probe decides whether phone enforcement is reported as healthy, so an untested probe is an enforcement failure that reports itself as fine.", + "acceptance_criteria": [ + "bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_monitor.sh exits 0 at its default 100% minimum for each of monitor.sh, monitor_checks_policy.sh and monitor_checks_health.sh.", + "bash phone_focus_mode/lib/tests/test_monitor.sh reports a test count strictly greater than the 9 it reported before this change, with 0 failures.", + "Every assertion is mutation-proofed: a scripted run breaks each _check_* status decision in turn and requires the suite to fail, with zero survivors.", + "bash meta/scripts/check_file_length.sh --all reports no more over-cap files than before this change (22), so no new file crosses the 250-line cap.", + "shellcheck passes on each touched file individually, with no -x and no disable directive, since the pre-commit hook reads each file standalone.", + "No suppression of any kind is added: no shellcheck disable, no lowered coverage threshold, no skipped assertion.", + "The mock reaches the probes through the adb_root_shell function, not a PATH stub, so adb_common.sh's device-detection and locking surface stays out of the test." + ], + "out_of_scope": [ + "Deploying to the phone or otherwise verifying against a real device; there is no device in this session and the probes are proven with stubs, hashes and real runs instead.", + "Splitting any of the other brief-scoped files (phone_backup.sh, curfew_enforcer.sh, hosts_enforcer.sh, focus_daemon.sh, config.sh, deploy.sh, focus_ctl.sh), which are separate commits.", + "Fixing test_magisk_service.sh, which hangs identically at HEAD and is unrelated to the monitor seam.", + "Changing what any probe reports to the user beyond the two defects fixed here." + ], + "verifier": "bash phone_focus_mode/lib/tests/test_monitor.sh (expects 82 passed, 0 failed), plus bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_monitor.sh {monitor.sh,monitor_checks_policy.sh,monitor_checks_health.sh} each at 100.00%, plus the mutation run at 18 killed / 0 survived, plus bash meta/scripts/check_file_length.sh --all still showing 22 files, plus pre-commit run --files on the touched files." +} diff --git a/docs/superpowers/evidence/monitor-coverage-backfill-2026-08-17.json b/docs/superpowers/evidence/monitor-coverage-backfill-2026-08-17.json new file mode 100644 index 00000000..fe24e1d2 --- /dev/null +++ b/docs/superpowers/evidence/monitor-coverage-backfill-2026-08-17.json @@ -0,0 +1,82 @@ +{ + "intent": "Take monitor.sh and its two probe libraries from 44.44% / 7.69% / 0% line coverage to 100%, so that the twelve enforcement probes behind the phone monitor are actually tested rather than merely present.", + "scope": [ + "phone_focus_mode/lib/monitor.sh - two defects fixed, two constructs restructured for measurability", + "phone_focus_mode/lib/tests/monitor_harness.sh - new: the fake device", + "phone_focus_mode/lib/tests/monitor_cases_health.sh - new: helper and health-probe assertions", + "phone_focus_mode/lib/tests/monitor_cases_policy.sh - new: policy-probe and snapshot assertions", + "phone_focus_mode/lib/tests/test_monitor.sh - reduced to the entry point that sources the three", + "Non-goal: any device deploy. There is no phone in this session; the probes are proven with stubs and real runs.", + "Non-goal: monitor_checks_health.sh and monitor_checks_policy.sh are untouched - they reached 100% on tests alone." + ], + "changes": [ + "Added a fake device that mocks adb_root_shell at the function boundary rather than stubbing an adb binary on PATH. The probes never invoke adb; they call adb_root_shell, a function from adb_common.sh, so a PATH stub would never be consulted. Dispatch is by pattern rather than exact string because the real commands embed awk programs with escaped $2, nested quotes and 2>/dev/null redirections.", + "Fixed a real JSON-escaping defect in _mon_escape_json: the substitution was written ${escaped//\"/\\\"}, where bash reads the bare quote as opening a quoted string, so the pattern silently degraded to `/\\` with an empty replacement and no quote was ever escaped. Any check message containing a double quote produced invalid JSON, which monitor_report.py then failed to parse. Found by the new test, not by inspection.", + "Rewrote two constructs that no test could ever cover: a multi-line array literal in monitor_print_format_warning and a `{ ... } >file` redirection group in monitor_collect_snapshot. kcov instruments the continuation lines and the closing brace, but bash only ever reports a statement at its first line, so those 12 lines were permanently unreachable. Reproduced in an isolated 9-line case before changing anything; both are now per-statement.", + "Removed an 11-line block of blank lines left in monitor.sh by the earlier split.", + "Split the assertions across two sourced case files so every test file stays under the 250-line cap while test_monitor.sh remains the single entry point, keeping one coverage command able to measure all three subjects.", + "Resolved SC2329 and SC2034 structurally rather than with disables: the non-numeric-count guard is now driven through a child bash process fed by a quoted heredoc (the device dns_iptables_harness.sh already uses), and the FORMAT_INDICATORS reset is read back immediately." + ], + "verification": [ + { + "command": "bash phone_focus_mode/lib/tests/test_monitor.sh", + "result": "pass", + "evidence": "Results: 82 passed, 0 failed. Was 9 passed before this change." + }, + { + "command": "bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_monitor.sh monitor.sh", + "result": "pass", + "evidence": "monitor.sh: 104/104 lines = 100.00% (exit 0 at the default 100% minimum). Baseline was 48/108 = 44.44%." + }, + { + "command": "bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_monitor.sh monitor_checks_policy.sh", + "result": "pass", + "evidence": "monitor_checks_policy.sh: 65/65 lines = 100.00%. Baseline was 5/65 = 7.69%." + }, + { + "command": "bash meta/scripts/shell_coverage.sh phone_focus_mode/lib/tests/test_monitor.sh monitor_checks_health.sh", + "result": "pass", + "evidence": "monitor_checks_health.sh: 79/79 lines = 100.00%. Baseline was 0% - kcov reported 'instrumented no lines' because the file was never loaded." + }, + { + "command": "mutation run: break each _check_* status decision in turn, require the suite to fail, restore", + "result": "pass", + "evidence": "18 mutations, 18 killed, 0 survived. The first run had 1 survivor - the repairable flag was asserted nowhere - which was closed by pinning it in both directions on a dead daemon and a healthy check." + }, + { + "command": "bash meta/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "22 over-cap files, unchanged from before this commit. An intermediate 411-line test_monitor.sh briefly made it 23; that is why the cases are split across two files." + }, + { + "command": "shellcheck on each touched file individually (no -x)", + "result": "pass", + "evidence": "All seven of monitor.sh, monitor_checks_health.sh, monitor_checks_policy.sh, monitor_harness.sh, monitor_cases_health.sh, monitor_cases_policy.sh and test_monitor.sh report clean standalone." + }, + { + "command": "timeout 60 bash phone_focus_mode/run_phone.sh --help", + "result": "pass", + "evidence": "exit 0, proving the source chain through monitor.sh into both probe libraries still resolves after the edits." + }, + { + "command": "jscpd, the pre-commit hook's exact invocation, measured in a clean HEAD worktree with and without these files", + "result": "pass", + "evidence": "1.43% at HEAD, 1.45% with these four files added - one new 19-line clone of the _t_pass/_t_fail/_t_eq block, well under the 2% gate. Working-tree runs report 2.47% because of vendored .venv site-packages, which is pre-existing and out of scope." + }, + { + "command": "bash phone_focus_mode/lib/tests/{test_dns_iptables,test_tether_enforcer,test_adb_common,test_adb_trusted}.sh", + "result": "pass", + "evidence": "24, 17, 10 and 6 passed respectively, all 0 failed - the sibling suites are unaffected." + } + ], + "risks": [ + "The JSON-escaping fix changes what monitor_collect_snapshot writes for any check message containing a double quote. That output was invalid JSON before, so nothing could have depended on it, but a consumer parsing the old malformed shape leniently would now see a different string.", + "The probes are verified against a stub device, not a real phone. The mock encodes what the commands are believed to return; a ROM whose dumpsys or df output differs in shape would still be mis-parsed, and no test here would catch it.", + "monitor_collect_snapshot now writes report.json with three redirections instead of one grouped redirect. If the process were killed between them the file would be truncated rather than absent; previously the group could leave a partial file too, so this is a change in shape rather than in kind." + ], + "rollback": [ + "git revert the commit. The three subjects return to their previous behaviour, including the JSON-escaping defect.", + "After rollback, re-run bash phone_focus_mode/lib/tests/test_monitor.sh - it should report 9 passed - and timeout 60 bash phone_focus_mode/run_phone.sh --help expecting exit 0.", + "The coverage regression is the point of the change, so a rollback should be paired with re-opening the backfill rather than treated as resolved." + ] +} diff --git a/phone_focus_mode/lib/monitor.sh b/phone_focus_mode/lib/monitor.sh index 45cc59a1..8b9d7675 100755 --- a/phone_focus_mode/lib/monitor.sh +++ b/phone_focus_mode/lib/monitor.sh @@ -29,7 +29,11 @@ _mon_escape_json() { local escaped="$1" escaped=${escaped//\\/\\\\} - escaped=${escaped//"/\\"} + # The quote must be backslash-escaped in the pattern. Written bare as + # ${escaped//"/\\"} bash reads the quote as opening a quoted string, so the + # pattern silently becomes `/\` with an empty replacement and no quote is + # ever escaped — which emitted invalid JSON for any message containing one. + escaped=${escaped//\"/\\\"} escaped=${escaped//$'\n'/\\n} escaped=${escaped//$'\r'/\\r} printf '%s' "${escaped}" @@ -126,27 +130,29 @@ monitor_is_formatted() { (( missing_count >= FORMAT_DETECTION_MIN_MISSING )) } +# Each array element is appended on its own statement rather than listed +# across a multi-line literal: kcov instruments the continuation lines but +# bash only ever reports the statement's first line, so a literal spanning N +# lines is permanently stuck at 1/N covered however thoroughly it is tested. monitor_print_format_warning() { local -a missing_indicators=("$@") - local -a box_lines=( - "" - "The following expected components were NOT found:" - ) + local -a box_lines=() local indicator="" + box_lines+=("") + box_lines+=("The following expected components were NOT found:") + for indicator in "${missing_indicators[@]}"; do box_lines+=(" ✗ ${indicator}") done - box_lines+=( - "" - "This strongly suggests the phone was factory-reset or formatted." - "" - "Next step: run the full recovery workflow:" - " ./scripts/run_all/run_phone.sh fresh-phone" - "" - "Do NOT run 'auto' mode — it will not restore anything." - ) + box_lines+=("") + box_lines+=("This strongly suggests the phone was factory-reset or formatted.") + box_lines+=("") + box_lines+=("Next step: run the full recovery workflow:") + box_lines+=(" ./scripts/run_all/run_phone.sh fresh-phone") + box_lines+=("") + box_lines+=("Do NOT run 'auto' mode — it will not restore anything.") _box "PHONE APPEARS TO HAVE BEEN WIPED" "${box_lines[@]}" } @@ -155,13 +161,6 @@ monitor_print_format_warning() { - - - - - - - monitor_collect_snapshot() { local snapshot_dir="$1" local tmp_checks="" @@ -183,11 +182,13 @@ monitor_collect_snapshot() { _check_companion_app "${tmp_checks}" _check_boot_persistence "${tmp_checks}" - { - printf '{"timestamp":"%s","device":"%s","checks":[\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$( _mon_escape_json "${ADB_SERIAL}" )" - paste -sd ',' "${tmp_checks}" - printf '\n]}\n' - } >"${report_path}" + # Redirected per statement rather than as a `{ ... } >file` group: kcov + # counts the group's closing brace as an instrumented line that bash never + # reports, which alone held this file below 100%. + printf '{"timestamp":"%s","device":"%s","checks":[\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$(_mon_escape_json "${ADB_SERIAL}")" >"${report_path}" + paste -sd ',' "${tmp_checks}" >>"${report_path}" + printf '\n]}\n' >>"${report_path}" cp "${report_path}" "$(dirname "${snapshot_dir}")/latest.json" 2>/dev/null || true rm -f "${tmp_checks}" diff --git a/phone_focus_mode/lib/tests/monitor_cases_health.sh b/phone_focus_mode/lib/tests/monitor_cases_health.sh new file mode 100755 index 00000000..1ff00ca9 --- /dev/null +++ b/phone_focus_mode/lib/tests/monitor_cases_health.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# lib/tests/monitor_cases_health.sh — assertions for monitor.sh's own helpers +# (JSON escaping, hosts-target resolution, pidfile/liveness, format +# indicators) and the device-health probes in monitor_checks_health.sh. +# +# Sourced by test_monitor.sh after monitor_harness.sh, which owns the fake +# device, the subject, and the PASS/FAIL counters this file adds to. +set -euo pipefail + +# --- helpers under monitor.sh ---------------------------------------------- + +line="$(_mon_check "test_check" "ok" "some_cmd" "all good" "false")" +_t_eq "test_check" "$(_field "${line}" check)" "_mon_check outputs check name" +_t_eq "ok" "$(_field "${line}" status)" "_mon_check outputs status" + +escaped="$(_mon_check 'a"b\c' "ok" "s" $'l1\nl2\r' "false")" +case "${escaped}" in + *'a\"b\\c'*) _t_pass "_mon_check escapes quotes and backslashes" ;; + *) _t_fail "_mon_check did not escape quotes/backslashes: ${escaped}" ;; +esac +case "${escaped}" in + *'l1\nl2\r'*) _t_pass "_mon_check escapes newlines and carriage returns" ;; + *) _t_fail "_mon_check did not escape newlines: ${escaped}" ;; +esac + +_t_eq "a b" "$(_trim_output $' a b \r\n')" "_trim_output strips CR and outer space" +_t_eq "" "$(_safe_adb_root_output "no-such-command")" "_safe_adb_root_output swallows failure" + +# --- hosts target resolution ------------------------------------------------ + +_dev_reset +_t_eq "" "$(_monitor_resolve_hosts_target)" "resolve_hosts_target empty when no candidate exists" + +_dev_reset +_dev_present /system/etc/hosts +_t_eq "/system/etc/hosts" "$(_monitor_resolve_hosts_target)" \ + "resolve_hosts_target short-circuits on the primary target" + +_dev_reset +_dev_present /vendor/etc/hosts +_t_eq "/vendor/etc/hosts" "$(_monitor_resolve_hosts_target)" \ + "resolve_hosts_target falls through to the first existing candidate" + +# --- pidfile and liveness --------------------------------------------------- + +_dev_reset +_t_eq "" "$(_monitor_read_pidfile /data/local/tmp/focus_mode/daemon.pid)" \ + "read_pidfile empty when the pidfile is absent" + +_dev_set pidfile_daemon 1234 +_t_eq "1234" "$(_monitor_read_pidfile /data/local/tmp/focus_mode/daemon.pid)" \ + "read_pidfile returns the stored pid" + +_dev_reset +if ! _monitor_pid_matches_script 999 "focus_daemon.sh"; then + _t_pass "pid_matches_script fails when the pid is not alive" +else + _t_fail "pid_matches_script should fail for a dead pid" +fi + +_dev_pid 999 +_dev_cmdline 999 "/system/bin/sh focus_daemon.sh" +if _monitor_pid_matches_script 999 "focus_daemon.sh"; then + _t_pass "pid_matches_script succeeds when cmdline matches" +else + _t_fail "pid_matches_script should succeed on a cmdline match" +fi + +# A live pid whose cmdline is hidden is trusted anyway — the deliberate +# false-negative guard at monitor.sh's "Some Android shells hide cmdline". +_dev_reset +_dev_pid 777 +if _monitor_pid_matches_script 777 "focus_daemon.sh"; then + _t_pass "pid_matches_script trusts a live pid with unreadable cmdline" +else + _t_fail "pid_matches_script should trust a live pid when cmdline is hidden" +fi + +# --- format indicators ------------------------------------------------------ + +_dev_reset +FORMAT_INDICATORS=( + "present|test -f /present" + "missing-one|test -f /missing-one" + "missing-two|test -f /missing-two" +) +_dev_present /present + +mapfile -t missing < <(monitor_check_format_indicators) +_t_eq "2" "${#missing[@]}" "check_format_indicators reports only the missing ones" +_t_eq "missing-one missing-two" "${missing[*]}" "check_format_indicators names them in order" +_t_eq "2" "$(monitor_count_missing_format_indicators)" "count_missing_format_indicators counts them" + +if monitor_is_formatted; then + _t_pass "monitor_is_formatted true at the missing-indicator threshold" +else + _t_fail "monitor_is_formatted should be true with 2 missing and threshold 2" +fi + +_t_eq "fatal" "$(_field "$(_probe _check_format_indicators)" status)" \ + "_check_format_indicators is fatal at the threshold" + +_dev_present /missing-one +_t_eq "warn" "$(_field "$(_probe _check_format_indicators)" status)" \ + "_check_format_indicators warns below the threshold" + +_dev_present /missing-two +_t_eq "ok" "$(_field "$(_probe _check_format_indicators)" status)" \ + "_check_format_indicators is ok with none missing" + +if ! monitor_is_formatted; then + _t_pass "monitor_is_formatted false when nothing is missing" +else + _t_fail "monitor_is_formatted should be false with no missing indicators" +fi + +# The guard calls _fatal, which exits, so it runs in a child rather than by +# shadowing the function here: an inline redefinition is invoked only +# indirectly, so standalone the linter calls it dead (SC2329). The child's +# source comes from a quoted heredoc, as in dns_iptables_harness.sh — it is +# code for the *child* to expand, so $VAR must survive this file untouched. +_mh_bad_count() { + MH_HARNESS="${SCRIPT_DIR}/monitor_harness.sh" bash <<'CHILD' +. "${MH_HARNESS}" +monitor_count_missing_format_indicators() { printf 'not-a-number\n'; } +monitor_is_formatted +CHILD +} + +if ! _mh_bad_count 2>/dev/null; then + _t_pass "monitor_is_formatted rejects a non-numeric count" +else + _t_fail "monitor_is_formatted should abort on a non-numeric count" +fi + +monitor_print_format_warning "missing-one" "missing-two" +_t_pass "monitor_print_format_warning renders without error" + +# Reset for the probes below, which must see no format indicators. Read back +# immediately so the assignment has a reader in this file (SC2034). +FORMAT_INDICATORS=() +_t_eq "0" "${#FORMAT_INDICATORS[@]}" "format indicators reset before the health probes" + +# --- battery ---------------------------------------------------------------- + +_dev_reset +_t_eq "warn" "$(_field "$(_probe _check_battery)" status)" "battery warns when the level is unreadable" + +_dev_set battery_level 5 +_t_eq "warn" "$(_field "$(_probe _check_battery)" status)" "battery warns below the threshold" + +_dev_set battery_level 80 +_dev_set battery_health Good +_dev_set battery_temp 305 +json="$(_probe _check_battery)" +_t_eq "ok" "$(_field "${json}" status)" "battery ok above the threshold" +case "$(_field "${json}" message)" in + *"health Good"*) _t_pass "battery message carries health and temperature" ;; + *) _t_fail "battery message lost health/temp: $(_field "${json}" message)" ;; +esac + +# Health and temp missing must not demote the status; they render as unknown. +_dev_reset +_dev_set battery_level 80 +case "$(_field "$(_probe _check_battery)" message)" in + *"health unknown"*) _t_pass "battery reports unknown health without warning" ;; + *) _t_fail "battery should render missing health as unknown" ;; +esac + +# --- storage ---------------------------------------------------------------- + +_dev_reset +_t_eq "warn" "$(_field "$(_probe _check_storage)" status)" "storage warns when both df paths fail" + +_dev_set df_emulated 1000000 +_t_eq "ok" "$(_field "$(_probe _check_storage)" status)" "storage falls back to /storage/emulated/0" + +_dev_reset +_dev_set df_sdcard 1024 +_t_eq "warn" "$(_field "$(_probe _check_storage)" status)" "storage warns below the MB threshold" + +_dev_set df_sdcard 2000000 +json="$(_probe _check_storage)" +_t_eq "ok" "$(_field "${json}" status)" "storage ok above the threshold" +_t_eq "Free storage: 1953 MB" "$(_field "${json}" message)" "storage converts KB to MB" + +# --- daemons ---------------------------------------------------------------- + +readonly PIDFILE="/data/local/tmp/focus_mode/daemon.pid" + +_dev_reset +json="$(_probe _check_daemon focus_daemon focus_daemon.sh "${PIDFILE}")" +_t_eq "error" "$(_field "${json}" status)" \ + "daemon errors when neither pidfile nor pgrep finds it" +# repairable drives whether the monitor offers to restart it, so it is pinned +# separately from the status: a dead daemon that reports itself unrepairable +# is reported but never fixed. +case "${json}" in + *'"repairable":true'*) _t_pass "a dead daemon is marked repairable" ;; + *) _t_fail "a dead daemon must be repairable: ${json}" ;; +esac + +_dev_set pidfile_daemon 4321 +_dev_pid 4321 +_dev_cmdline 4321 "focus_daemon.sh" +json="$(_probe _check_daemon focus_daemon focus_daemon.sh "${PIDFILE}")" +_t_eq "ok" "$(_field "${json}" status)" "daemon ok via the pidfile" +_t_eq "focus_daemon running (PID 4321)" "$(_field "${json}" message)" "daemon names the pidfile pid" + +# Stale pidfile, live process: the pgrep fallback is what must find it. +_dev_reset +_dev_set pidfile_daemon 4321 +_dev_set pgrep_focus_daemon.sh 8888 +_dev_pid 8888 +json="$(_probe _check_daemon focus_daemon focus_daemon.sh "${PIDFILE}")" +_t_eq "ok" "$(_field "${json}" status)" "daemon ok via the pgrep fallback" +_t_eq "focus_daemon running (PID 8888)" "$(_field "${json}" message)" "daemon names the pgrep pid" + +# pgrep reports a pid that has since exited — neither path may claim ok. +_dev_reset +_dev_set pgrep_focus_daemon.sh 8888 +_t_eq "error" "$(_field "$(_probe _check_daemon focus_daemon focus_daemon.sh "${PIDFILE}")" status)" \ + "daemon errors when the pgrep pid is already dead" + +# --- hosts daemon ----------------------------------------------------------- + +_dev_reset +_t_eq "warn" "$(_field "$(_probe _check_hosts_daemon)" status)" \ + "hosts daemon skipped when the ROM has no hosts target" + +_dev_present /system/etc/hosts +_t_eq "error" "$(_field "$(_probe _check_hosts_daemon)" status)" \ + "hosts daemon checked once a hosts target exists" + +# --- launcher daemon -------------------------------------------------------- + +_dev_reset +_t_eq "warn" "$(_field "$(_probe _check_launcher_daemon)" status)" \ + "launcher daemon skipped when launcher is unconfigured" + +_dev_reset +_dev_present /data/local/tmp/focus_mode/minimalist_launcher.activity +_t_eq "error" "$(_field "$(_probe _check_launcher_daemon)" status)" \ + "launcher daemon checked once a snapshot exists" + +_dev_reset +_dev_present pkg_launcher +_t_eq "error" "$(_field "$(_probe _check_launcher_daemon)" status)" \ + "launcher daemon checked once the launcher is installed" diff --git a/phone_focus_mode/lib/tests/monitor_cases_policy.sh b/phone_focus_mode/lib/tests/monitor_cases_policy.sh new file mode 100755 index 00000000..18528e4f --- /dev/null +++ b/phone_focus_mode/lib/tests/monitor_cases_policy.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# lib/tests/monitor_cases_policy.sh — enforcement-integrity assertions: +# hosts integrity, DNS, launcher state, companion app, boot persistence, and +# the snapshot/summary/severity surface of monitor.sh. +# +# Sourced by test_monitor.sh after monitor_harness.sh, which owns the fake +# device, the subject, and the PASS/FAIL counters this file adds to. Split out +# to keep every test file under the repo's 250-line cap; one entry point still +# runs the lot, so a single coverage command measures all three subjects. +set -euo pipefail + +# --- hosts integrity -------------------------------------------------------- + +_dev_reset +_t_eq "warn" "$(_field "$(_probe _check_hosts_integrity)" status)" \ + "hosts integrity skipped when no hosts target exists" + +_dev_present /system/etc/hosts +_t_eq "fatal" "$(_field "$(_probe _check_hosts_integrity)" status)" \ + "hosts integrity fatal when the canonical copy is missing" + +_dev_present /data/local/tmp/focus_mode/hosts.canonical +_t_eq "error" "$(_field "$(_probe _check_hosts_integrity)" status)" \ + "hosts integrity errors when the hashes cannot be read" + +_dev_set hosts_expected_sha "abc123def456789" +_t_eq "error" "$(_field "$(_probe _check_hosts_integrity)" status)" \ + "hosts integrity errors when only one hash is readable" + +_dev_set hosts_actual_sha "999888777666555" +json="$(_probe _check_hosts_integrity)" +_t_eq "error" "$(_field "${json}" status)" "hosts integrity errors on a hash mismatch" +case "$(_field "${json}" message)" in + *"Hosts mismatch"*) _t_pass "hosts integrity names the mismatch" ;; + *) _t_fail "hosts integrity message should report a mismatch" ;; +esac + +_dev_set hosts_actual_sha "abc123def456789" +_t_eq "ok" "$(_field "$(_probe _check_hosts_integrity)" status)" \ + "hosts integrity ok when the hashes agree" + +# --- DNS -------------------------------------------------------------------- + +_dev_reset +_t_eq "error" "$(_field "$(_probe _check_dns)" status)" \ + "dns errors when private DNS is off but the chains are missing" + +_dev_present dns_chain +_t_eq "ok" "$(_field "$(_probe _check_dns)" status)" \ + "dns ok when private DNS is off and the chains exist" + +_dev_set private_dns_mode "off" +_t_eq "ok" "$(_field "$(_probe _check_dns)" status)" "dns ok for an explicit off mode" + +_dev_set private_dns_mode "null" +_t_eq "ok" "$(_field "$(_probe _check_dns)" status)" "dns ok for a null mode" + +_dev_set private_dns_mode "hostname" +json="$(_probe _check_dns)" +_t_eq "error" "$(_field "${json}" status)" "dns errors when private DNS is enabled" +case "$(_field "${json}" message)" in + *"mode=hostname"*) _t_pass "dns names the enabled mode" ;; + *) _t_fail "dns should report the enabled mode" ;; +esac + +# --- launcher state --------------------------------------------------------- + +_dev_reset +_t_eq "warn" "$(_field "$(_probe _check_launcher)" status)" \ + "launcher warns when it is absent and unconfigured" + +_dev_present /data/local/tmp/focus_mode/minimalist_launcher.activity +_t_eq "fatal" "$(_field "$(_probe _check_launcher)" status)" \ + "launcher fatal when a snapshot exists but the package is gone" + +_dev_reset +_dev_present pkg_launcher +_t_eq "warn" "$(_field "$(_probe _check_launcher)" status)" \ + "launcher warns when the snapshot metadata is empty" + +_dev_set launcher_desired "com.qqlabs.minimalistlauncher/.MainActivity" +_dev_set launcher_actual "com.android.launcher3/.Launcher" +json="$(_probe _check_launcher)" +_t_eq "error" "$(_field "${json}" status)" "launcher errors on a HOME activity mismatch" +case "$(_field "${json}" message)" in + *"Launcher default mismatch"*) _t_pass "launcher names the mismatch" ;; + *) _t_fail "launcher should report the default mismatch" ;; +esac + +_dev_set launcher_actual "com.qqlabs.minimalistlauncher/.MainActivity" +_t_eq "ok" "$(_field "$(_probe _check_launcher)" status)" "launcher ok when the activities agree" + +# An unreadable current activity must not be reported as a mismatch. +_dev_reset +_dev_present pkg_launcher +_dev_set launcher_desired "com.qqlabs.minimalistlauncher/.MainActivity" +_t_eq "ok" "$(_field "$(_probe _check_launcher)" status)" \ + "launcher ok when the active activity cannot be read" + +# --- companion app and boot persistence ------------------------------------- + +_dev_reset +_t_eq "warn" "$(_field "$(_probe _check_companion_app)" status)" "companion app warns when missing" + +_dev_present pkg_companion +_t_eq "ok" "$(_field "$(_probe _check_companion_app)" status)" "companion app ok when installed" + +_dev_reset +_t_eq "fatal" "$(_field "$(_probe _check_boot_persistence)" status)" \ + "boot persistence fatal when the Magisk script is missing" + +_dev_present /data/adb/service.d/99-focus-mode.sh +json="$(_probe _check_boot_persistence)" +_t_eq "ok" "$(_field "${json}" status)" \ + "boot persistence ok when the Magisk script is executable" +case "${json}" in + *'"repairable":false'*) _t_pass "a healthy check is not marked repairable" ;; + *) _t_fail "a healthy check must not be repairable: ${json}" ;; +esac + +# --- snapshot, summary and severity ---------------------------------------- + +_dev_reset +SNAP="${TEST_TMPDIR}/snap/run" +monitor_collect_snapshot "${SNAP}" + +if [[ -f "${SNAP}/report.json" ]]; then + _t_pass "collect_snapshot writes report.json" +else + _t_fail "collect_snapshot did not write report.json" +fi + +if [[ -f "${TEST_TMPDIR}/snap/latest.json" ]]; then + _t_pass "collect_snapshot copies the report to latest.json" +else + _t_fail "collect_snapshot did not write latest.json" +fi + +checks_seen="$(grep -o '"check":"[a-z_]*"' "${SNAP}/report.json" | sort -u | wc -l)" +_t_eq "12" "${checks_seen}" "collect_snapshot emits one entry per probe" + +if python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${SNAP}/report.json"; then + _t_pass "collect_snapshot writes parseable JSON" +else + _t_fail "collect_snapshot produced invalid JSON" +fi + +case "$(cat "${SNAP}/report.json")" in + *'"device":"test-serial"'*) _t_pass "collect_snapshot records the device serial" ;; + *) _t_fail "collect_snapshot did not record the serial" ;; +esac + +monitor_print_summary "${SNAP}" >/dev/null +_t_pass "print_summary renders an existing report" + +monitor_print_summary "${TEST_TMPDIR}/no-such-dir" >/dev/null +_t_pass "print_summary warns and returns 0 for a missing report" + +if monitor_severity_exit "${TEST_TMPDIR}/no-such-dir"; then + _t_pass "severity_exit returns 0 when no report exists" +else + _t_fail "severity_exit should return 0 for a missing report" +fi + +# The all-fatal snapshot above must exit nonzero. +if ! monitor_severity_exit "${SNAP}"; then + _t_pass "severity_exit returns 1 when a check is fatal" +else + _t_fail "severity_exit should return 1 on a fatal check" +fi + +printf '%s\n' '{"checks":[{"check":"x","status":"ok","source":"s","message":"mentions fatal and error","repairable":false}]}' \ + >"${SNAP}/report.json" +if monitor_severity_exit "${SNAP}"; then + _t_pass "severity_exit ignores fatal/error words in free text" +else + _t_fail "severity_exit should only inspect the JSON status field" +fi diff --git a/phone_focus_mode/lib/tests/monitor_harness.sh b/phone_focus_mode/lib/tests/monitor_harness.sh new file mode 100755 index 00000000..2ad78f84 --- /dev/null +++ b/phone_focus_mode/lib/tests/monitor_harness.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# lib/tests/monitor_harness.sh — the fake device behind the monitor.sh tests. +# +# Sourced, not executed. Unlike dns_iptables_harness.sh there are no PATH +# stubs here: every probe in monitor_checks_{health,policy}.sh reaches the +# device through adb_root_shell, which is a *function* from adb_common.sh, so +# a stub binary on PATH would never be consulted. Mocking at the function +# boundary is both sufficient and far cheaper — it keeps adb_common.sh's +# device-detection and locking surface out of the test entirely. +# +# monitor.sh and monitor_checks_policy.sh declare readonly constants, so the +# subject can be sourced exactly once per process. State is therefore reset +# through the mock's own store (_dev_reset), never by re-sourcing. +set -euo pipefail + +_MH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly _MH_DIR + +PASS=0 +FAIL=0 + +_t_pass() { + PASS=$((PASS + 1)) + printf ' OK: %s\n' "$1" +} + +_t_fail() { + FAIL=$((FAIL + 1)) + printf ' FAIL: %s\n' "$1" +} + +# Assert two strings match, naming the expectation either way. +_t_eq() { + local want="$1" got="$2" what="$3" + if [[ "$got" == "$want" ]]; then + _t_pass "$what" + else + _t_fail "$what (want '${want}', got '${got}')" + fi +} + +TEST_TMPDIR="$(mktemp -d)" +trap 'rm -rf "${TEST_TMPDIR}"' EXIT + +readonly DEV="${TEST_TMPDIR}/device" + +# --- device state ----------------------------------------------------------- +# +# One file per fact the probes can observe. Seeding a fact is writing a file; +# the mock never grows a new case arm when a test wants a new scenario. That +# is the _seed_jumps/_fail_op idea from dns_iptables_harness.sh lifted to the +# function boundary. + +_dev_reset() { + rm -rf "${DEV}" + mkdir -p "${DEV}" +} + +# _dev_set — record an observable value (battery level, a hash). +_dev_set() { + printf '%s' "$2" >"${DEV}/$1" +} + +# _dev_get [default] — read one back, or the default when never seeded. +_dev_get() { + if [[ -f "${DEV}/$1" ]]; then + cat "${DEV}/$1" + else + printf '%s' "${2:-}" + fi +} + +# _dev_present — mark files/paths as existing for `test -f`/`-x`/-s. +_dev_present() { + local path="" + for path in "$@"; do + printf '%s\n' "${path}" >>"${DEV}/present" + done +} + +_dev_has() { + [[ -f "${DEV}/present" ]] && grep -qxF "$1" "${DEV}/present" +} + +# _dev_pid — mark a PID as alive for `kill -0`. +_dev_pid() { + printf '%s\n' "$1" >>"${DEV}/pids" +} + +_dev_pid_alive() { + [[ -f "${DEV}/pids" ]] && grep -qxF "$1" "${DEV}/pids" +} + +# _dev_cmdline — what /proc//cmdline greps against. Absent +# means the grep fails, which is the "Android hides cmdline" path. +_dev_cmdline() { + printf '%s' "$2" >"${DEV}/cmdline_$1" +} + +# --- the mock --------------------------------------------------------------- +# +# Dispatch is by *pattern*, not exact string: the real commands embed awk +# programs with escaped $2, nested quotes and 2>/dev/null redirections, which +# an exact-match case arm cannot survive being edited around. +# +# Contract matches the real adb_root_shell: stdout is the command output, +# exit status is the command's status. +adb_root_shell() { + local cmd="$*" + + case "${cmd}" in + 'test -f '*|'test -x '*|"test -s '"*) + local target="${cmd#test -? }" + target="${target#\'}" + target="${target%\'}" + _dev_has "${target}" + ;; + 'kill -0 '*) + local pid="${cmd#kill -0 }" + pid="${pid%% *}" + _dev_pid_alive "${pid}" + ;; + *'/cmdline'*) + # tr '\0' ' ' /cmdline | grep -q + local pid="${cmd#*/proc/}" + pid="${pid%%/*}" + local want="${cmd##* }" + [[ -f "${DEV}/cmdline_${pid}" ]] && grep -qF "${want}" "${DEV}/cmdline_${pid}" + ;; + *'dumpsys battery'*'level:'*) _dev_emit battery_level ;; + *'dumpsys battery'*'health:'*) _dev_emit battery_health ;; + *'dumpsys battery'*'temperature:'*) _dev_emit battery_temp ;; + *'df /sdcard'*) _dev_emit df_sdcard ;; + *'df /storage/emulated/0'*) _dev_emit df_emulated ;; + 'pgrep -f '*) _dev_emit "pgrep_$(_mh_script_of "${cmd}")" ;; + *'cat /data/local/tmp/focus_mode/'*'.pid'*) _dev_emit "pidfile_$(_mh_pidfile_of "${cmd}")" ;; + *'sha256sum'*) _dev_emit hosts_actual_sha ;; + *'cat /data/local/tmp/focus_mode/hosts.sha256'*) _dev_emit hosts_expected_sha ;; + *'cat /data/local/tmp/focus_mode/minimalist_launcher.activity'*) _dev_emit launcher_desired ;; + *'resolve-activity'*) _dev_emit launcher_actual ;; + *'settings get global private_dns_mode'*) _dev_emit private_dns_mode ;; + *'iptables -L '*) _dev_has "dns_chain" ;; + 'pm path '*) _dev_has "pkg_launcher" ;; + 'pm list packages -e '*) _dev_has "pkg_companion" ;; + *) return 1 ;; + esac +} + +# Emit a seeded value on stdout, failing like a real command when unseeded so +# that _safe_adb_root_output's `|| true` path stays reachable. +_dev_emit() { + [[ -f "${DEV}/$1" ]] || return 1 + cat "${DEV}/$1" +} + +# `pgrep -f 'foo.sh' 2>/dev/null | head -1` -> foo.sh +_mh_script_of() { + local rest="${1#pgrep -f \'}" + printf '%s' "${rest%%\'*}" +} + +# `if [ -f /.../daemon.pid ]; then cat /.../daemon.pid; fi` -> daemon +_mh_pidfile_of() { + local rest="${1##*focus_mode/}" + printf '%s' "${rest%%.pid*}" +} + +# --- ambient definitions the subject expects from its callers --------------- + +_info() { :; } +_warn() { :; } +_error() { :; } +_box() { :; } + +_fatal() { + printf 'FATAL: %s\n' "$*" >&2 + exit 1 +} + +FORMAT_INDICATORS=() +FORMAT_DETECTION_MIN_MISSING=2 +BATTERY_WARN_BELOW=20 +STORAGE_WARN_BELOW_MB=500 +ADB_SERIAL="test-serial" + +: "${FORMAT_DETECTION_MIN_MISSING}" "${BATTERY_WARN_BELOW}" +: "${STORAGE_WARN_BELOW_MB}" "${ADB_SERIAL}" "${FORMAT_INDICATORS[*]}" + +_dev_reset + +# shellcheck source=../monitor.sh +. "${_MH_DIR}/../monitor.sh" + +# --- probe drivers ---------------------------------------------------------- + +# _probe [args...] — run one _check_* into a fresh file and echo the JSON. +# Guarded: a probe that exits nonzero under `set -e` would otherwise abort the +# whole suite mid-run and still leave a plausible-looking coverage figure. +_probe() { + local out="${TEST_TMPDIR}/probe.json" + : >"${out}" + "$@" "${out}" || printf '{"status":"PROBE-FAILED"}' >"${out}" + cat "${out}" +} + +# _field — pull one string field out of a probe's JSON line. +_field() { + local json="$1" key="$2" + [[ "${json}" =~ \"${key}\":\"([^\"]*)\" ]] && printf '%s' "${BASH_REMATCH[1]}" +} diff --git a/phone_focus_mode/lib/tests/test_monitor.sh b/phone_focus_mode/lib/tests/test_monitor.sh index 758b043e..2a4bdc48 100755 --- a/phone_focus_mode/lib/tests/test_monitor.sh +++ b/phone_focus_mode/lib/tests/test_monitor.sh @@ -1,154 +1,26 @@ #!/usr/bin/env bash -# Unit tests for monitor.sh helpers that do not require a real device. +# Unit tests for monitor.sh and its two probe libraries, against the fake +# device in monitor_harness.sh. No real device is needed. +# +# Every _check_* status branch gets its own case: the probes are what decides +# whether enforcement is reported as healthy, so a probe that silently always +# says "ok" is the failure this file exists to catch. +# +# This is the only entry point — the cases live in two sourced files purely to +# stay under the 250-line cap, so one run still measures all three subjects: +# +# bash meta/scripts/shell_coverage.sh \ +# phone_focus_mode/lib/tests/test_monitor.sh monitor.sh set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -adb_root_shell() { - return 1 -} - -_info() { - : -} - -_warn() { - : -} - -_error() { - : -} - -_fatal() { - printf 'FATAL: %s\n' "$*" >&2 - exit 1 -} - -_box() { - : -} - -FORMAT_INDICATORS=() -FORMAT_DETECTION_MIN_MISSING=2 -BATTERY_WARN_BELOW=20 -STORAGE_WARN_BELOW_MB=500 -ADB_SERIAL="test-serial" - -: "${FORMAT_DETECTION_MIN_MISSING}" -: "${BATTERY_WARN_BELOW}" -: "${STORAGE_WARN_BELOW_MB}" -: "${ADB_SERIAL}" -: "${FORMAT_INDICATORS[*]}" - -source "${SCRIPT_DIR}/../monitor.sh" - -PASS=0 -FAIL=0 - -_t_pass() { - PASS=$((PASS + 1)) - printf ' OK: %s\n' "$1" -} - -_t_fail() { - FAIL=$((FAIL + 1)) - printf ' FAIL: %s\n' "$1" -} - -TMPDIR_TEST="$(mktemp -d)" -trap 'rm -rf "${TMPDIR_TEST}"' EXIT - -line="$(_mon_check "test_check" "ok" "some_cmd" "all good" "false")" -if [[ "${line}" == *'"check":"test_check"'* ]]; then - _t_pass "_mon_check outputs check name" -else - _t_fail "_mon_check missing check name" -fi - -if [[ "${line}" == *'"status":"ok"'* ]]; then - _t_pass "_mon_check outputs status" -else - _t_fail "_mon_check missing status" -fi - -if [[ -z "$(_monitor_resolve_hosts_target)" ]]; then - _t_pass "_monitor_resolve_hosts_target returns empty when no hosts file exists" -else - _t_fail "_monitor_resolve_hosts_target should be empty when all candidates are missing" -fi - -adb_root_shell() { - case "$*" in - 'test -f /system/etc/hosts') return 1 ;; - 'test -f /etc/hosts') return 1 ;; - 'test -f /vendor/etc/hosts') return 0 ;; - 'test -f /system/system/etc/hosts') return 1 ;; - *) return 1 ;; - esac -} - -if [[ "$(_monitor_resolve_hosts_target)" == "/vendor/etc/hosts" ]]; then - _t_pass "_monitor_resolve_hosts_target picks first existing candidate" -else - _t_fail "_monitor_resolve_hosts_target did not return expected candidate" -fi - -adb_root_shell() { - return 1 -} - -if ! monitor_is_formatted 2>/dev/null; then - _t_pass "monitor_is_formatted returns 1 when no indicators are missing" -else - _t_fail "monitor_is_formatted should return 1 with empty FORMAT_INDICATORS" -fi - -if monitor_severity_exit "${TMPDIR_TEST}/nonexistent"; then - _t_pass "monitor_severity_exit returns 0 when no report exists" -else - _t_fail "monitor_severity_exit should return 0 for missing report" -fi - -printf '%s\n' '{"checks":[{"check":"x","status":"fatal","source":"s","message":"m","repairable":false}]}' > "${TMPDIR_TEST}/report.json" -if ! monitor_severity_exit "${TMPDIR_TEST}"; then - _t_pass "monitor_severity_exit returns 1 on fatal status" -else - _t_fail "monitor_severity_exit should return 1 on fatal status" -fi - -printf '%s\n' '{"checks":[{"check":"x","status":"ok","source":"s","message":"mentions fatal and error in text","repairable":false}]}' > "${TMPDIR_TEST}/report.json" -if monitor_severity_exit "${TMPDIR_TEST}"; then - _t_pass "monitor_severity_exit ignores free-text fatal/error words in message" -else - _t_fail "monitor_severity_exit should only inspect the JSON status field" -fi - -FORMAT_INDICATORS=( - "present|test -f /present" - "missing-one|test -f /missing-one" - "missing-two|test -f /missing-two" -) - -: "${FORMAT_INDICATORS[*]}" - -adb_root_shell() { - case "$*" in - 'test -f /present') - return 0 - ;; - *) - return 1 - ;; - esac -} - -mapfile -t missing_indicators < <(monitor_check_format_indicators) -if [[ ${#missing_indicators[@]} -eq 2 ]] && [[ "${missing_indicators[0]}" == "missing-one" ]] && [[ "${missing_indicators[1]}" == "missing-two" ]]; then - _t_pass "monitor_check_format_indicators prints only missing indicator names" -else - _t_fail "monitor_check_format_indicators did not return the expected missing indicators" -fi +# shellcheck source=monitor_harness.sh +. "${SCRIPT_DIR}/monitor_harness.sh" +# shellcheck source=monitor_cases_health.sh +. "${SCRIPT_DIR}/monitor_cases_health.sh" +# shellcheck source=monitor_cases_policy.sh +. "${SCRIPT_DIR}/monitor_cases_policy.sh" printf '\nResults: %d passed, %d failed\n' "${PASS}" "${FAIL}" [[ "${FAIL}" -eq 0 ]]