Skip to content

Commit e68d9e8

Browse files
authored
fix(workbench): default git clone repo, accurate skips, and R readback (#490)
1 parent 0d97a04 commit e68d9e8

9 files changed

Lines changed: 391 additions & 47 deletions

File tree

selftests/test_config.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pytest
66

77
from vip.config import (
8+
DEFAULT_PUBLIC_CLONE_URL,
89
AuthConfig,
910
ConnectConfig,
1011
GitTestConfig,
@@ -670,9 +671,12 @@ def test_git_test_absent_by_default(self):
670671
wc = WorkbenchConfig(url="https://workbench.example.com")
671672
assert wc.git_test is None
672673

673-
def test_git_test_absent_from_dict_when_key_missing(self):
674+
def test_git_test_absent_from_dict_yields_default_public_repo(self):
674675
wc = WorkbenchConfig.from_dict({"url": "https://workbench.example.com"})
675-
assert wc.git_test is None
676+
assert wc.git_test is not None
677+
assert wc.git_test.clone_url == DEFAULT_PUBLIC_CLONE_URL
678+
assert wc.git_test.auth_method == "none"
679+
assert wc.git_test.token == ""
676680

677681
def test_git_test_parsed_from_dict(self):
678682
wc = WorkbenchConfig.from_dict(
@@ -695,10 +699,12 @@ def test_git_test_token_from_env(self, monkeypatch):
695699
assert wc.git_test is not None
696700
assert wc.git_test.token == "tok123"
697701

698-
def test_git_test_none_when_env_not_set_and_no_block(self, monkeypatch):
699-
monkeypatch.delenv("VIP_GIT_TOKEN", raising=False)
702+
def test_git_test_default_ignores_env_token(self, monkeypatch):
703+
"""auth_method='none' forces the default's token empty even with VIP_GIT_TOKEN set."""
704+
monkeypatch.setenv("VIP_GIT_TOKEN", "tok123")
700705
wc = WorkbenchConfig.from_dict({"url": "https://workbench.example.com"})
701-
assert wc.git_test is None
706+
assert wc.git_test is not None
707+
assert wc.git_test.token == ""
702708

703709

704710
class TestLoadConfigGitTest:
@@ -719,15 +725,18 @@ def test_git_test_block_parsed(self, tmp_toml, monkeypatch):
719725
assert cfg.workbench.git_test.clone_url == "https://github.com/org/repo.git"
720726
assert cfg.workbench.git_test.auth_method == "https-token"
721727

722-
def test_git_test_block_absent_yields_none(self, tmp_toml):
728+
def test_git_test_block_absent_yields_default_public_repo(self, tmp_toml):
723729
path = tmp_toml(
724730
"""
725731
[workbench]
726732
url = "https://workbench.example.com"
727733
"""
728734
)
729735
cfg = load_config(path)
730-
assert cfg.workbench.git_test is None
736+
assert cfg.workbench.git_test is not None
737+
assert cfg.workbench.git_test.clone_url == DEFAULT_PUBLIC_CLONE_URL
738+
assert cfg.workbench.git_test.auth_method == "none"
739+
assert cfg.workbench.git_test.token == ""
731740

732741
def test_git_test_token_from_env(self, tmp_toml, monkeypatch):
733742
monkeypatch.setenv("VIP_GIT_TOKEN", "env-token-xyz")

selftests/test_workbench_exec.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
_extract_between_markers,
2929
_make_sentinels,
3030
_parse_done_marker,
31+
_read_file_r_expr,
3132
_split_marker,
3233
_strip_r_index,
3334
_wrap_python_expr,
@@ -345,6 +346,39 @@ def test_returns_none_for_non_digit_suffix(self):
345346
content = "VIP_DONE_abc:"
346347
assert _parse_done_marker(content, "VIP_DONE_abc") is None
347348

349+
def test_raw_exit_code_parses_but_quoted_does_not(self):
350+
"""Regression guard: R's auto-print wraps a bare (un-cat'd) character
351+
result in a trailing closing quote, turning ``...:0`` into ``...:0"``.
352+
The raw form must parse as exit 0; the quoted form must be treated as
353+
still-running rather than silently misparsed. This is why
354+
``_read_file_r_expr`` wraps its read expression in ``cat()`` -- so the
355+
marker line R's console echoes back is always the raw form."""
356+
raw = "hello\nVIP_DONE_abc:0"
357+
quoted = 'hello\nVIP_DONE_abc:0"'
358+
assert _parse_done_marker(raw, "VIP_DONE_abc") == ("hello", 0)
359+
assert _parse_done_marker(quoted, "VIP_DONE_abc") is None
360+
361+
362+
# ---------------------------------------------------------------------------
363+
# _read_file_r_expr
364+
# ---------------------------------------------------------------------------
365+
366+
367+
class TestReadFileRExpr:
368+
def test_wraps_read_in_cat(self):
369+
"""The read expression must be cat()'d, not left as a bare/auto-printed
370+
expression -- R's console auto-print wraps a bare character result in
371+
quotes and escapes embedded newlines as literal ``\\n``, which broke
372+
the done-marker exit-code parse (see TestParseDoneMarker above)."""
373+
expr = _read_file_r_expr("/tmp/foo.txt")
374+
assert expr.startswith("cat(")
375+
assert "readLines(" in expr
376+
assert "paste(" in expr
377+
378+
def test_embeds_path(self):
379+
expr = _read_file_r_expr("/tmp/vip_term_abc.txt")
380+
assert '"/tmp/vip_term_abc.txt"' in expr
381+
348382

349383
# ---------------------------------------------------------------------------
350384
# _detect_ide
@@ -497,7 +531,9 @@ class TestReadFileRouting:
497531
def test_rstudio_calls_rstudio_eval(self, monkeypatch):
498532
page = MagicMock()
499533
monkeypatch.setattr(exec_mod, "_detect_ide", lambda p: "rstudio")
500-
mock_rstudio_eval = MagicMock(return_value="[1] hello world")
534+
# cat()'d output is raw -- no "[1]" index, no quoting -- so the mock
535+
# return value here is the *raw* form, not R's auto-printed form.
536+
mock_rstudio_eval = MagicMock(return_value="hello world")
501537
mock_positron_eval_r = MagicMock()
502538
mock_positron_eval_python = MagicMock()
503539
mock_editor_read = MagicMock()
@@ -508,7 +544,9 @@ def test_rstudio_calls_rstudio_eval(self, monkeypatch):
508544

509545
result = read_file(page, "/tmp/foo.txt", lang="r")
510546

511-
assert result == "hello world" # _strip_r_index applied
547+
assert result == "hello world"
548+
rstudio_expr = mock_rstudio_eval.call_args[0][1]
549+
assert rstudio_expr.startswith("cat(") # not a bare auto-printed expr
512550
mock_rstudio_eval.assert_called_once()
513551
mock_positron_eval_r.assert_not_called()
514552
mock_positron_eval_python.assert_not_called()
@@ -518,7 +556,8 @@ def test_positron_r_calls_positron_eval_r(self, monkeypatch):
518556
page = MagicMock()
519557
monkeypatch.setattr(exec_mod, "_detect_ide", lambda p: "positron")
520558
mock_rstudio_eval = MagicMock()
521-
mock_positron_eval_r = MagicMock(return_value="[1] file contents")
559+
# cat()'d output is raw -- no "[1]" index, no quoting.
560+
mock_positron_eval_r = MagicMock(return_value="file contents")
522561
mock_positron_eval_python = MagicMock()
523562
mock_editor_read = MagicMock()
524563
monkeypatch.setattr(exec_mod, "rstudio_eval", mock_rstudio_eval)
@@ -528,7 +567,9 @@ def test_positron_r_calls_positron_eval_r(self, monkeypatch):
528567

529568
result = read_file(page, "/tmp/foo.txt", lang="r")
530569

531-
assert result == "file contents" # _strip_r_index applied
570+
assert result == "file contents"
571+
positron_expr = mock_positron_eval_r.call_args[0][1]
572+
assert positron_expr.startswith("cat(") # not a bare auto-printed expr
532573
mock_positron_eval_r.assert_called_once()
533574
mock_rstudio_eval.assert_not_called()
534575
mock_positron_eval_python.assert_not_called()

selftests/test_workbench_git_ops.py

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
1-
"""Selftests for the Workbench git-ops clone command construction.
1+
"""Selftests for the Workbench git-ops clone command construction and skip gates.
22
33
Covers ``_do_clone`` in ``vip_tests.workbench.test_git_ops``: verifies the
44
shell command it builds removes any pre-existing clone directory before
55
running ``git clone``, so repeat runs against long-lived QA VMs don't fail
66
with "destination path already exists". No live Workbench deployment or
77
Playwright browser required -- ``terminal_run`` is monkeypatched to capture
88
the command string instead of executing it.
9+
10+
Also covers the config/auth skip-gate wording (#483) and the Gherkin step
11+
order guard (#479): every scenario in test_git_ops.feature must resolve the
12+
Git config gate before attempting the (possibly flaky) login step, so a
13+
read-only/config situation is reported deterministically instead of being
14+
masked by a login bounce.
915
"""
1016

1117
from __future__ import annotations
1218

19+
from importlib.util import find_spec
20+
from pathlib import Path
21+
22+
import pytest
23+
24+
from vip.config import GitTestConfig, VIPConfig, WorkbenchConfig
25+
from vip.gherkin import parse_feature_file
1326
from vip_tests.workbench import test_git_ops as git_ops
1427

1528

@@ -57,3 +70,115 @@ def fake_terminal_run(page, cmd, timeout=30_000, *, readback_lang="r"):
5770

5871
assert clone_dir == "/home/vip/other-repo"
5972
assert "rm -rf /home/vip/other-repo" in captured["cmd"]
73+
74+
75+
# ---------------------------------------------------------------------------
76+
# #479 -- Gherkin step order guard
77+
# ---------------------------------------------------------------------------
78+
79+
80+
def _git_ops_feature_path() -> Path:
81+
spec = find_spec("vip_tests")
82+
assert spec and spec.submodule_search_locations
83+
return Path(spec.submodule_search_locations[0]) / "workbench" / "test_git_ops.feature"
84+
85+
86+
class TestFeatureStepOrder:
87+
"""The Git config gate must resolve before the (possibly flaky) login step.
88+
89+
Otherwise a read-only/config situation is masked by a non-deterministic
90+
login bounce, surfacing a misleading auth error instead of the real
91+
config/read-only reason (#479).
92+
"""
93+
94+
def test_config_gate_precedes_login_in_every_scenario(self):
95+
parsed = parse_feature_file(_git_ops_feature_path())
96+
assert parsed["scenarios"], "expected scenarios in test_git_ops.feature"
97+
for scenario in parsed["scenarios"]:
98+
steps = scenario["steps"]
99+
config_idx = next(
100+
i for i, s in enumerate(steps) if "the Git test config is available" in s
101+
)
102+
login_idx = next(
103+
i for i, s in enumerate(steps) if "Workbench is accessible and I am logged in" in s
104+
)
105+
assert config_idx < login_idx, (
106+
f"scenario {scenario['title']!r}: 'the Git test config is available' "
107+
f"must precede 'Workbench is accessible and I am logged in', "
108+
f"got steps: {steps}"
109+
)
110+
111+
def test_push_scenarios_check_pushing_before_login(self):
112+
parsed = parse_feature_file(_git_ops_feature_path())
113+
push_scenarios = [s for s in parsed["scenarios"] if "push" in s["title"].lower()]
114+
assert push_scenarios, "expected at least one push scenario"
115+
for scenario in push_scenarios:
116+
steps = scenario["steps"]
117+
pushing_idx = next(
118+
i for i, s in enumerate(steps) if "the Git test config supports pushing" in s
119+
)
120+
login_idx = next(
121+
i for i, s in enumerate(steps) if "Workbench is accessible and I am logged in" in s
122+
)
123+
assert pushing_idx < login_idx, (
124+
f"scenario {scenario['title']!r}: 'the Git test config supports pushing' "
125+
f"must precede 'Workbench is accessible and I am logged in', "
126+
f"got steps: {steps}"
127+
)
128+
129+
130+
# ---------------------------------------------------------------------------
131+
# #483 -- skip-message wording
132+
# ---------------------------------------------------------------------------
133+
134+
135+
def _make_vip_config(git_test: GitTestConfig | None) -> VIPConfig:
136+
wc = WorkbenchConfig(url="https://workbench.example.com", git_test=git_test)
137+
return VIPConfig(workbench=wc)
138+
139+
140+
class TestGitConfigAvailableSkipMessages:
141+
def test_none_config_message_explains_public_vs_token(self):
142+
"""Defensive fallback: git_test is None only via direct construction."""
143+
cfg = _make_vip_config(None)
144+
with pytest.raises(pytest.skip.Exception) as exc_info:
145+
git_ops.git_config_available(cfg)
146+
msg = exc_info.value.msg
147+
assert "auth_method='none'" in msg
148+
assert "no token required" in msg
149+
assert "VIP_GIT_TOKEN" in msg
150+
151+
def test_empty_clone_url_message_explains_public_vs_token(self):
152+
cfg = _make_vip_config(GitTestConfig(clone_url="", auth_method="none"))
153+
with pytest.raises(pytest.skip.Exception) as exc_info:
154+
git_ops.git_config_available(cfg)
155+
msg = exc_info.value.msg
156+
assert "auth_method='none'" in msg
157+
assert "no token" in msg
158+
159+
def test_valid_none_config_does_not_skip(self):
160+
cfg = _make_vip_config(
161+
GitTestConfig(
162+
clone_url="https://github.com/posit-dev/posit-cli.git", auth_method="none"
163+
)
164+
)
165+
result = git_ops.git_config_available(cfg)
166+
assert result is cfg.workbench.git_test
167+
168+
169+
class TestGitConfigSupportsPushingSkipMessage:
170+
def test_anonymous_auth_skip_message(self):
171+
git_cfg = GitTestConfig(clone_url="https://github.com/org/repo.git", auth_method="none")
172+
with pytest.raises(pytest.skip.Exception) as exc_info:
173+
git_ops.git_config_supports_pushing(git_cfg)
174+
msg = exc_info.value.msg
175+
assert "auth_method='none' is anonymous (read-only)" in msg
176+
assert "auth_method='https-token'" in msg
177+
assert "VIP_GIT_TOKEN" in msg
178+
179+
def test_https_token_auth_does_not_skip(self):
180+
git_cfg = GitTestConfig(
181+
clone_url="https://github.com/org/repo.git", auth_method="https-token", token="tok"
182+
)
183+
# Should not raise/skip.
184+
git_ops.git_config_supports_pushing(git_cfg)

src/vip/config.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,21 @@ def from_dict(cls, raw: dict) -> WorkbenchExtensionsConfig:
168168
)
169169

170170

171+
# Default anonymous-clone target used when [workbench.git_test] is absent, so
172+
# the clone/connectivity scenarios have something to run against out-of-the-box.
173+
DEFAULT_PUBLIC_CLONE_URL = "https://github.com/posit-dev/posit-cli.git"
174+
175+
171176
@dataclass
172177
class GitTestConfig:
173178
"""Configuration for [workbench.git_test] Git operations testing.
174179
175180
Enables terminal and GUI Git scenarios (clone, branch, commit, push)
176-
against a real Git repository. All scenarios auto-skip when this block
177-
is absent from the config file.
181+
against a real Git repository. When this block is absent from the config
182+
file, ``WorkbenchConfig.from_dict`` synthesizes a default pointing at a
183+
public repository (``DEFAULT_PUBLIC_CLONE_URL``) with ``auth_method =
184+
"none"``, so clone/connectivity scenarios run out-of-the-box; push/commit
185+
scenarios skip as read-only until the user configures a token.
178186
179187
Token is never stored in the TOML file. For ``auth_method = "https-token"``
180188
set VIP_GIT_TOKEN in the environment (same pattern as
@@ -242,6 +250,9 @@ class WorkbenchConfig(ProductConfig):
242250
idle_grace_seconds: int = 60
243251
extensions: WorkbenchExtensionsConfig = field(default_factory=WorkbenchExtensionsConfig)
244252
kubernetes: WorkbenchKubernetesConfig = field(default_factory=WorkbenchKubernetesConfig)
253+
# None only when constructed directly (e.g. in tests); ``from_dict`` always
254+
# populates this, defaulting to an anonymous public-repo clone when
255+
# [workbench.git_test] is absent from the TOML file.
245256
git_test: GitTestConfig | None = None
246257
# Base path of Chronicle's on-disk data on the Workbench server. Defaults
247258
# to the embedded-Workbench location under the shared-storage tree. The
@@ -286,7 +297,11 @@ def from_dict(cls, raw: dict) -> WorkbenchConfig:
286297
idle_grace_seconds=raw.get("idle_grace_seconds", 60),
287298
extensions=WorkbenchExtensionsConfig.from_dict(raw.get("extensions", {})),
288299
kubernetes=WorkbenchKubernetesConfig.from_dict(raw.get("kubernetes", {})),
289-
git_test=GitTestConfig.from_dict(git_test_raw) if git_test_raw is not None else None,
300+
git_test=(
301+
GitTestConfig.from_dict(git_test_raw)
302+
if git_test_raw is not None
303+
else GitTestConfig(clone_url=DEFAULT_PUBLIC_CLONE_URL, auth_method="none")
304+
),
290305
chronicle_data_path=raw.get(
291306
"chronicle_data_path", "/var/lib/rstudio-server/shared-storage/chronicle"
292307
),

src/vip_tests/workbench/exec.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ def _wrap_r_expr(expr: str, start: str, end: str) -> str:
8585
return f'cat("{s1}", "{s2}\\n", sep=""); {expr}; cat("\\n", "{e1}", "{e2}\\n", sep="")'
8686

8787

88+
def _read_file_r_expr(path: str) -> str:
89+
"""Build the R expression that reads *path* and emits its raw contents.
90+
91+
Wrapped in ``cat()`` so R prints the file bytes directly. A bare
92+
``paste(readLines(...))`` is auto-printed by the R REPL as a quoted,
93+
backslash-escaped character vector -- which appends a stray ``"`` to the
94+
done-marker line (``...:0"``) and makes ``_parse_done_marker`` reject the
95+
exit code as non-numeric, hanging ``terminal_run`` until timeout.
96+
"""
97+
return f'cat(paste(readLines("{path}"), collapse="\\n"))'
98+
99+
88100
def _wrap_python_expr(expr: str, start: str, end: str) -> str:
89101
"""Wrap *expr* with Python print() markers.
90102
@@ -773,16 +785,11 @@ def read_file(page: Page, path: str, timeout: int = 30_000, *, lang: str = "r")
773785
ide = _detect_ide(page)
774786
if ide == "positron":
775787
if lang.lower() == "r":
776-
expr = f'paste(readLines("{path}"), collapse="\\n")'
777-
return _strip_r_index(positron_eval_r(page, expr, timeout=timeout))
788+
return positron_eval_r(page, _read_file_r_expr(path), timeout=timeout)
778789
return positron_eval_python(
779790
page, f'with open("{path}") as _f: print(_f.read())', timeout=timeout
780791
)
781792
if ide == "vscode":
782793
return read_file_via_vscode_editor(page, path, timeout=timeout)
783794
# RStudio (and unknown — fall back to RStudio R path)
784-
if lang.lower() == "r":
785-
expr = f'paste(readLines("{path}"), collapse="\\n")'
786-
return _strip_r_index(rstudio_eval(page, expr, timeout=timeout))
787-
expr = f'paste(readLines("{path}"), collapse="\\n")'
788-
return _strip_r_index(rstudio_eval(page, expr, timeout=timeout))
795+
return rstudio_eval(page, _read_file_r_expr(path), timeout=timeout)

0 commit comments

Comments
 (0)