Skip to content

Commit 3cb08f1

Browse files
refactor: reconcile registry in all modes, expand CI test report
Remove the executable-mode early-exit from reconcile_shell_integration so the context-menu keys are written regardless of how the app was launched. Drop the now-unused REGISTRY_RECONCILE_SKIPPED log event. Refactor BinaryTestReport into per-stage Check objects so each artifact (exe, log, config, Start Menu shortcut, individual registry values) is individually probed and reported rather than checked as a positional tuple. Add read_registry_value helpers and REGISTRY_VALUE_PROBES to the CI actions module, and add start_menu_shortcut to Paths in config. Update the registry test to assert that reconcile writes in executable mode.
1 parent ab5dbb4 commit 3cb08f1

5 files changed

Lines changed: 115 additions & 34 deletions

File tree

.github/workflows/scripts/actions.py

Lines changed: 101 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,30 @@ def registry_key_exists() -> bool:
204204
return False
205205

206206

207+
def read_registry_value(sub_key: str, value_name: str) -> str | None:
208+
import winreg # Windows-only; imported lazily so this module loads on the Linux CI runner.
209+
210+
try:
211+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, sub_key, 0, winreg.KEY_READ) as key:
212+
value, _ = winreg.QueryValueEx(key, value_name)
213+
return str(value)
214+
except FileNotFoundError:
215+
return None
216+
217+
218+
# (label, sub_key, value_name) for the context-menu entries the app populates on first launch.
219+
REGISTRY_VALUE_PROBES = (
220+
("Subsearch", r"Software\Classes\*\shell\Subsearch", ""),
221+
("Icon", r"Software\Classes\*\shell\Subsearch", "Icon"),
222+
("AppliesTo", r"Software\Classes\*\shell\Subsearch", "AppliesTo"),
223+
("command", r"Software\Classes\*\shell\Subsearch\command", ""),
224+
)
225+
226+
227+
def read_registry_values() -> list[tuple[str, str | None]]:
228+
return [(label, read_registry_value(sub_key, value_name)) for label, sub_key, value_name in REGISTRY_VALUE_PROBES]
229+
230+
207231
class BinaryTester:
208232
def msi_artifact_path(self) -> Path:
209233
candidates = sorted(Paths.artifacts.glob("*.msi"))
@@ -368,46 +392,96 @@ def test_executable(self, test_length: int = 30) -> None:
368392
self.end_process(EXE_NAME)
369393

370394

371-
class BinaryTestReport:
372-
_PROBES = ("Exe", "Log", "Config", "Registry key")
395+
class Check:
396+
def __init__(self, label: str, expected: bool, actual: bool, detail: str) -> None:
397+
self.label = label
398+
self.expected = expected
399+
self.actual = actual
400+
self.detail = detail
373401

