fix(cli): scope TUI npm-install check to the ui-tui workspace closure
_tui_need_npm_install compared the full multi-workspace root package-lock.json against the hidden .package-lock.json, but the launch install is scoped with npm install --workspace ui-tui and only writes the ui-tui dependency closure. Every dep belonging solely to another workspace (apps/desktop, web, ...) was therefore reported as missing, so the check returned True and printed "Installing TUI dependencies..." on every launch. Restrict the comparison to the ui-tui workspace's dependency closure, computed from the root lock's packages map (following npm's node-resolution walk and workspace symlinks). Standalone / own-lockfile layouts and any case where the workspace can't be located fall back to the full comparison, so drift on a genuine ui-tui dependency is still detected. Fixes #66978
This commit is contained in:
@@ -2056,6 +2056,71 @@ def _termux_workspace_install_context(
|
||||
return ws_root, tuple(workspace_args)
|
||||
|
||||
|
||||
def _npm_lock_workspace_closure(packages: dict, start: str) -> Optional[set]:
|
||||
"""Package-map keys reachable from workspace ``start`` via npm resolution.
|
||||
|
||||
Returns ``None`` when ``start`` is absent from *packages* so callers fall
|
||||
back to the full-lockfile comparison.
|
||||
|
||||
The launch install is scoped with ``npm install --workspace ui-tui`` (see
|
||||
``_make_tui_argv``), so only the ui-tui workspace's dependency closure is
|
||||
written to the hidden ``.package-lock.json``. The shared root
|
||||
``package-lock.json`` additionally lists every *other* workspace's deps
|
||||
(``apps/desktop``, ``web``, …); comparing the two in full reports those
|
||||
unrelated packages as "missing" and reinstalls on every launch (#66978).
|
||||
|
||||
Keys follow npm's v3 ``packages`` map (``""`` root, ``ui-tui`` /
|
||||
``apps/desktop`` workspace members, ``node_modules/<name>`` hoisted deps,
|
||||
``<dir>/node_modules/<name>`` nested deps). Dependency names resolve to a
|
||||
key by walking up ``node_modules`` ancestors, mirroring node resolution, and
|
||||
workspace symlinks (``link: true``) are followed to their real entry so a
|
||||
linked workspace's own deps join the closure.
|
||||
"""
|
||||
if start not in packages:
|
||||
return None
|
||||
|
||||
def resolve(from_key: str, dep: str) -> Optional[str]:
|
||||
base = from_key
|
||||
while True:
|
||||
prefix = f"{base}/" if base else ""
|
||||
candidate = f"{prefix}node_modules/{dep}"
|
||||
if candidate in packages:
|
||||
return candidate
|
||||
if not base:
|
||||
return None
|
||||
base = base.rsplit("/", 1)[0] if "/" in base else ""
|
||||
|
||||
seen: set = set()
|
||||
stack = [start]
|
||||
while stack:
|
||||
key = stack.pop()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
entry = packages.get(key)
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
# Workspace symlink (e.g. node_modules/@hermes/ink → ui-tui/packages/…):
|
||||
# follow to the real package entry so its dependencies join the closure.
|
||||
resolved = entry.get("resolved")
|
||||
if entry.get("link") and isinstance(resolved, str) and resolved in packages:
|
||||
stack.append(resolved)
|
||||
# devDependencies are installed for the workspace being scoped (ui-tui's
|
||||
# build toolchain), but not for transitive deps.
|
||||
fields = ["dependencies", "optionalDependencies", "peerDependencies"]
|
||||
if key == start:
|
||||
fields.append("devDependencies")
|
||||
for field in fields:
|
||||
deps = entry.get(field)
|
||||
if not isinstance(deps, dict):
|
||||
continue
|
||||
for dep in deps:
|
||||
target = resolve(key, dep)
|
||||
if target is not None:
|
||||
stack.append(target)
|
||||
return seen
|
||||
|
||||
|
||||
def _tui_need_npm_install(root: Path) -> bool:
|
||||
"""True when @hermes/ink is missing or node_modules is behind package-lock.json.
|
||||
|
||||
@@ -2117,10 +2182,28 @@ def _tui_need_npm_install(root: Path) -> bool:
|
||||
def comparable(pkg: dict) -> dict:
|
||||
return {k: v for k, v in pkg.items() if k not in _NPM_LOCK_RUNTIME_KEYS}
|
||||
|
||||
# In a shared workspace checkout the launch install is scoped to the ui-tui
|
||||
# workspace, so only its dependency closure lands in the hidden lock. Limit
|
||||
# the comparison to that closure so unrelated workspace deps (apps/desktop,
|
||||
# web, …) don't force a reinstall every launch (#66978). Standalone /
|
||||
# own-lockfile layouts (ws_root == root) do a full install, so keep the full
|
||||
# comparison; a missing/unlocatable workspace falls back to it too.
|
||||
closure: Optional[set] = None
|
||||
if ws_root != root:
|
||||
try:
|
||||
workspace_key = root.relative_to(ws_root).as_posix()
|
||||
except ValueError:
|
||||
workspace_key = ""
|
||||
if workspace_key:
|
||||
closure = _npm_lock_workspace_closure(wanted, workspace_key)
|
||||
|
||||
for name, pkg in wanted.items():
|
||||
if not name:
|
||||
continue
|
||||
|
||||
if closure is not None and name not in closure:
|
||||
continue
|
||||
|
||||
if not isinstance(pkg, dict):
|
||||
continue
|
||||
|
||||
|
||||
@@ -57,6 +57,415 @@ def test_make_tui_argv_uses_bundled_tui_when_workspace_missing(
|
||||
runnable bundled TUI on disk. The bundled shortcut must succeed without
|
||||
ever touching the (missing) ui-tui workspace or git.
|
||||
"""
|
||||
_touch_ink(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text(
|
||||
'{"packages":{'
|
||||
'"node_modules/foo":{"version":"1.0.0","dev":true,"peer":true,"resolved":"https://x/foo.tgz"}'
|
||||
'}}'
|
||||
)
|
||||
(tmp_path / "node_modules" / ".package-lock.json").write_text(
|
||||
'{"packages":{'
|
||||
'"node_modules/foo":{"version":"1.0.0","dev":true,"resolved":"https://x/foo.tgz"}'
|
||||
'}}'
|
||||
)
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
|
||||
|
||||
def test_install_when_version_differs_even_with_peer_drop(tmp_path: Path, main_mod) -> None:
|
||||
"""The peer-drop tolerance must not mask a real version skew."""
|
||||
_touch_ink(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text(
|
||||
'{"packages":{"node_modules/foo":{"version":"2.0.0","dev":true,"peer":true}}}'
|
||||
)
|
||||
(tmp_path / "node_modules" / ".package-lock.json").write_text(
|
||||
'{"packages":{"node_modules/foo":{"version":"1.0.0","dev":true}}}'
|
||||
)
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is True
|
||||
|
||||
|
||||
def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
(tmp_path / "node_modules" / ".package-lock.json").write_text("{}")
|
||||
os.utime(tmp_path / "package-lock.json", (100, 100))
|
||||
os.utime(tmp_path / "node_modules" / ".package-lock.json", (200, 200))
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
|
||||
|
||||
def test_need_install_when_marker_missing(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is True
|
||||
|
||||
|
||||
def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod) -> None:
|
||||
_touch_ink(tmp_path)
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
|
||||
|
||||
# ── workspace-scoped comparison (#66978) ────────────────────────────
|
||||
#
|
||||
# In a shared workspace checkout the launch install is scoped to the ui-tui
|
||||
# workspace, so only its dependency closure lands in the hidden lock while the
|
||||
# root lock lists every other workspace's deps too. The comparison must ignore
|
||||
# those unrelated packages instead of reinstalling on every launch.
|
||||
|
||||
|
||||
def _write_ws(root: Path, ws_lock: str, hidden_lock: str) -> Path:
|
||||
"""Lay out a workspace root + ui-tui member and return the ui-tui dir.
|
||||
|
||||
``@hermes/ink`` and the marker live at the workspace root (hoisted);
|
||||
``ui-tui/`` has no lockfile of its own so ``_workspace_root`` treats the
|
||||
parent as the workspace root and the launch scopes to ``--workspace ui-tui``.
|
||||
"""
|
||||
(root / "package-lock.json").write_text(ws_lock)
|
||||
_touch_ink(root)
|
||||
(root / "node_modules" / ".package-lock.json").write_text(hidden_lock)
|
||||
tui_dir = root / "ui-tui"
|
||||
tui_dir.mkdir(parents=True, exist_ok=True)
|
||||
# package.json (and no own lockfile) is what makes _workspace_root treat the
|
||||
# parent as the workspace root and the launch scope to --workspace ui-tui.
|
||||
(tui_dir / "package.json").write_text('{"name":"hermes-tui"}')
|
||||
return tui_dir
|
||||
|
||||
|
||||
def test_no_install_when_only_other_workspace_deps_missing(tmp_path: Path, main_mod) -> None:
|
||||
"""Deps that belong to apps/desktop / web (never installed by the ui-tui
|
||||
scoped install) must not trigger a reinstall on every launch (#66978)."""
|
||||
tui_dir = _write_ws(
|
||||
tmp_path,
|
||||
'{"packages":{'
|
||||
'"ui-tui":{"dependencies":{"foo":"1.0.0"}},'
|
||||
'"node_modules/foo":{"version":"1.0.0"},'
|
||||
'"apps/desktop":{"dependencies":{"desktop-only":"1.0.0"}},'
|
||||
'"node_modules/desktop-only":{"version":"1.0.0"},'
|
||||
'"apps/desktop/node_modules/nested":{"version":"1.0.0"}'
|
||||
"}}",
|
||||
'{"packages":{'
|
||||
'"ui-tui":{"dependencies":{"foo":"1.0.0"}},'
|
||||
'"node_modules/foo":{"version":"1.0.0"}'
|
||||
"}}",
|
||||
)
|
||||
assert main_mod._tui_need_npm_install(tui_dir) is False
|
||||
|
||||
|
||||
def test_need_install_when_ui_tui_dep_missing_in_workspace_layout(tmp_path: Path, main_mod) -> None:
|
||||
"""A genuinely missing ui-tui dependency is still caught after scoping."""
|
||||
tui_dir = _write_ws(
|
||||
tmp_path,
|
||||
'{"packages":{'
|
||||
'"ui-tui":{"dependencies":{"foo":"1.0.0","bar":"1.0.0"}},'
|
||||
'"node_modules/foo":{"version":"1.0.0"},'
|
||||
'"node_modules/bar":{"version":"1.0.0"}'
|
||||
"}}",
|
||||
'{"packages":{'
|
||||
'"ui-tui":{"dependencies":{"foo":"1.0.0","bar":"1.0.0"}},'
|
||||
'"node_modules/foo":{"version":"1.0.0"}'
|
||||
"}}",
|
||||
)
|
||||
assert main_mod._tui_need_npm_install(tui_dir) is True
|
||||
|
||||
|
||||
def test_need_install_when_linked_workspace_dep_missing(tmp_path: Path, main_mod) -> None:
|
||||
"""The closure follows workspace symlinks (@hermes/ink → ui-tui/packages/…)
|
||||
so a linked workspace's own missing dep triggers a reinstall."""
|
||||
tui_dir = _write_ws(
|
||||
tmp_path,
|
||||
'{"packages":{'
|
||||
'"ui-tui":{"dependencies":{"@hermes/ink":"*"}},'
|
||||
'"node_modules/@hermes/ink":{"link":true,"resolved":"ui-tui/packages/hermes-ink"},'
|
||||
'"ui-tui/packages/hermes-ink":{"dependencies":{"inkdep":"1.0.0"}},'
|
||||
'"node_modules/inkdep":{"version":"1.0.0"}'
|
||||
"}}",
|
||||
'{"packages":{'
|
||||
'"ui-tui":{"dependencies":{"@hermes/ink":"*"}},'
|
||||
'"node_modules/@hermes/ink":{"link":true,"resolved":"ui-tui/packages/hermes-ink"},'
|
||||
'"ui-tui/packages/hermes-ink":{"dependencies":{"inkdep":"1.0.0"}}'
|
||||
"}}",
|
||||
)
|
||||
assert main_mod._tui_need_npm_install(tui_dir) is True
|
||||
|
||||
|
||||
def test_need_install_when_closure_package_version_drifts(tmp_path: Path, main_mod) -> None:
|
||||
"""Version drift on an in-closure package still forces a reinstall."""
|
||||
tui_dir = _write_ws(
|
||||
tmp_path,
|
||||
'{"packages":{'
|
||||
'"ui-tui":{"dependencies":{"foo":"2.0.0"}},'
|
||||
'"node_modules/foo":{"version":"2.0.0"}'
|
||||
"}}",
|
||||
'{"packages":{'
|
||||
'"ui-tui":{"dependencies":{"foo":"2.0.0"}},'
|
||||
'"node_modules/foo":{"version":"1.0.0"}'
|
||||
"}}",
|
||||
)
|
||||
assert main_mod._tui_need_npm_install(tui_dir) is True
|
||||
|
||||
|
||||
def test_workspace_closure_includes_dev_deps_of_scoped_workspace(main_mod) -> None:
|
||||
"""ui-tui's devDependencies (esbuild/typescript build toolchain) are part of
|
||||
the closure; a transitive package's devDependencies are not."""
|
||||
packages = {
|
||||
"ui-tui": {
|
||||
"dependencies": {"foo": "1"},
|
||||
"devDependencies": {"esbuild": "1"},
|
||||
},
|
||||
"node_modules/foo": {"devDependencies": {"foo-dev-only": "1"}},
|
||||
"node_modules/esbuild": {},
|
||||
"node_modules/foo-dev-only": {},
|
||||
}
|
||||
closure = main_mod._npm_lock_workspace_closure(packages, "ui-tui")
|
||||
assert "node_modules/esbuild" in closure
|
||||
assert "node_modules/foo-dev-only" not in closure
|
||||
|
||||
|
||||
def test_workspace_closure_returns_none_when_start_absent(main_mod) -> None:
|
||||
"""Missing workspace key → None so the caller falls back to full compare."""
|
||||
assert main_mod._npm_lock_workspace_closure({"node_modules/foo": {}}, "ui-tui") is None
|
||||
|
||||
|
||||
def test_no_install_prebuilt_bundle_mode(tmp_path: Path, main_mod) -> None:
|
||||
"""dist/entry.js present and no package-lock.json → prebuilt bundle, skip npm install."""
|
||||
_touch_tui_entry(tmp_path)
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is False
|
||||
|
||||
|
||||
def test_need_rebuild_when_tui_bundle_missing(tmp_path: Path, main_mod) -> None:
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "entry.tsx").write_text("console.log('src')")
|
||||
|
||||
assert main_mod._tui_need_rebuild(tmp_path) is True
|
||||
|
||||
|
||||
def test_no_rebuild_when_tui_bundle_newer_than_inputs(tmp_path: Path, main_mod) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
src = tmp_path / "src"
|
||||
src.mkdir()
|
||||
(src / "entry.tsx").write_text("console.log('src')")
|
||||
os.utime(src / "entry.tsx", (100, 100))
|
||||
os.utime(tmp_path / "dist" / "entry.js", (200, 200))
|
||||
|
||||
assert main_mod._tui_need_rebuild(tmp_path) is False
|
||||
|
||||
|
||||
def test_rebuild_when_tui_source_newer_than_bundle(tmp_path: Path, main_mod) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
src = tmp_path / "src"
|
||||
src.mkdir()
|
||||
(src / "entry.tsx").write_text("console.log('src')")
|
||||
os.utime(tmp_path / "dist" / "entry.js", (100, 100))
|
||||
os.utime(src / "entry.tsx", (200, 200))
|
||||
|
||||
assert main_mod._tui_need_rebuild(tmp_path) is True
|
||||
|
||||
|
||||
def test_make_tui_argv_skips_build_only_on_termux_when_fresh(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
|
||||
def fail_run(*_args, **_kwargs):
|
||||
raise AssertionError("fresh Termux TUI launch must not rebuild")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fail_run)
|
||||
|
||||
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)
|
||||
|
||||
assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")]
|
||||
assert cwd == tmp_path
|
||||
|
||||
|
||||
def test_make_tui_argv_skips_install_on_termux_when_bundle_fresh(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
|
||||
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
|
||||
def fail_run(*_args, **_kwargs):
|
||||
raise AssertionError("fresh Termux TUI launch must not run npm")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fail_run)
|
||||
|
||||
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=False)
|
||||
|
||||
assert argv == ["/bin/node", "--expose-gc", str(tmp_path / "dist" / "entry.js")]
|
||||
assert cwd == tmp_path
|
||||
|
||||
|
||||
def test_make_tui_argv_scopes_npm_install_on_termux_workspace(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
tui_dir = tmp_path / "ui-tui"
|
||||
tui_dir.mkdir()
|
||||
(tui_dir / "package.json").write_text("{}")
|
||||
ink_dir = tui_dir / "packages" / "hermes-ink"
|
||||
ink_dir.mkdir(parents=True)
|
||||
(ink_dir / "package.json").write_text("{}")
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
|
||||
monkeypatch.setenv("TERMUX_VERSION", "1")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
|
||||
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: True)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
main_mod._make_tui_argv(tui_dir, tui_dev=False)
|
||||
|
||||
install_cmd = calls[0][0][0]
|
||||
assert install_cmd[:7] == [
|
||||
"/bin/npm",
|
||||
"install",
|
||||
"--workspace",
|
||||
"ui-tui",
|
||||
"--workspace",
|
||||
"ui-tui/packages/hermes-ink",
|
||||
"--include-workspace-root=false",
|
||||
]
|
||||
assert calls[0][1]["cwd"] == str(tmp_path)
|
||||
_assert_utf8_replace_capture(calls[0][1])
|
||||
_assert_utf8_replace_capture(calls[1][1])
|
||||
|
||||
|
||||
def test_make_tui_argv_keeps_desktop_workspace_install_behaviour(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
tui_dir = tmp_path / "ui-tui"
|
||||
tui_dir.mkdir()
|
||||
(tui_dir / "package.json").write_text("{}")
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
main_mod._make_tui_argv(tui_dir, tui_dev=False)
|
||||
|
||||
assert calls[0][0][0] == [
|
||||
"/bin/npm",
|
||||
"install",
|
||||
"--workspace",
|
||||
"ui-tui",
|
||||
"--include=dev",
|
||||
"--silent",
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
"--progress=false",
|
||||
]
|
||||
assert calls[0][1]["cwd"] == str(tmp_path)
|
||||
_assert_utf8_replace_capture(calls[0][1])
|
||||
_assert_utf8_replace_capture(calls[1][1])
|
||||
|
||||
|
||||
def test_make_tui_argv_npm_install_forces_include_dev(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
"""The TUI-launch npm install must force --include=dev: ui-tui's build
|
||||
toolchain (esbuild, typescript) lives in devDependencies, and an inherited
|
||||
NODE_ENV=production (container shells; a parent TUI sets it on its own
|
||||
subprocess env) or an npm `omit=dev` config would silently skip them,
|
||||
breaking the TUI build with `tsc`/`esbuild: command not found."""
|
||||
tui_dir = tmp_path / "ui-tui"
|
||||
tui_dir.mkdir()
|
||||
(tui_dir / "package.json").write_text("{}")
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
monkeypatch.setenv("NODE_ENV", "production")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
main_mod._make_tui_argv(tui_dir, tui_dev=False)
|
||||
|
||||
install_cmd = calls[0][0][0]
|
||||
assert install_cmd[:2] == ["/bin/npm", "install"]
|
||||
assert "--include=dev" in install_cmd
|
||||
|
||||
|
||||
def test_make_tui_argv_keeps_desktop_always_build_behaviour(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
_touch_tui_entry(tmp_path)
|
||||
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
||||
monkeypatch.setenv("PREFIX", "/usr")
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
main_mod._make_tui_argv(tmp_path, tui_dev=False)
|
||||
|
||||
assert calls
|
||||
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
|
||||
_assert_utf8_replace_capture(calls[0][1])
|
||||
|
||||
|
||||
def test_make_tui_argv_decodes_dev_prebuild_with_utf8_replace(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
ink_dir = tmp_path / "packages" / "hermes-ink"
|
||||
ink_dir.mkdir(parents=True)
|
||||
tsx = tmp_path / "node_modules" / ".bin" / "tsx"
|
||||
tsx.parent.mkdir(parents=True)
|
||||
tsx.write_text("")
|
||||
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=True)
|
||||
|
||||
assert argv == [str(tsx), "src/entry.tsx"]
|
||||
assert cwd == tmp_path
|
||||
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
|
||||
assert calls[0][1]["cwd"] == str(ink_dir)
|
||||
_assert_utf8_replace_capture(calls[0][1])
|
||||
|
||||
|
||||
def test_make_tui_argv_exits_with_recovery_hint_when_workspace_unrecoverable(
|
||||
tmp_path: Path, main_mod, monkeypatch, capsys
|
||||
) -> None:
|
||||
"""Missing ui-tui + no git checkout → clean error, never touches node/npm."""
|
||||
monkeypatch.delenv("HERMES_TUI_DIR", raising=False)
|
||||
monkeypatch.setattr(main_mod, "_ensure_tui_node", lambda: None)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user