# -*- coding: utf-8 -*- """Tests for tools/dsh_worker.py -- the one-shot CLI bridge to a dsh headless agent. Loaded by path, like `test_aiextract_run.py`: the tool is host CPython and lives outside `WillTools.extension` entirely. The subprocess boundary (`which` / `runner`) is faked here exactly the way `aiextract_run.py` fakes `transport` -- no real dsh install and no real API key are exercised by this suite. What matters is the part that makes this safe for a caller to trust: every failure shape (missing binary, non-zero exit, timeout, a zero exit with an empty reply) comes back as a named DshRefusal, never as a quiet "ok" with nothing in it -- see the module docstring's "could not judge" vs "judged, found nothing" rule. """ import json 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) dsh_worker = pytest.importorskip('dsh_worker') # --------------------------------------------------------------- fixtures def _which_found(path='C:\\fake\\dsh.cmd'): return lambda name: path def _which_missing(): return lambda name: None class _FakeCompletedProcess(object): def __init__(self, returncode=0, stdout='', stderr=''): self.returncode = returncode self.stdout = stdout self.stderr = stderr def _runner_returning(returncode=0, stdout='', stderr=''): calls = [] def runner(cmd, capture_output, text, timeout, encoding=None, errors=None): calls.append({'cmd': cmd, 'timeout': timeout}) return _FakeCompletedProcess(returncode, stdout, stderr) runner.calls = calls return runner def _runner_raising(exc): def runner(cmd, capture_output, text, timeout, encoding=None, errors=None): raise exc return runner # ------------------------------------------------------------- run_task ok def test_run_task_returns_the_trimmed_reply_on_success(): runner = _runner_returning(0, stdout=' pong \n', stderr='') reply = dsh_worker.run_task('ping', which=_which_found(), runner=runner) assert reply == 'pong' def test_run_task_passes_profile_and_task_through_to_the_command_line(): runner = _runner_returning(0, stdout='ok') dsh_worker.run_task('do the thing', profile='headless', which=_which_found('C:\\fake\\dsh.cmd'), runner=runner) cmd = runner.calls[0]['cmd'] assert cmd == ['C:\\fake\\dsh.cmd', '--profile', 'headless', 'do the thing'] def test_run_task_passes_the_timeout_through(): runner = _runner_returning(0, stdout='ok') dsh_worker.run_task('x', which=_which_found(), runner=runner, timeout_s=42) assert runner.calls[0]['timeout'] == 42 # --------------------------------------------------------- refusal: no dsh def test_run_task_refuses_when_dsh_is_not_on_path(): with pytest.raises(dsh_worker.DshRefusal) as exc: dsh_worker.run_task('ping', which=_which_missing(), runner=_runner_returning(0, stdout='ok')) assert 'not on PATH' in str(exc.value) assert 'npm install' in str(exc.value) def test_run_task_refuses_on_empty_task_text(): with pytest.raises(dsh_worker.DshRefusal) as exc: dsh_worker.run_task(' ', which=_which_found(), runner=_runner_returning(0, stdout='ok')) assert 'empty task' in str(exc.value) # ------------------------------------------------------ refusal: bad exit def test_run_task_refuses_and_quotes_stderr_on_nonzero_exit(): runner = _runner_returning(1, stdout='', stderr='MISSING_CREDENTIAL: ' 'llm-deepseek: no API ' 'key') with pytest.raises(dsh_worker.DshRefusal) as exc: dsh_worker.run_task('ping', which=_which_found(), runner=runner) msg = str(exc.value) assert 'exited 1' in msg assert 'MISSING_CREDENTIAL' in msg def test_run_task_refuses_with_a_placeholder_when_nonzero_exit_has_no_stderr(): runner = _runner_returning(1, stdout='', stderr='') with pytest.raises(dsh_worker.DshRefusal) as exc: dsh_worker.run_task('ping', which=_which_found(), runner=runner) assert '(no stderr)' in str(exc.value) # ------------------------------------------------------- refusal: timeout def test_run_task_refuses_on_timeout_never_hangs_the_caller(): runner = _runner_raising( subprocess.TimeoutExpired(cmd='dsh', timeout=5)) with pytest.raises(dsh_worker.DshRefusal) as exc: dsh_worker.run_task('ping', which=_which_found(), runner=runner, timeout_s=5) assert '5s' in str(exc.value) def test_run_task_refuses_when_the_process_cannot_even_start(): runner = _runner_raising(OSError('Access is denied')) with pytest.raises(dsh_worker.DshRefusal) as exc: dsh_worker.run_task('ping', which=_which_found(), runner=runner) assert 'could not start dsh' in str(exc.value) # ---------------------------------------------- refusal: THE EMPTY-OK TRAP # # This is the one the module docstring calls out by name: dsh exiting 0 # with nothing on stdout must never read as success. This is the same # discipline CLAUDE.md SS3 / SS5 requires of `ticketchecks.py` skip probes -- # a check (or here, a worker call) that returns nothing owes an explanation, # and "ran fine, empty" is a claim, not a default. def test_run_task_refuses_when_exit_is_zero_but_stdout_is_empty(): runner = _runner_returning(0, stdout='', stderr='') with pytest.raises(dsh_worker.DshRefusal) as exc: dsh_worker.run_task('ping', which=_which_found(), runner=runner) msg = str(exc.value) assert 'exited 0 but printed no reply' in msg assert 'could not judge' in msg def test_run_task_refuses_when_exit_is_zero_but_stdout_is_only_whitespace(): runner = _runner_returning(0, stdout=' \n\n ', stderr='') with pytest.raises(dsh_worker.DshRefusal): dsh_worker.run_task('ping', which=_which_found(), runner=runner) # --------------------------------------------------------------- CLI: main def _run_main(argv, capsys, monkeypatch=None, stdin_text=None): if stdin_text is not None: import io sys.stdin = io.StringIO(stdin_text) code = dsh_worker.main(argv) out = capsys.readouterr().out return code, json.loads(out) def test_main_prints_ok_json_and_exits_0_on_success(capsys, monkeypatch): monkeypatch.setattr(dsh_worker, 'run_task', lambda task, profile, timeout_s: 'pong') code, payload = _run_main(['hello'], capsys) assert code == 0 assert payload == {'ok': True, 'reply': 'pong'} def test_main_prints_refusal_json_and_exits_1_on_failure(capsys, monkeypatch): def fake(task, profile, timeout_s): raise dsh_worker.DshRefusal('dsh is not on PATH') monkeypatch.setattr(dsh_worker, 'run_task', fake) code, payload = _run_main(['hello'], capsys) assert code == 1 assert payload == {'ok': False, 'refused': True, 'reason': 'dsh is not on PATH'} def test_main_refuses_cleanly_with_no_task_at_all(capsys, monkeypatch): monkeypatch.setattr(sys.stdin, 'isatty', lambda: True) code, payload = _run_main([], capsys) assert code == 1 assert payload['ok'] is False assert 'no task given' in payload['reason'] def test_main_reads_the_task_from_a_file(capsys, monkeypatch, tmp_path): task_file = tmp_path / 'task.md' task_file.write_text('summarize this', encoding='utf-8') seen = {} def fake(task, profile, timeout_s): seen['task'] = task return 'done' monkeypatch.setattr(dsh_worker, 'run_task', fake) code, payload = _run_main(['--file', str(task_file)], capsys) assert code == 0 assert seen['task'] == 'summarize this' # ------------------------------------------------------------- CLI: --plain def test_main_plain_mode_prints_just_the_reply_on_success(capsys, monkeypatch): monkeypatch.setattr(dsh_worker, 'run_task', lambda task, profile, timeout_s: 'pong') code = dsh_worker.main(['--plain', 'hello']) out = capsys.readouterr().out assert code == 0 assert out == 'pong\n' def test_main_plain_mode_prints_just_the_reason_on_refusal(capsys, monkeypatch): def fake(task, profile, timeout_s): raise dsh_worker.DshRefusal('dsh is not on PATH') monkeypatch.setattr(dsh_worker, 'run_task', fake) code = dsh_worker.main(['--plain', 'hello']) out = capsys.readouterr().out assert code == 1 assert out == 'dsh is not on PATH\n' # ---------------------------------------------- _resolve_launch_cmd: the # Windows argv trap (module docstring: a multi-line task truncates at the # first newline when the .CMD shim routes it through cmd.exe -- exit 0, no # error, and the truncated task still gets a plausible-looking reply). On # Windows, this must skip the shim and call node on its bin.js directly. def test_resolve_launch_cmd_leaves_non_windows_paths_alone(monkeypatch): monkeypatch.setattr(dsh_worker.os, 'name', 'posix') assert dsh_worker._resolve_launch_cmd('/usr/local/bin/dsh') == \ ['/usr/local/bin/dsh'] def test_resolve_launch_cmd_leaves_non_shim_windows_paths_alone(monkeypatch): monkeypatch.setattr(dsh_worker.os, 'name', 'nt') # not a .cmd/.bat -- e.g. a real dsh.exe or a git-bash shell shim assert dsh_worker._resolve_launch_cmd('C:\\fake\\dsh') == \ ['C:\\fake\\dsh'] def test_resolve_launch_cmd_falls_back_when_bin_js_is_not_where_expected( monkeypatch): monkeypatch.setattr(dsh_worker.os, 'name', 'nt') # C:\fake\dsh.cmd does not exist on disk, so its sibling bin.js can't # either -- must fall back to invoking the shim rather than crash. assert dsh_worker._resolve_launch_cmd('C:\\fake\\dsh.cmd') == \ ['C:\\fake\\dsh.cmd'] def test_resolve_launch_cmd_calls_node_on_bin_js_directly_when_present( monkeypatch, tmp_path): monkeypatch.setattr(dsh_worker.os, 'name', 'nt') shim_dir = tmp_path bin_js_dir = shim_dir / 'node_modules' / '@deepseek-ai' / 'dsh' / 'lib' bin_js_dir.mkdir(parents=True) bin_js = bin_js_dir / 'bin.js' bin_js.write_text('// fake', encoding='utf-8') node_exe = shim_dir / 'node.exe' node_exe.write_text('', encoding='utf-8') dsh_cmd = str(shim_dir / 'dsh.CMD') result = dsh_worker._resolve_launch_cmd(dsh_cmd) assert result == [str(node_exe), str(bin_js)] def test_resolve_launch_cmd_falls_back_to_which_node_when_no_sibling_node( monkeypatch, tmp_path): monkeypatch.setattr(dsh_worker.os, 'name', 'nt') shim_dir = tmp_path bin_js_dir = shim_dir / 'node_modules' / '@deepseek-ai' / 'dsh' / 'lib' bin_js_dir.mkdir(parents=True) bin_js = bin_js_dir / 'bin.js' bin_js.write_text('// fake', encoding='utf-8') # no node.exe placed beside the shim this time monkeypatch.setattr(dsh_worker.shutil, 'which', lambda name: '/usr/bin/node' if name == 'node' else None) dsh_cmd = str(shim_dir / 'dsh.cmd') result = dsh_worker._resolve_launch_cmd(dsh_cmd) assert result == ['/usr/bin/node', str(bin_js)]