374-
# Per stage, the expected presence of (exe, log, config, registry key).
375-
_EXPECTED_STATE = {
376-
"install": (True, False, False, True),
377-
"executable": (True, True, True, True),
378-
"uninstall": (False, True, True, False),
402+
@property
403+
def passed(self) -> bool:
404+
return self.actual == self.expected
405+
406+
407+
class BinaryTestReport:
408+
_TITLES = {
409+
"install": "MSI install",
410+
"executable": "Subsearch ran",
411+
"uninstall": "MSI uninstall",
379412
}
380413

381414
def __init__(self, step_summary: StepSummary) -> None:
382415
self._step_summary = step_summary
383416

384-
def _current_state(self) -> tuple[bool, bool, bool, bool]:
385-
return (
386-
Paths.installed_executable.is_file(),
387-
Paths.log_file.is_file(),
388-
Paths.config_file.is_file(),
389-
registry_key_exists(),
390-
)
417+
def _path_check(self, label: str, path: Path, expected: bool) -> Check:
418+
present = path.is_file()
419+
detail = path.as_posix() if present else f"missing ({path.as_posix()})"
420+
return Check(label, expected, present, detail)
421+
422+
def _registry_value_checks(self, expected_populated: bool) -> list[Check]:
423+
checks = []
424+
for label, value in read_registry_values():
425+
populated = value not in (None, "")
426+
if value is None:
427+
detail = "key absent"
428+
elif value == "":
429+
detail = "empty placeholder"
430+
else:
431+
detail = f"`{value}`"
432+
checks.append(Check(f"Registry {label}", expected_populated, populated, detail))
433+
return checks
434+
435+
def _checks_for_install(self) -> list[Check]:
436+
return [
437+
self._path_check("Executable", Paths.installed_executable, expected=True),
438+
self._path_check("Start Menu shortcut", Paths.start_menu_shortcut, expected=True),
439+
*self._registry_value_checks(expected_populated=False),
440+
]
441+
442+
def _checks_for_executable(self) -> list[Check]:
443+
return [
444+
self._path_check("Executable", Paths.installed_executable, expected=True),
445+
self._path_check("Log", Paths.log_file, expected=True),
446+
self._path_check("Config", Paths.config_file, expected=True),
447+
*self._registry_value_checks(expected_populated=True),
448+
]
449+
450+
def _checks_for_uninstall(self) -> list[Check]:
451+
return [
452+
self._path_check("Executable removed", Paths.installed_executable, expected=False),
453+
self._path_check("Start Menu shortcut removed", Paths.start_menu_shortcut, expected=False),
454+
Check("Registry key removed", expected=False, actual=registry_key_exists(), detail="HKCU context-menu key"),
455+
]
456+
457+
def _checks_for_stage(self, name: str) -> list[Check]:
458+
return {
459+
"install": self._checks_for_install,
460+
"executable": self._checks_for_executable,
461+
"uninstall": self._checks_for_uninstall,
462+
}[name]()
463+
464+
def add_stage_card(self, name: str) -> None:
465+
checks = self._checks_for_stage(name)
466+
passed = all(check.passed for check in checks)
467+
self._step_summary.card(f"{self._TITLES[name]}: {self._step_summary.result(passed)}", passed=passed)
468+
self._step_summary.add_summary("Reason:")
469+
for check in checks:
470+
mark = self._step_summary.result(check.passed)
471+
self._step_summary.add_summary(f"- {check.label}: {mark} - {check.detail}")
472+
self._log_stage(name, checks, passed)
473+
self._assert_stage_passed(name, passed)
391474

392475
def _assert_stage_passed(self, name: str, passed: bool) -> None:
393476
if not passed:
394477
list_files_in_directory(Paths.installed_executable.parent)
395478
list_files_in_directory(Paths.log_file.parent)
396479
raise RuntimeError(f"{name} test failed")
397480

398-
def add_stage_card(self, name: str) -> None:
399-
actual = self._current_state()
400-
expected = self._EXPECTED_STATE[name]
401-
passed = actual == expected
402-
self._step_summary.card(f"{name.capitalize()} test", passed=passed)
403-
for probe, probe_found, probe_expected in zip(self._PROBES, actual, expected):
404-
self._step_summary.add_summary(f"- {probe}: {self._step_summary.result(probe_found == probe_expected)}")
405-
self._log_stage(name, actual, passed)
406-
self._assert_stage_passed(name, passed)
407-
408-
def _log_stage(self, name: str, actual: tuple[bool, bool, bool, bool], passed: bool) -> None:
409-
log(", ".join(f"{probe}: {present}" for probe, present in zip(self._PROBES, actual)))
410-
log(f"{name.capitalize()} test {'passed' if passed else 'failed'}", level="PASS" if passed else "FAIL")
481+
def _log_stage(self, name: str, checks: list[Check], passed: bool) -> None:
482+
for check in checks:
483+
log(f"{check.label}: {'OK' if check.passed else 'BAD'} ({check.detail})")
484+
log(f"{self._TITLES[name]} {'passed' if passed else 'failed'}", level="PASS" if passed else "FAIL")
411485
print(STYLE_SEPARATOR)
412486

413487

.github/workflows/scripts/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ class Paths:
2323
installed_executable: Path = install_directory / EXE_NAME
2424
log_file: Path = user_data_directory / "log.log"
2525
config_file: Path = user_data_directory / "config.json"
26+
start_menu_shortcut: Path = (
27+
home_directory / "AppData" / "Roaming" / "Microsoft" / "Windows" / "Start Menu" / "Programs" / f"{APP_NAME}.lnk"
28+
)
2629

2730
working_directory: Path = Path.cwd()
2831
pyproject: Path = working_directory / "pyproject.toml"

src/subsearch/io/windows_registry.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,6 @@ def _write_registry_value(sub_key: str, value_name: str, value: str) -> None:
145145

146146

147147
def reconcile_shell_integration() -> None:
148-
if DEVICE_INFO.mode == "executable":
149-
log.event(LogEvent.REGISTRY_RECONCILE_SKIPPED, level="debug")
150-
return
151148
context_menu_enabled = config_session.read_config_value(ConfigKey.SHELL_INTEGRATION_CONTEXT_MENU)
152149
if not context_menu_enabled:
153150
if _context_menu_key_exists():

src/subsearch/runtime/logging/events.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ class LogEvent(StrEnum):
4848
REGISTRY_KEY_DELETING = "registry.key_deleting"
4949
REGISTRY_CONTEXT_MENU_REMOVING = "registry.context_menu_removing"
5050
REGISTRY_KEY_MISSING = "registry.key_missing"
51-
REGISTRY_RECONCILE_SKIPPED = "registry.reconcile_skipped"
5251
REGISTRY_MATCHES_ABSENT = "registry.matches_absent"
5352
REGISTRY_MATCHES_CURRENT = "registry.matches_current"
5453
REGISTRY_VALUE_UPDATED = "registry.value_updated"
@@ -209,7 +208,6 @@ class LogEvent(StrEnum):
209208
LogEvent.REGISTRY_KEY_DELETING: "Deleting registry key: {key}",
210209
LogEvent.REGISTRY_CONTEXT_MENU_REMOVING: "Removing Subsearch context menu from registry",
211210
LogEvent.REGISTRY_KEY_MISSING: "Registry key missing, could not write {sub_key}\\{value_name}",
212-
LogEvent.REGISTRY_RECONCILE_SKIPPED: "Skipping registry reconcile: MSI installer owns the context menu keys",
213211
LogEvent.REGISTRY_MATCHES_ABSENT: "Registry matches config: context menu absent",
214212
LogEvent.REGISTRY_MATCHES_CURRENT: "Registry matches config: context menu up to date",
215213
LogEvent.REGISTRY_VALUE_UPDATED: "Registry updated: {name}",

tests/test_windows_registry.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,25 @@ def test_changed_value_names_reports_everything_when_keys_absent():
2424
assert windows_registry.changed_value_names(desired, current) == ["subsearch", "icon"]
2525

2626

27-
def test_reconcile_skips_in_executable_mode(monkeypatch):
27+
def test_reconcile_writes_in_executable_mode(monkeypatch):
2828
monkeypatch.setattr(
2929
windows_registry, "DEVICE_INFO", dataclasses.replace(windows_registry.DEVICE_INFO, mode="executable")
3030
)
31+
monkeypatch.setattr(windows_registry.config_session, "read_config_value", lambda key: True)
32+
monkeypatch.setattr(windows_registry, "desired_registry_values", lambda: {"command": "run"})
33+
monkeypatch.setattr(windows_registry, "current_registry_values", lambda: {"command": None})
34+
monkeypatch.setattr(windows_registry, "_create_context_menu_keys", lambda: None)
35+
written = []
3136
monkeypatch.setattr(
32-
windows_registry, "current_registry_values", lambda: (_ for _ in ()).throw(AssertionError("touched registry"))
37+
windows_registry,
38+
"_write_registry_value",
39+
lambda sub_key, value_name, value: written.append((sub_key, value_name, value)),
3340
)
3441

3542
windows_registry.reconcile_shell_integration()
3643

44+
assert written == [(*windows_registry.VALUE_LOCATIONS["command"], "run")]
45+
3746

3847
def test_reconcile_disabled_and_absent_does_not_touch_registry(monkeypatch):
3948
monkeypatch.setattr(

0 commit comments

Comments
 (0)