# -*- coding: utf-8 -*- """Tests for tools/setup_machine.py's `.setup-opts` opt-file. Loaded the way `test_libindex.py` and `test_snapshot_cycle.py` load their `tools/` modules: put `tools/` on `sys.path` and import it directly. Lives here because this is where every other `tools/*.py` test already lives. WHAT THIS FILE DOES NOT COVER ------------------------------ Nothing here shells out to real `pip`, PowerShell, or the real `panellift` CLI -- those are exercised by hand (see the module's own docstring and machine-setup.md) because they mutate machine-wide state (env vars, the installed package set) that a test suite must not touch. What IS covered is everything `.setup-opts` changes about the DECISIONS this script makes: whether a line is read at all, whether an unrecognised or malformed line is refused, whether the panellift step is skipped instead of run, and how the final VERDICT line counts a skip. `_run` is monkeypatched wherever a real subprocess would otherwise be launched, and `S.OPTS_FILE` is monkeypatched to a path under `tmp_path` so nothing here reads or writes the real repo root's `.setup-opts`. """ import os import subprocess import sys import pytest ROOT = os.path.dirname(os.path.dirname(os.path.dirname( os.path.abspath(__file__)))) TOOLS = os.path.join(ROOT, 'tools') if TOOLS not in sys.path: sys.path.insert(0, TOOLS) S = pytest.importorskip('setup_machine') def _step(): """A Step that records every line to a list instead of printing it.""" out = [] step = S.Step(out.append) step.lines = out return step def _write_opts(path, text): with open(str(path), 'w', encoding='utf-8') as fh: fh.write(text) def _git(repo, *args): """Run a real `git` command against a real temp repo. Used instead of a mocked porcelain string for the completeness-guard tests below -- Gitea issue #1's diagnosis turned on exactly which two-column codes a real `git status --porcelain` prints for real filesystem states (an untracked folder vs. a tracked file actually missing from disk), and a hand-typed porcelain string is precisely the kind of guess that could quietly encode the same wrong assumption the bug had.""" result = subprocess.run(['git', '-C', str(repo)] + list(args), capture_output=True, text=True, encoding='utf-8') assert result.returncode == 0, ( 'git {0} failed: {1}'.format(' '.join(args), result.stderr)) return result def _init_repo(repo): repo.mkdir() _git(repo, 'init', '-q', '.') _git(repo, 'config', 'user.email', 'scrub-test' + '@' + 'example.invalid') _git(repo, 'config', 'user.name', 'test') return repo # --------------------------------------------------------------- load_opts() def test_absent_file_returns_no_opts_and_no_failures(tmp_path, monkeypatch): """No `.setup-opts` at all -- today's default on every machine that has never declared one. Must behave exactly as if the feature did not exist: an empty dict, nothing on `step.failures`.""" monkeypatch.setattr(S, 'OPTS_FILE', str(tmp_path / '.setup-opts')) step = _step() opts = S.load_opts(step) assert opts == {} assert step.failures == [] assert step.lines == [] def test_dev_mode_declared_is_read(tmp_path, monkeypatch): opts_file = tmp_path / '.setup-opts' _write_opts(opts_file, 'panellift: dev-mode\n') monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file)) step = _step() opts = S.load_opts(step) assert opts == {'panellift': 'dev-mode'} assert step.failures == [] def test_comments_and_blank_lines_are_ignored(tmp_path, monkeypatch): opts_file = tmp_path / '.setup-opts' _write_opts(opts_file, '# a comment\n\n \npanellift: dev-mode # trailing note\n') monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file)) step = _step() opts = S.load_opts(step) assert opts == {'panellift': 'dev-mode'} assert step.failures == [] def test_stale_entry_is_flagged_not_silently_dropped(tmp_path, monkeypatch): """A step name this script does not know -- the blindfold-left-behind case. Must be refused loudly (a FAIL step naming it 'stale'), not merged into an empty-looking opts dict that quietly ignores it.""" opts_file = tmp_path / '.setup-opts' _write_opts(opts_file, 'snapshot_cycle: dev-mode\n') monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file)) step = _step() opts = S.load_opts(step) assert opts == {} assert len(step.failures) == 1 assert 'stale' in ' '.join(step.lines).lower() assert 'snapshot_cycle' in ' '.join(step.lines) def test_unknown_mode_for_a_known_step_is_flagged(tmp_path, monkeypatch): opts_file = tmp_path / '.setup-opts' _write_opts(opts_file, 'panellift: some-other-mode\n') monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file)) step = _step() opts = S.load_opts(step) assert opts == {} assert step.failures assert 'some-other-mode' in ' '.join(step.lines) def test_empty_value_is_refused_like_a_bare_empty_key(tmp_path, monkeypatch): """`panellift:` with nothing after the colon is ambiguous -- it cannot be told apart from a value that was meant to be written and was not -- so it is refused, the same way a bare empty knowledge key is, rather than treated as an opt-out.""" opts_file = tmp_path / '.setup-opts' _write_opts(opts_file, 'panellift:\n') monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file)) step = _step() opts = S.load_opts(step) assert opts == {} assert step.failures assert 'no value' in ' '.join(step.lines).lower() def test_unreadable_line_is_refused(tmp_path, monkeypatch): opts_file = tmp_path / '.setup-opts' _write_opts(opts_file, 'this is not a key value line\n') monkeypatch.setattr(S, 'OPTS_FILE', str(opts_file)) step = _step() opts = S.load_opts(step) assert opts == {} assert step.failures # ----------------------------------------------------------- verify_panellift def test_verify_panellift_skips_in_dev_mode_and_names_the_file(tmp_path, monkeypatch): monkeypatch.setattr(S, 'OPTS_FILE', str(tmp_path / '.setup-opts')) step = _step() ok = S.verify_panellift(step, str(tmp_path), {'panellift': 'dev-mode'}) assert ok is True assert step.skipped == ['panellift CLI runs installed (no PYTHONPATH)'] assert step.failures == [] assert step.passed == [] joined = ' '.join(step.lines) assert 'SKIP' in joined assert '.setup-opts' in joined assert 'dev mode' in joined.lower() def test_verify_panellift_with_no_opts_runs_the_real_check_unchanged(monkeypatch, tmp_path): """opts={} (equivalently opts=None, i.e. no `.setup-opts` at all) must take the ORIGINAL code path -- the installed-CLI subprocess is actually invoked, and a failure there is reported as FAIL under the same step name as before this feature existed, never as SKIP.""" calls = [] def fake_run(cmd, **kw): calls.append(cmd) class R(object): returncode = 1 stdout = '' stderr = "No module named panellift" return R() monkeypatch.setattr(S, '_run', fake_run) step = _step() ok = S.verify_panellift(step, str(tmp_path), {}) assert ok is False assert step.skipped == [] assert step.failures == ['panellift CLI runs installed (no PYTHONPATH)'] assert len(calls) == 1 # the real CLI invocation was attempted def test_verify_panellift_default_opts_argument_behaves_like_absent_file(monkeypatch, tmp_path): """Calling verify_panellift with no `opts` argument at all (the old call shape, before this feature existed) must still work -- a caller that never learned about `.setup-opts` is not broken by it.""" def fake_run(cmd, **kw): class R(object): returncode = 1 stdout = '' stderr = "No module named panellift" return R() monkeypatch.setattr(S, '_run', fake_run) step = _step() ok = S.verify_panellift(step, str(tmp_path)) assert ok is False assert step.skipped == [] assert step.failures == ['panellift CLI runs installed (no PYTHONPATH)'] # --------------------------------------------------------- install_dependencies def test_install_dependencies_dev_mode_skips_pip_install_e_and_verifies_pythonpath( monkeypatch, tmp_path): """With `panellift: dev-mode`, the full (non---check) run must not call `pip install -e panellift`, and must instead run the PYTHONPATH route and say which route it verified.""" calls = [] def fake_run(cmd, **kw): calls.append((cmd, kw)) class R(object): returncode = 0 stdout = '' stderr = '' return R() # write_requirements() only writes if requirements.txt is missing; the # real one exists in this checkout, so no filesystem write happens here. monkeypatch.setattr(S, '_run', fake_run) step = _step() ok = S.install_dependencies(step, {'panellift': 'dev-mode'}) assert ok is True assert step.failures == [] # Exactly two subprocess calls: `pip install -r requirements.txt`, then # the PYTHONPATH=src probe. `pip install -e panellift` never happens. assert len(calls) == 2 first_cmd, _ = calls[0] assert 'requirements.txt' in ' '.join(first_cmd) second_cmd, second_kw = calls[1] assert '-e' not in second_cmd assert 'panellift' in second_cmd assert '--help' in second_cmd env = second_kw.get('env') or {} assert 'PYTHONPATH' in env assert env['PYTHONPATH'].endswith(os.path.join('panellift', 'src')) joined = ' '.join(step.lines) assert 'PYTHONPATH=src' in joined or 'PYTHONPATH=' in joined assert 'dev mode' in joined.lower() def test_install_dependencies_no_opts_still_installs_panellift_editable( monkeypatch, tmp_path): """opts={} must reach the original `pip install -e panellift` call -- unchanged behaviour when nothing has been declared.""" calls = [] def fake_run(cmd, **kw): calls.append(cmd) class R(object): returncode = 0 stdout = '' stderr = '' return R() monkeypatch.setattr(S, '_run', fake_run) step = _step() ok = S.install_dependencies(step, {}) assert ok is True assert len(calls) == 2 assert '-e' in calls[1] # ------------------------------------------------------------------ _verdict def test_verdict_pass_with_no_skips_has_old_unadorned_shape(): step = _step() step.passed = ['a', 'b', 'c'] code, lines = S._verdict(step) assert code == 0 assert len(lines) == 1 assert lines[0].startswith('VERDICT: PASS -- 3 passed --') assert 'skipped' not in lines[0] def test_verdict_pass_with_skips_counts_them_in_the_summary(): step = _step() step.passed = ['a', 'b', 'c', 'd', 'e'] step.skipped = ['panellift CLI runs installed (no PYTHONPATH)'] code, lines = S._verdict(step) assert code == 0 assert lines[0] == ('VERDICT: PASS -- 5 passed, 1 skipped (see SKIP ' 'lines above for why each one) -- everything above ' 'is a real, checked answer, not just an exit code.') def test_verdict_fail_still_fails_even_with_a_skip_present(): """A skip is never allowed to launder a real failure into a pass -- the whole point of SKIP as a third state is that it is honest about doing nothing, not that it forgives something else that broke.""" step = _step() step.passed = ['a'] step.skipped = ['panellift CLI runs installed (no PYTHONPATH)'] step.failures = ['knowledgelint.py'] code, lines = S._verdict(step) assert code == 1 assert lines[0].startswith('VERDICT: FAIL') assert 'knowledgelint.py' in lines[0] # the passed/skipped counts are still visible even though it failed assert any('1 skipped' in line for line in lines) def test_verdict_fail_with_no_skips_has_old_shape(): step = _step() step.passed = ['a'] step.failures = ['knowledgelint.py'] code, lines = S._verdict(step) assert code == 1 assert not any('skipped' in line for line in lines) # ------------------------------------------- _parse_porcelain_status (Gitea #1) # # Gitea issue #1: `resolve_private_repo`'s completeness guard failed on ANY # non-empty `git status --porcelain` output, but the truncation it exists to # catch (the long-path `sessions/` failure, machine-setup.md sec.2) only ever # shows up as a TRACKED file missing from the worktree -- porcelain worktree # status `D`. An untracked addition (`??`) or a locally modified tracked file # (`M`) cannot mean that, and used to false-FAIL anyway, with a # `restore --source=HEAD` hint that is a no-op for untracked paths. def test_parse_porcelain_classifies_untracked_as_untracked_not_missing(): missing, untracked, other = S._parse_porcelain_status('?? sample_job/\n') assert missing == [] assert untracked == ['sample_job/'] assert other == [] def test_parse_porcelain_treats_worktree_D_as_missing_tracked(): """The truncation signature itself: index still names the file (X is one of ' ', M, A, R, C -- whatever staged state), but Y='D' means it is not actually on disk.""" for code in (' D', 'MD', 'AD', 'RD', 'CD'): missing, untracked, other = S._parse_porcelain_status( '{0} plant/foo.yaml\n'.format(code)) assert missing == ['plant/foo.yaml'], 'code {0!r} should be missing-tracked'.format(code) assert untracked == [] assert other == [] def test_parse_porcelain_staged_deletion_is_not_missing_tracked(): """'D ' (X='D', Y=' ') is a DELIBERATE staged deletion (`git rm`, or `git add` after deleting by hand) -- index and worktree already agree the file is gone on purpose. This must not be confused with 'D' in the worktree column, which means the opposite: an accident.""" missing, untracked, other = S._parse_porcelain_status('D plant/foo.yaml\n') assert missing == [] assert other == ['plant/foo.yaml'] def test_parse_porcelain_ordinary_modifications_are_other_not_missing(): for code in ('M ', ' M', 'MM', 'A ', 'R ', 'C '): missing, untracked, other = S._parse_porcelain_status( '{0} plant/foo.yaml\n'.format(code)) assert missing == [], 'code {0!r} must not be treated as truncation'.format(code) def test_parse_porcelain_rename_reports_destination_path(): missing, untracked, other = S._parse_porcelain_status( 'R old/name.yaml -> new/name.yaml\n') assert other == ['new/name.yaml'] missing, untracked, other = S._parse_porcelain_status( 'RD old/name.yaml -> new/name.yaml\n') assert missing == ['new/name.yaml'] def test_parse_porcelain_conflict_codes_are_not_guessed_at_as_missing(): """Merge-conflict markers can't arise on the plain clone/status path this guard runs on (no merge in progress); they are filed as 'other' rather than treated as truncation evidence.""" for code in ('UU', 'AA', 'DD', 'AU', 'UD', 'UA', 'DU'): missing, untracked, other = S._parse_porcelain_status( '{0} plant/foo.yaml\n'.format(code)) assert missing == [], 'conflict code {0!r} must not fail the guard'.format(code) def test_parse_porcelain_empty_output_is_all_empty(): missing, untracked, other = S._parse_porcelain_status('') assert (missing, untracked, other) == ([], [], []) # --------------------------------------------- resolve_private_repo (Gitea #1) # # These use real temp git repos (not mocked porcelain strings) so the # completeness guard is exercised against what `git status --porcelain` # actually prints for real filesystem states, per the issue's own # instruction not to trust a hand-typed porcelain string here. def test_untracked_job_folder_checkout_passes(tmp_path): """The issue's exact false-FAIL scenario: a complete checkout with one untracked job folder (named `sample_job`, never a real client name -- see CLAUDE.md's scrub rule) must PASS, not FAIL.""" repo = _init_repo(tmp_path / 'private') (repo / 'plant').mkdir() (repo / 'plant' / 'knowledge.yaml').write_text('a: 1\n', encoding='utf-8') _git(repo, 'add', '.') _git(repo, 'commit', '-q', '-m', 'init') (repo / 'sample_job').mkdir() (repo / 'sample_job' / 'notes.txt').write_text('scratch work\n', encoding='utf-8') step = _step() result = S.resolve_private_repo(step, str(repo)) assert result == str(repo) assert step.failures == [] joined = ' '.join(step.lines) assert '1 untracked' in joined assert "installer's business" in joined def test_missing_tracked_file_checkout_fails_with_restore_hint(tmp_path): """The real truncation signature: a committed file that never made it onto disk (simulating the long-path checkout failure). Must FAIL, and the fix hint's `restore --source=HEAD` genuinely applies here.""" repo = _init_repo(tmp_path / 'private') (repo / 'plant').mkdir() (repo / 'plant' / 'knowledge.yaml').write_text('a: 1\n', encoding='utf-8') _git(repo, 'add', '.') _git(repo, 'commit', '-q', '-m', 'init') os.remove(str(repo / 'plant' / 'knowledge.yaml')) step = _step() result = S.resolve_private_repo(step, str(repo)) assert result is None assert step.failures == ['private repo checkout is complete'] joined = ' '.join(step.lines) assert 'tracked path(s) missing from the working tree' in joined assert 'restore --source=HEAD :/' in joined def test_modified_only_checkout_passes_with_informational_note(tmp_path): """A locally modified tracked file is ordinary uncommitted work -- it cannot indicate a truncated checkout (the file IS present, just edited), and must PASS, with the modification only noted, not acted on.""" repo = _init_repo(tmp_path / 'private') (repo / 'plant').mkdir() (repo / 'plant' / 'knowledge.yaml').write_text('a: 1\n', encoding='utf-8') _git(repo, 'add', '.') _git(repo, 'commit', '-q', '-m', 'init') (repo / 'plant' / 'knowledge.yaml').write_text('a: 2\n', encoding='utf-8') step = _step() result = S.resolve_private_repo(step, str(repo)) assert result == str(repo) assert step.failures == [] joined = ' '.join(step.lines) assert '1 modified' in joined assert "installer's business" in joined assert 'restore --source=HEAD' not in joined @pytest.mark.skipif(sys.platform != 'win32', reason='wire_links shells out to real PowerShell to ' 'create NTFS junctions, which do not exist off ' 'Windows') def test_cascade_untracked_job_folder_passes_wiring_end_to_end(tmp_path, monkeypatch): """The issue's reported cascade: a false FAIL on the completeness guard used to skip wiring entirely (plant layer stays empty, knowledgelint reports 1 layer). With the guard fixed, resolving a checkout that has only an untracked job folder must let `wire_links` actually run and succeed -- proving the fix, not just the guard's own return value.""" private = _init_repo(tmp_path / 'KNOWLEDGE') for name in ('plant', 'job', 'library', 'qna'): d = private / name d.mkdir() (d / 'README.md').write_text('placeholder for ' + name, encoding='utf-8') _git(private, 'add', '.') _git(private, 'commit', '-q', '-m', 'init') (private / 'sample_job').mkdir() (private / 'sample_job' / 'notes.txt').write_text('scratch\n', encoding='utf-8') step = _step() resolved = S.resolve_private_repo(step, str(private)) assert resolved == str(private) assert step.failures == [] fake_root = tmp_path / 'checkout' (fake_root / 'knowledge').mkdir(parents=True) monkeypatch.setattr(S, 'ROOT', str(fake_root)) wire_step = _step() S.wire_links(wire_step, resolved) assert wire_step.failures == [] plant_local = fake_root / 'knowledge' / 'plant' assert plant_local.is_dir() assert (plant_local / 'README.md').read_text(encoding='utf-8') == 'placeholder for plant' job_local = fake_root / 'knowledge' / 'job' assert (job_local / 'README.md').read_text(encoding='utf-8') == 'placeholder for job'