Skip to content

Commit e334038

Browse files
committed
feat: redesign setup studio workflow
Guide first-time setup through prerequisites, configuration, runtime validation, and a first processing run so blockers and recovery steps are clear.
1 parent 1b7307f commit e334038

13 files changed

Lines changed: 2206 additions & 732 deletions

tests/adapters/test_api_app.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ def test_routes_delegate_to_runtime_use_cases_and_keep_http_shapes() -> None:
102102
rss = client.get("/rss?limit=5")
103103
runtime_response = client.get("/runtime")
104104
bootstrap = client.post("/setup/bootstrap")
105+
runtime_payload = runtime_response.json()
106+
bootstrap_payload = bootstrap.json()
105107

106108
assert ingest.status_code == 200
107109
assert ingest.json() == {
@@ -127,16 +129,26 @@ def test_routes_delegate_to_runtime_use_cases_and_keep_http_shapes() -> None:
127129
assert rss.status_code == 200
128130
assert rss.text == "<rss>feed</rss>"
129131
assert runtime_response.status_code == 200
130-
assert runtime_response.json() == {
132+
assert runtime_payload == {
131133
"ollama_version": "0.6.0",
132134
"local_models": {"qwen": {"size": 1}},
133135
"reachable": True,
134136
"database_path": ".data/runtime.db",
135137
"storage_dir": ".data",
136138
"models": ["qwen", "qwen:min"],
139+
"setup_view": {
140+
"state": "blocked",
141+
"missing_models": ["qwen:min"],
142+
"next_action": "Bootstrap required models",
143+
},
144+
}
145+
assert runtime_payload["setup_view"] == {
146+
"state": "blocked",
147+
"missing_models": ["qwen:min"],
148+
"next_action": "Bootstrap required models",
137149
}
138150
assert bootstrap.status_code == 200
139-
assert bootstrap.json() == {
151+
assert bootstrap_payload == {
140152
"models_prepared": ["qwen"],
141153
"runtime": {
142154
"ollama_version": "0.6.0",

tests/test_api_setup.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,15 @@ def test_gui_and_setup_routes() -> None:
5757

5858
css = client.get("/static/setup.css")
5959
assert css.status_code == 200
60-
assert "--accent" in css.text
60+
assert ".shell" in css.text
6161

6262
js = client.get("/static/setup.js")
6363
assert js.status_code == 200
64-
assert "runDiagnostics" in js.text
65-
assert "API_KEY=${fields.apiKey.value.trim()}" not in js.text
66-
assert "const apiKey = fields.apiKey.value.trim();" in js.text
64+
assert js.headers["content-type"].startswith("text/javascript")
65+
66+
setup_state = client.get("/static/setup_state.js")
67+
assert setup_state.status_code == 200
68+
assert setup_state.headers["content-type"].startswith("text/javascript")
6769

6870
setup = client.get("/setup/config")
6971
assert setup.status_code == 200
@@ -82,6 +84,17 @@ def test_gui_and_setup_routes() -> None:
8284
diagnostics = client.get("/setup/diagnostics")
8385
assert diagnostics.status_code == 200
8486
diag_payload = diagnostics.json()
87+
setup_view = diag_payload["setup_view"]
88+
assert set(setup_view) >= {"state", "checks", "blockers", "next_action"}
89+
assert setup_view["state"] in {"ready", "blocked"}
90+
assert isinstance(setup_view["checks"], list)
91+
assert {check["id"] for check in setup_view["checks"]} >= {
92+
"python",
93+
"ffmpeg",
94+
"ffprobe",
95+
"yt_dlp",
96+
"ollama",
97+
}
8598
assert "platform" in diag_payload
8699
assert "dependencies" in diag_payload
87100
assert "ready" in diag_payload
@@ -102,18 +115,7 @@ def test_runtime_requires_api_key_when_enabled() -> None:
102115

103116
authorized = client.get("/runtime", headers={"X-API-Key": "secret"})
104117
assert authorized.status_code == 200
105-
assert authorized.json() == {
106-
"ollama_version": "0.6.0",
107-
"local_models": {"qwen3.5:2b-q4_K_M": {}},
108-
"reachable": True,
109-
"database_path": ".data/vra.db",
110-
"storage_dir": ".data",
111-
"models": [
112-
"qwen3.5:4b-q4_K_M",
113-
"qwen3.5:2b-q4_K_M",
114-
"qwen3.5:0.8b-q8_0",
115-
],
116-
}
118+
assert authorized.json()["setup_view"]["state"] == "blocked"
117119

118120

119121
def test_setup_config_omits_api_key_and_uses_current_bind_in_commands() -> None:
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from dataclasses import dataclass
5+
from typing import Any, cast
6+
7+
from fastapi.testclient import TestClient
8+
9+
from adapter_api import create_app
10+
from core_config import Config
11+
from video_rss_aggregator.bootstrap import AppRuntime, AppUseCases
12+
13+
14+
@dataclass
15+
class _AsyncValue:
16+
value: Any
17+
18+
async def execute(self, *_args, **_kwargs) -> Any:
19+
return self.value
20+
21+
22+
def _build_runtime(config: Config) -> AppRuntime:
23+
runtime_status = {
24+
"ollama_version": "0.6.0",
25+
"local_models": {"qwen3.5:2b-q4_K_M": {}},
26+
"reachable": True,
27+
"database_path": config.database_path,
28+
"storage_dir": config.storage_dir,
29+
"models": list(config.model_priority),
30+
}
31+
return AppRuntime(
32+
config=config,
33+
use_cases=AppUseCases(
34+
get_runtime_status=_AsyncValue(runtime_status),
35+
bootstrap_runtime=_AsyncValue({"models": ["qwen3.5:2b-q4_K_M"]}),
36+
ingest_feed=cast(Any, _AsyncValue(None)),
37+
process_source=cast(Any, _AsyncValue(None)),
38+
render_rss_feed=_AsyncValue("<rss></rss>"),
39+
),
40+
close=lambda: None,
41+
)
42+
43+
44+
client = TestClient(create_app(_build_runtime(Config())))
45+
46+
47+
def class_tokens_for_id(html: str, element_id: str) -> set[str]:
48+
tag_match = re.search(rf'<[^>]*\bid="{re.escape(element_id)}"[^>]*>', html)
49+
assert tag_match is not None
50+
51+
class_match = re.search(r'\bclass="([^"]+)"', tag_match.group(0))
52+
assert class_match is not None
53+
54+
return set(class_match.group(1).split())
55+
56+
57+
def test_setup_css_supports_shell_and_disclosure_layout() -> None:
58+
css = client.get("/static/setup.css")
59+
60+
assert css.status_code == 200
61+
assert "--paper-base" in css.text
62+
assert ".shell" in css.text
63+
assert ".progress-rail" in css.text
64+
assert ".detail-drawer" in css.text
65+
assert ".summary-card" in css.text
66+
assert "@media (max-width: 860px)" in css.text
67+
assert '#setup-progress[data-mobile-open="false"]' in css.text
68+
assert '#mobile-step-toggle[aria-expanded="true"]' in css.text
69+
assert "#6366f1" not in css.text
70+
71+
72+
def test_setup_home_renders_shell_and_collapsed_detail_drawers() -> None:
73+
home = client.get("/")
74+
workbench_classes = class_tokens_for_id(home.text, "setup-workbench")
75+
progress_classes = class_tokens_for_id(home.text, "setup-progress")
76+
prerequisites_panel_classes = class_tokens_for_id(
77+
home.text, "step-panel-prerequisites"
78+
)
79+
configuration_panel_classes = class_tokens_for_id(
80+
home.text, "step-panel-configuration"
81+
)
82+
runtime_panel_classes = class_tokens_for_id(home.text, "step-panel-runtime")
83+
process_panel_classes = class_tokens_for_id(home.text, "step-panel-process")
84+
85+
assert home.status_code == 200
86+
assert 'id="setup-workbench"' in home.text
87+
assert 'id="setup-progress"' in home.text
88+
assert 'id="readiness-summary"' in home.text
89+
assert 'id="blocker-summary"' in home.text
90+
assert 'id="common-fixes"' in home.text
91+
assert 'data-step-id="prerequisites"' in home.text
92+
assert 'data-step-id="configuration"' in home.text
93+
assert 'data-step-id="runtime"' in home.text
94+
assert 'data-step-id="process"' in home.text
95+
assert "shell" in workbench_classes
96+
assert "progress-rail" in progress_classes
97+
assert {"step-panel", "card"} <= prerequisites_panel_classes
98+
assert {"step-panel", "card", "wide"} <= configuration_panel_classes
99+
assert {"step-panel", "card"} <= runtime_panel_classes
100+
assert {"step-panel", "card"} <= process_panel_classes
101+
assert 'id="mobile-step-toggle"' in home.text
102+
assert 'aria-controls="setup-progress"' in home.text
103+
assert 'aria-expanded="false"' in home.text
104+
assert 'id="run-diagnostics"' in home.text
105+
assert 'id="copy-env"' in home.text
106+
assert 'id="runtime-check"' in home.text
107+
assert 'id="bootstrap-models"' in home.text
108+
assert 'id="process-run"' in home.text
109+
assert 'id="runtime-summary"' in home.text
110+
assert 'id="process-summary"' in home.text
111+
assert 'id="advanced-config"' in home.text
112+
assert 'id="runtime-details"' in home.text
113+
assert 'id="process-details"' in home.text
114+
assert re.search(
115+
r'<details[^>]*id="advanced-config"(?![^>]*\bopen\b)[^>]*>', home.text
116+
)
117+
assert re.search(
118+
r'<details[^>]*id="runtime-details"(?![^>]*\bopen\b)[^>]*>', home.text
119+
)
120+
assert re.search(
121+
r'<details[^>]*id="process-details"(?![^>]*\bopen\b)[^>]*>', home.text
122+
)
123+
124+
125+
def test_setup_assets_expose_module_and_top_level_contracts() -> None:
126+
home = client.get("/")
127+
entry_js = client.get("/static/setup.js")
128+
api_js = client.get("/static/setup_api.js")
129+
state_js = client.get("/static/setup_state.js")
130+
view_models_js = client.get("/static/setup_view_models.js")
131+
views_js = client.get("/static/setup_views.js")
132+
133+
assert 'src="static/setup.js" type="module"' in home.text
134+
135+
for response in (entry_js, api_js, state_js, view_models_js, views_js):
136+
assert response.status_code == 200
137+
assert response.headers["content-type"].startswith("text/javascript")
138+
139+
assert 'import { createSetupApi } from "./setup_api.js";' in entry_js.text
140+
assert 'import { createSetupState } from "./setup_state.js";' in entry_js.text
141+
assert (
142+
'import { buildShellSummaryView } from "./setup_view_models.js";'
143+
in entry_js.text
144+
)
145+
assert (
146+
'import { buildProcessSummaryView } from "./setup_view_models.js";'
147+
in entry_js.text
148+
)
149+
assert (
150+
'import { buildProcessFailureView } from "./setup_view_models.js";'
151+
in entry_js.text
152+
)
153+
assert 'import { createSetupViews } from "./setup_views.js";' in entry_js.text
154+
assert "function boot()" in entry_js.text
155+
assert "boot();" in entry_js.text
156+
assert "handleDiagnosticsRun" in entry_js.text
157+
assert "handleRuntimeCheck" in entry_js.text
158+
assert "handleBootstrapModels" in entry_js.text
159+
assert "handleProcessRun" in entry_js.text
160+
assert "runDiagnostics();" not in entry_js.text
161+
162+
assert "export function createSetupApi" in api_js.text
163+
assert "runDiagnostics" in api_js.text
164+
assert "checkRuntime" in api_js.text
165+
assert "bootstrapModels" in api_js.text
166+
assert "runProcess" in api_js.text
167+
168+
assert "export function createSetupState" in state_js.text
169+
assert "beginDiagnosticsCheck" in state_js.text
170+
assert "markConfigurationComplete" in state_js.text
171+
assert "beginRuntimeCheck" in state_js.text
172+
assert "completeRuntimeCheck" in state_js.text
173+
assert "beginProcess" in state_js.text
174+
assert "markProcessSuccess" in state_js.text
175+
assert "markProcessFailure" in state_js.text
176+
assert (
177+
'const STEP_ORDER = ["prerequisites", "configuration", "runtime", "process"]'
178+
in state_js.text
179+
)
180+
181+
assert "export function buildShellSummaryView" in view_models_js.text
182+
assert "export function buildStaleSummaryLabel" in view_models_js.text
183+
assert "export function buildProcessSummaryView" in view_models_js.text
184+
assert "export function buildProcessFailureView" in view_models_js.text
185+
186+
assert "export function createSetupViews" in views_js.text
187+
assert "renderShellState" in views_js.text
188+
assert "renderDiagnosticsSummary" in views_js.text
189+
assert "renderRuntimeSummary" in views_js.text
190+
assert "renderRuntimeDetails" in views_js.text
191+
assert "renderProcessSummary" in views_js.text
192+
assert "renderProcessDetails" in views_js.text
193+
assert "bindMobileStepToggle" in views_js.text
194+
assert 'document.getElementById("mobile-step-toggle")' in views_js.text
195+
assert (
196+
'progressRail.dataset.mobileOpen = expanded ? "true" : "false";'
197+
in views_js.text
198+
)
199+
assert (
200+
'mobileStepToggle.setAttribute("aria-expanded", String(expanded));'
201+
in views_js.text
202+
)
203+
assert "views.bindMobileStepToggle();" in entry_js.text
204+
205+
206+
def test_setup_js_prefers_backend_setup_views_for_state_transitions() -> None:
207+
entry_js = client.get("/static/setup.js")
208+
209+
assert entry_js.status_code == 200
210+
assert "function toDiagnosticsView(report)" in entry_js.text
211+
assert (
212+
'return report.setup_view || { state: report.ready ? "ready" : "blocked" };'
213+
in entry_js.text
214+
)
215+
assert "function toRuntimeView(runtime)" in entry_js.text
216+
assert (
217+
'return runtime.setup_view || { state: runtime.reachable ? "ready" : "blocked" };'
218+
in entry_js.text
219+
)
220+
assert "const diagnosticsView = toDiagnosticsView(report);" in entry_js.text
221+
assert "state.applyDiagnosticsResult(diagnosticsView);" in entry_js.text
222+
assert "const runtimeView = toRuntimeView(runtime);" in entry_js.text
223+
assert "state.completeRuntimeCheck(runtimeView);" in entry_js.text
224+
225+
226+
def test_setup_js_renders_shaped_setup_view_summaries_and_common_fixes() -> None:
227+
entry_js = client.get("/static/setup.js")
228+
229+
assert entry_js.status_code == 200
230+
assert "const diagnosticsView = toDiagnosticsView(report);" in entry_js.text
231+
assert "views.renderDiagnosticsSummary(diagnosticsView);" in entry_js.text
232+
assert "views.renderCommonFixes(diagnosticsView);" in entry_js.text
233+
assert "const runtimeView = toRuntimeView(runtime);" in entry_js.text
234+
assert "renderRuntimeState(runtimeView, runtime);" in entry_js.text
235+
assert (
236+
"renderRuntimeState(runtimeView, { ...runtime, bootstrap: report });"
237+
in entry_js.text
238+
)
239+
240+
241+
def test_setup_views_expose_common_fixes_and_runtime_readiness_copy() -> None:
242+
views_js = client.get("/static/setup_views.js")
243+
244+
assert views_js.status_code == 200
245+
assert "function renderCommonFixes(view)" in views_js.text
246+
assert 'const container = document.getElementById("common-fixes");' in views_js.text
247+
assert "const fixes = Array.isArray(view?.checks)" in views_js.text
248+
assert "view.checks.filter((check) => check.fix)" in views_js.text
249+
assert "function renderRuntimeSummary(runtimeView, runtime)" in views_js.text
250+
assert (
251+
'["Required models", `${requiredModels.length} configured`],' in views_js.text
252+
)
253+
assert '["Available locally", `${localModels.length} ready`],' in views_js.text
254+
assert "runtimeView?.next_action ||" in views_js.text
255+
assert "renderCommonFixes," in views_js.text

0 commit comments

Comments
 (0)