diff --git a/spec/integration/cli_spec.rb b/spec/integration/cli_spec.rb new file mode 100644 index 0000000..34f49ea --- /dev/null +++ b/spec/integration/cli_spec.rb @@ -0,0 +1,366 @@ +# SPDX-FileCopyrightText: Copyright 2026 Todd Schulman +# +# SPDX-License-Identifier: GPL-3.0-or-later + +require "open3" + +# Behavioral tests for the blackoutd CLI's argument handling — the surface +# the manual checklist (spec/manual/TESTING.md) cannot cover cheaply: +# usage/validation/rejection paths, --version, --dry-run walkthroughs, and +# the preference write-back subcommands. Hardware- and daemon-affecting +# behavior (actual blackout, real sleep/wake recovery, launchctl lifecycle, +# live display cycling) stays in the manual checklist by design. +# +# The write-back subcommands (auto / verbosity / recovery) normally mutate +# the real "blackoutd" NSUserDefaults suite and SIGHUP the running daemon. +# The accept-path examples here set BLACKOUTD_TEST_DEFAULTS_SUITE to a +# throwaway suite (the src/main.m test seam): writes divert to that suite and +# the daemon is not signaled, so these tests never touch the user's real +# preferences or the running daemon. An invalid value for that var (the real +# suite name, or non-UTF-8) is a hard error before any write — exercised by +# the "test-suite isolation guard" examples. +# +# The macOS-only ObjC binary is built once in before(:all); the RSpec CI job +# runs on macos-latest but does not build, so the suite builds it itself. + +BLACKOUTD_BIN = File.join(REPO_ROOT, "build", "blackoutd") + +module CLISpecHelpers + # Runs the built binary with args and optional env overrides. + # Returns [stdout, stderr, Process::Status]. Output is forced to UTF-8: + # Open3 returns ASCII-8BIT, and the usage text carries em-dashes that + # would raise "invalid byte sequence" on a US-ASCII regex match. + def blackoutd(*args, env: {}) + out, err, status = Open3.capture3(env, BLACKOUTD_BIN, *args) + [out.force_encoding("UTF-8"), err.force_encoding("UTF-8"), status] + end + + # Yields a unique throwaway NSUserDefaults suite name for accept-path + # isolation, then best-effort removes it. Cleanup failure is ignored: the + # suite name is unique per example and read by nothing. + def with_isolated_suite + suite = "blackoutd-test-#{Process.pid}-#{rand(1_000_000)}" + yield suite + ensure + system("defaults", "delete", suite, out: File::NULL, err: File::NULL) + end + + # The value of a key in the real suite, or "" when absent. stdout only: + # an absent key sends a timestamped `defaults[pid]` diagnostic to stderr + # (differs between calls), while stdout is the value or empty — the stable + # thing to compare when asserting the real suite is untouched. + def real_default(key) + out, = Open3.capture3("defaults", "read", "blackoutd", key) + out.force_encoding("UTF-8") + end +end + +RSpec.describe "blackoutd CLI" do + include CLISpecHelpers + + before(:all) do + skip "macOS-only ObjC binary" unless RUBY_PLATFORM.include?("darwin") + out, err, status = Open3.capture3("make", "-C", REPO_ROOT) + raise "make failed before CLI specs:\n#{out}#{err}" unless status.success? + unless File.executable?(BLACKOUTD_BIN) + raise "binary missing after build: #{BLACKOUTD_BIN}" + end + end + + describe "top-level dispatch" do + it "prints usage to stderr and exits 1 with no arguments" do + _out, err, status = blackoutd + expect(status.exitstatus).to eq(1) + expect(err).to include("Usage: blackoutd") + end + + it "prints usage and exits 1 on an unknown command" do + _out, err, status = blackoutd("frobnicate") + expect(status.exitstatus).to eq(1) + expect(err).to include("Usage: blackoutd") + end + + it "lists every subcommand in the usage text" do + _out, err, _status = blackoutd + %w[on off status diagnose recover repro verbosity recovery auto + daemon].each do |cmd| + expect(err).to match(/^\s+#{Regexp.escape(cmd)}\b/) + end + end + + it "prints a semver version and build stamp on --version" do + out, _err, status = blackoutd("--version") + expect(status.exitstatus).to eq(0) + expect(out).to match(/^blackoutd \d+\.\d+\.\d+ /) + expect(out).to include("built:") + end + end + + describe "verbosity" do + it "rejects a missing argument" do + _out, err, status = blackoutd("verbosity") + expect(status.exitstatus).to eq(1) + expect(err).to include("Usage: blackoutd verbosity") + end + + it "rejects a non-numeric level" do + _out, err, status = blackoutd("verbosity", "high") + expect(status.exitstatus).to eq(1) + expect(err).to include("Usage: blackoutd verbosity") + end + + it "rejects an out-of-range level" do + _out, _err, status = blackoutd("verbosity", "3") + expect(status.exitstatus).to eq(1) + end + + it "persists a valid level to an isolated suite without signaling" do + with_isolated_suite do |suite| + out, _err, status = + blackoutd("verbosity", "2", + env: { "BLACKOUTD_TEST_DEFAULTS_SUITE" => suite }) + expect(status.exitstatus).to eq(0) + expect(out).to match(/verbosity: 2 .*isolated test suite/) + end + end + end + + describe "recovery" do + it "rejects a missing argument" do + _out, err, status = blackoutd("recovery") + expect(status.exitstatus).to eq(1) + expect(err).to include("Usage: blackoutd recovery ") + end + + it "rejects an unknown strategy" do + _out, err, status = blackoutd("recovery", "bogus") + expect(status.exitstatus).to eq(1) + expect(err).to include("Usage: blackoutd recovery ") + end + + it "accepts displaysleep against an isolated suite" do + with_isolated_suite do |suite| + out, _err, status = + blackoutd("recovery", "displaysleep", + env: { "BLACKOUTD_TEST_DEFAULTS_SUITE" => suite }) + expect(status.exitstatus).to eq(0) + expect(out).to match(/recovery: displaysleep .*isolated test suite/) + end + end + + it "accepts none against an isolated suite" do + with_isolated_suite do |suite| + out, _err, status = + blackoutd("recovery", "none", + env: { "BLACKOUTD_TEST_DEFAULTS_SUITE" => suite }) + expect(status.exitstatus).to eq(0) + expect(out).to match(/recovery: none .*isolated test suite/) + end + end + + it "leaves the real blackoutd suite untouched when isolated" do + before_val = real_default("recoveryStrategy") + with_isolated_suite do |suite| + env = { "BLACKOUTD_TEST_DEFAULTS_SUITE" => suite } + blackoutd("recovery", "none", env: env) + blackoutd("recovery", "displaysleep", env: env) + end + expect(real_default("recoveryStrategy")).to eq(before_val) + end + end + + describe "auto" do + it "rejects a missing argument" do + _out, err, status = blackoutd("auto") + expect(status.exitstatus).to eq(1) + expect(err).to include("Usage: blackoutd auto [on|off]") + end + + it "rejects an unknown value" do + _out, err, status = blackoutd("auto", "maybe") + expect(status.exitstatus).to eq(1) + expect(err).to include("Usage: blackoutd auto [on|off]") + end + + it "accepts off against an isolated suite" do + with_isolated_suite do |suite| + out, _err, status = + blackoutd("auto", "off", + env: { "BLACKOUTD_TEST_DEFAULTS_SUITE" => suite }) + expect(status.exitstatus).to eq(0) + expect(out).to match(/auto-blackout: disabled .*isolated test suite/) + end + end + end + + # The seam must fail loudly on an invalid isolation value rather than + # silently falling back to the real suite (which would write real + # preferences while skipping the daemon signal). Because the guard aborts + # before any subcommand runs, these rejection paths are safe to automate. + describe "test-suite isolation guard" do + it "refuses to run when the test suite names the real suite" do + out, err, status = + blackoutd("recovery", "displaysleep", + env: { "BLACKOUTD_TEST_DEFAULTS_SUITE" => "blackoutd" }) + expect(status.exitstatus).to eq(2) + expect(err).to include("must not be the real suite") + expect(out).not_to include("isolated test suite") + end + + it "refuses to run on a non-UTF-8 test suite" do + out, err, status = + blackoutd("recovery", "none", + env: { "BLACKOUTD_TEST_DEFAULTS_SUITE" => "\xFF\xFE".b }) + expect(status.exitstatus).to eq(2) + expect(err).to include("not valid UTF-8") + expect(out).not_to include("isolated test suite") + end + + it "does not touch the real suite when it rejects the value" do + before_val = real_default("recoveryStrategy") + blackoutd("recovery", "none", + env: { "BLACKOUTD_TEST_DEFAULTS_SUITE" => "blackoutd" }) + expect(real_default("recoveryStrategy")).to eq(before_val) + end + end + + describe "recover" do + it "rejects an unknown method and lists the known ones" do + _out, err, status = blackoutd("recover", "--method", "bogus") + expect(status.exitstatus).to eq(1) + expect(err).to include("displaysleep") + expect(err).to include("extcycle") + expect(err).to include("fbpower") + end + + it "rejects an unknown option" do + _out, err, status = blackoutd("recover", "--nope") + expect(status.exitstatus).to eq(1) + expect(err).to include("unknown recover option") + end + + it "previews the displaysleep cycle under --dry-run" do + out, _err, status = + blackoutd("recover", "--method", "displaysleep", "--dry-run") + expect(status.exitstatus).to eq(0) + expect(out).to include("[dry-run]") + expect(out).to include("pmset displaysleepnow") + expect(out).to include("caffeinate") + end + + it "previews the extcycle sequence under --dry-run" do + out, _err, status = + blackoutd("recover", "--method", "extcycle", "--dry-run") + expect(status.exitstatus).to eq(0) + expect(out).to match(/extcycle: disable external.*re-enable/) + end + + it "previews the fbpower probe under --dry-run" do + out, _err, status = + blackoutd("recover", "--method", "fbpower", "--dry-run") + expect(status.exitstatus).to eq(0) + expect(out).to include("fbpower: open external-0") + end + end + + describe "repro argument validation" do + it "rejects an unknown trigger before sleeping" do + _out, err, status = blackoutd("repro", "--trigger", "bogus") + expect(status.exitstatus).to eq(1) + expect(err).to include("unknown trigger") + end + + it "rejects a non-alphanumeric group" do + _out, err, status = blackoutd("repro", "--group", "!!") + expect(status.exitstatus).to eq(1) + expect(err).to include("--group must be") + end + + it "rejects an empty --wake" do + _out, err, status = blackoutd("repro", "--wake", "") + expect(status.exitstatus).to eq(1) + expect(err).to include("non-negative integer") + end + + it "rejects a non-numeric --settle" do + _out, err, status = blackoutd("repro", "--settle", "abc") + expect(status.exitstatus).to eq(1) + expect(err).to include("non-negative integer") + end + + it "rejects an unknown option" do + _out, err, status = blackoutd("repro", "--frob") + expect(status.exitstatus).to eq(1) + expect(err).to include("unknown repro option") + end + end + + describe "repro --dry-run walkthrough" do + # The step lines (sayCue prints " [step] ") plus the sudo-prime + # and schedule notices, in emission order. + def dry_run_lines(*extra_args) + out, err, status = blackoutd("repro", "--dry-run", *extra_args) + expect(status.exitstatus).to eq(0) + "#{out}#{err}".lines.map(&:chomp) + end + + def index_of(lines, needle) + lines.index { |l| l.include?(needle) } + end + + it "orders the trigger BEFORE scheduling the wake (the W1 fix)" do + lines = dry_run_lines("--wake", "15", "--trigger", "extcycle", + "--recover", "displaysleep") + prime = index_of(lines, "priming sudo") + trigger = index_of(lines, "triggering external cycle") + schedule = index_of(lines, "scheduling wake") + sleep_now = index_of(lines, "[step] sleeping now") + awake = index_of(lines, "[step] awake") + capture = index_of(lines, "capturing post wake") + recover = index_of(lines, "[step] recovering") + [prime, trigger, schedule, sleep_now, awake, capture, + recover].each { |i| expect(i).not_to be_nil } + # prime < trigger < schedule < sleepnow < awake < capture < recover + expect(prime).to be < trigger + expect(trigger).to be < schedule + expect(schedule).to be < sleep_now + expect(sleep_now).to be < awake + expect(awake).to be < capture + expect(capture).to be < recover + end + + it "locks before scheduling the wake" do + lines = dry_run_lines("--wake", "15", "--lock") + lock = index_of(lines, "locking session") + schedule = index_of(lines, "scheduling wake") + expect(lock).not_to be_nil + expect(schedule).not_to be_nil + expect(lock).to be < schedule + end + + it "speaks an awake cue between sleep and capture" do + lines = dry_run_lines("--wake", "0") + sleep_now = index_of(lines, "[step] sleeping now") + awake = index_of(lines, "[step] awake") + capture = index_of(lines, "capturing post wake") + expect(awake).to be_between(sleep_now + 1, capture - 1) + end + + it "skips sudo entirely for a manual wake (--wake 0)" do + lines = dry_run_lines("--wake", "0") + expect(lines.any? { |l| l.include?("priming sudo") }).to be false + expect(lines.any? { |l| l.include?("scheduling wake") }).to be false + end + end + + describe "help lists the new subcommands and methods" do + it "documents recovery, recover methods, and repro flags in usage" do + _out, err, _status = blackoutd + expect(err).to include("recovery ") + expect(err).to include("extcycle") + expect(err).to include("fbpower") + expect(err).to include("--trigger") + expect(err).to include("--lock") + end + end +end diff --git a/spec/manual/TESTING.md b/spec/manual/TESTING.md index faed0fc..10ab422 100644 --- a/spec/manual/TESTING.md +++ b/spec/manual/TESTING.md @@ -55,6 +55,37 @@ These tests require a MacBook with an external display connected via USB-C. - [ ] Works on AC power - [ ] External does not go black ~30s after wake (the pre-fix failure mode) +## Cursor-on-black auto-recovery (P20/P29) + +The CLI argument surface for these commands is covered by +`spec/integration/cli_spec.rb`; the items below need real hardware, a +running daemon, and an eyewitness, so they stay manual. Use `blackoutd +repro` (see `docs/debug/cursor-on-black-matrix.md`) to provoke the black. + +- [ ] `blackoutd recovery displaysleep` reports the value and, with the + daemon running, notifies it (`status` then shows `recovery : + displaysleep`) +- [ ] `blackoutd recovery none` disables auto-recovery; `status` reflects it +- [ ] With `recovery displaysleep` and an external attached, a + cursor-on-black wake self-clears within a few seconds of settle + (daemon log shows `[wake] recovery=displaysleep marker=present`) +- [ ] With `recovery none`, the same wake stays black (marker logged, + no recovery issued) — the control case for data collection +- [ ] A clean wake logs `marker=absent` and does not cycle the display +- [ ] `blackoutd recover --method displaysleep` clears a black manually +- [ ] `blackoutd recover --method extcycle` clears a black in an + **unlocked** session (do NOT run while locked — induces blacks; + see the matrix group C notes) + +## repro harness (maintainer-run) + +- [ ] `blackoutd repro --wake 15 --trigger extcycle` provokes the black + and the scheduled wake fires (no manual wake needed) +- [ ] The spoken "awake" cue lands at first light; the run sheet and + bundles are written under `docs/debug/` +- [ ] `--lock` locks the session before sleep; the run stays locked + through capture (keep the Apple Watch out of range) + ## Build/Install Cycle - [ ] `make clean; make; make reinstall` succeeds diff --git a/src/main.m b/src/main.m index d0029c0..cf451f3 100644 --- a/src/main.m +++ b/src/main.m @@ -35,6 +35,59 @@ static NSString *const kRecoveryKey = @"recoveryStrategy"; static NSString *const kAgentLabel = @BD_BUNDLE_ID; +// Test-only isolation seam. Its sole consumer is the project's own test +// suite; it is not a supported public interface. When +// BLACKOUTD_TEST_DEFAULTS_SUITE names a valid throwaway suite, the CLI +// preference helpers read and write that suite instead of the real +// "blackoutd" suite, and the write-back subcommands (auto / verbosity / +// recovery) skip signaling the daemon. This lets the CLI harness exercise +// the accept-paths without mutating the user's real preferences or +// perturbing the running daemon, which always reads the hardcoded +// kSuiteName — this seam is deliberately NOT wired into AppDelegate.m. +// Unset (normal operation) ⇒ identical to targeting kSuiteName directly +// with the daemon signaled as before. +static const char *const kTestSuiteEnv = "BLACKOUTD_TEST_DEFAULTS_SUITE"; + +// Resolved once by initCliDefaults() at startup, then read by the helpers. +static NSString *gCliSuite = nil; +static BOOL gCliDefaultsIsolated = NO; + +// Validates the test-suite env var and sets the file-statics above. Returns +// 0 for normal operation and for a valid isolation suite. When the var is +// present but invalid — not decodable as UTF-8, or equal to the real suite +// (which would write real/global preferences while skipping the daemon +// signal, the opposite of isolation) — prints an error and returns a +// non-zero exit code so main() aborts BEFORE any subcommand writes defaults +// or signals the daemon. A test can therefore exercise the rejection paths +// with no real-state side effects. +static int initCliDefaults(void) { + const char *raw = getenv(kTestSuiteEnv); + if (raw == NULL || raw[0] == '\0') { + gCliSuite = kSuiteName; + gCliDefaultsIsolated = NO; + return 0; + } + NSString *name = [NSString stringWithUTF8String:raw]; + if (name == nil) { + fprintf(stderr, "blackoutd: %s is not valid UTF-8; refusing to run\n", + kTestSuiteEnv); + return 2; + } + if ([name isEqualToString:kSuiteName]) { + fprintf(stderr, + "blackoutd: %s must not be the real suite '%s'; refusing to run\n", + kTestSuiteEnv, kSuiteName.UTF8String); + return 2; + } + gCliSuite = name; + gCliDefaultsIsolated = YES; + return 0; +} + +static NSString *cliSuiteName(void) { return gCliSuite ?: kSuiteName; } + +static BOOL cliDefaultsIsolated(void) { return gCliDefaultsIsolated; } + static NSString *agentPlistPath(void) { return [NSHomeDirectory() stringByAppendingPathComponent: @@ -554,7 +607,7 @@ static void appendConnectionModes(NSMutableString *r) { static NSString *buildReport(void) { NSProcessInfo *info = NSProcessInfo.processInfo; NSUserDefaults *defaults = - [[NSUserDefaults alloc] initWithSuiteName:kSuiteName]; + [[NSUserDefaults alloc] initWithSuiteName:cliSuiteName()]; BOOL autoMode = [defaults objectForKey:kAutoBlackoutKey] != nil ? [defaults boolForKey:kAutoBlackoutKey] : YES; @@ -823,7 +876,7 @@ static int runDiagnose(int minutes, NSString *start, NSString *end, BOOL quiet, static int printStatus(void) { pid_t pid = daemonPid(); NSUserDefaults *defaults = - [[NSUserDefaults alloc] initWithSuiteName:kSuiteName]; + [[NSUserDefaults alloc] initWithSuiteName:cliSuiteName()]; BOOL autoMode = [defaults objectForKey:kAutoBlackoutKey] != nil ? [defaults boolForKey:kAutoBlackoutKey] : YES; @@ -850,9 +903,15 @@ static int setAutoBlackout(const char *value) { } BOOL enable = strcmp(value, "on") == 0; NSUserDefaults *defaults = - [[NSUserDefaults alloc] initWithSuiteName:kSuiteName]; + [[NSUserDefaults alloc] initWithSuiteName:cliSuiteName()]; [defaults setBool:enable forKey:kAutoBlackoutKey]; [defaults synchronize]; + if (cliDefaultsIsolated()) { + printf("auto-blackout: %s (isolated test suite '%s'; daemon not " + "notified)\n", + enable ? "enabled" : "disabled", cliSuiteName().UTF8String); + return 0; + } printf("auto-blackout: %s\n", enable ? "enabled" : "disabled"); return sendSignalToDaemon(SIGHUP); } @@ -872,10 +931,15 @@ static int setRecoveryStrategy(const char *value) { return 1; } NSUserDefaults *defaults = - [[NSUserDefaults alloc] initWithSuiteName:kSuiteName]; + [[NSUserDefaults alloc] initWithSuiteName:cliSuiteName()]; [defaults setObject:@(value) forKey:kRecoveryKey]; [defaults synchronize]; NSString *applied = [defaults stringForKey:kRecoveryKey]; + if (cliDefaultsIsolated()) { + printf("recovery: %s (isolated test suite '%s'; daemon not notified)\n", + applied.UTF8String, cliSuiteName().UTF8String); + return 0; + } pid_t pid = daemonPid(); if (pid > 0) { if (kill(pid, SIGHUP) != 0) { @@ -927,12 +991,17 @@ static int setVerbosity(const char *value) { } NSUserDefaults *defaults = - [[NSUserDefaults alloc] initWithSuiteName:kSuiteName]; + [[NSUserDefaults alloc] initWithSuiteName:cliSuiteName()]; [defaults setInteger:level forKey:kVerbosityKey]; [defaults synchronize]; // Read the persisted value back so the reported number is what the daemon // will load on reload, not merely the parsed input. long applied = (long)[defaults integerForKey:kVerbosityKey]; + if (cliDefaultsIsolated()) { + printf("verbosity: %ld (isolated test suite '%s'; daemon not notified)\n", + applied, cliSuiteName().UTF8String); + return 0; + } pid_t pid = daemonPid(); if (pid > 0) { if (kill(pid, SIGHUP) != 0) { @@ -2197,6 +2266,12 @@ int main(int argc, const char *argv[]) { setvbuf(stderr, NULL, _IONBF, 0); @autoreleasepool { + // Resolve the CLI defaults suite before any subcommand runs; an invalid + // test-suite override aborts here, before any write or daemon signal. + int suiteRC = initCliDefaults(); + if (suiteRC != 0) + return suiteRC; + if (argc < 2) { printUsage(); return 1;