From 231cf0c6ba57c9e8efbe02f46b44ee1fe640ec63 Mon Sep 17 00:00:00 2001 From: bboonnee Date: Fri, 31 Jul 2026 16:58:38 +0800 Subject: [PATCH] U-022: v_project_setup + c_pick_folder 全平台补齐 --- docs/development-plan.md | 1 + docs/sdd/unit_matrix.json | 2 +- frontend/app.js | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- frontend/style.css | 17 +++++++++++++++++ src/backend_server.py | 144 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------- src/paths.py | 6 ++++++ src/schema.py | 10 ++++++++-- src/test_backend_server.py | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ 8 files changed, 412 insertions(+), 42 deletions(-) diff --git a/docs/development-plan.md b/docs/development-plan.md index 88fd616..0374ea5 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -217,6 +217,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-022 [Value Path]** `v_project_setup` + `c_pick_folder` 全平台补齐(Windows `PowerShell`/Linux `zenity`/`kdialog`) - 依赖:U-007, U-008 - Done:REQ-11 happy/boundary/failure 通过 + - **实现补充(2026-07-31)**:Done 行字面写的是"REQ-11 全部通过",但 REQ-11 happy 用例完整走完还需要 `v_goal_composer`/`c_task_create` 创建 task 那一步——那是 U-023 的范围,本 Unit 依赖只到 U-007/U-008,不可能单独满足。本 Unit 实际完成的是 REQ-11 里 `v_project_setup`/`c_pick_folder`/`c_project_create` 那部分:① `pick_folder()` 补齐 Windows(PowerShell `FolderBrowserDialog` 脚本)/Linux(`zenity` 优先,缺失则退回 `kdialog`,都不在则 `DIALOG_UNAVAILABLE`);② `c_project_create`(`POST /api/projects`)此前只有 U-002 写好的 `project_scaffold.create_project()` 函数本体、从未接到 HTTP 层(`backend_server.py` 因此第一次需要持有一个 sqlite 连接——`ThreadingHTTPServer` 按请求起线程,给 `schema.connect()` 加了 `check_same_thread=False` 参数 + 一把 `server.conn_lock` 避免并发请求踩到同一个连接);③ `frontend/app.js` 补上 `#/setup` 真实交互(选文件夹 → 校验 Git 仓库 → 非 Git 仓库时二次确认 → 创建 → 跳转仪表盘),DOM class 命名对齐 `docs/wireframes/v_project_setup.html`。REQ-11 的完整 happy 用例(含创建 task 那一步)等 U-023 落地后再验收。浏览器扩展在本次会话不可用,UI 部分改为对着真实运行的后端做完整 HTTP 级冒烟(`curl` 走一遍选文件夹之外的全部接口调用序列,`node --check` 过一遍 `app.js` 语法),不是真人点击浏览器验证过的。 - 覆盖:02-V `v_project_setup`;03-C `c_pick_folder`;05 节 §5.4.1;REQ-11 - **U-023 [Value Path]** `v_goal_composer` + `v_bug_feedback` diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index 1711d26..172b325 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -36,7 +36,7 @@ {"id": "U-020", "phase": "PH2", "sub_phase": "PH2-S3", "name": "p_usage_limit_wait 完整验收(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012"], "coverage": {"model": [], "view": [], "controller": [], "process": ["p_usage_limit_wait"], "requirement": ["REQ-14"]}, "done": "REQ-14 全部通过(含超时兜底);补上了 opus 真实降级重试(U-013 时只做到不退避,没做到真重试)+ 之前漏测的 weekly 类型"}, {"id": "U-021", "phase": "PH2", "sub_phase": "PH2-S4", "name": "Graph Engine 持久化 + 断点恢复(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["work_graph_run", "work_node_execution"], "view": [], "controller": [], "process": ["p_work_graph_execution"], "requirement": ["REQ-06"]}, "done": "REQ-06 happy/boundary/failure 通过;now 默认值从 time.monotonic 改成 time.time(墙钟)以支持跨进程重启的 quota 等待续算;新增 resume_work_graph_run() 返回未完成节点列表"}, - {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 全部通过"}, + {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder", "c_project_create"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 里 v_project_setup/c_pick_folder/c_project_create 部分通过;c_pick_folder 补齐 Windows/Linux;c_project_create 首次接上 HTTP 路由;REQ-11 完整 happy(含建 task)随 U-023 累计满足"}, {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record"}, {"id": "U-024", "phase": "PH3", "sub_phase": "PH3-S2", "name": "c_ws_subscribe + execution_event 实时推送(最小连通版)", "classification": "Value Path", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["execution_event"], "view": [], "controller": ["c_ws_subscribe"], "process": [], "requirement": ["REQ-12"]}, "done": "推送链路本身连通,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure 留给 U-024.5)"}, {"id": "U-024.5", "phase": "PH3", "sub_phase": "PH3-S2", "name": "REQ-12 完整验收:事件多样性联调(2026-07-30 新增)", "classification": "Value Path", "depends_on": ["U-024", "U-015", "U-016", "U-018", "U-019", "U-020", "U-021"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-12"]}, "done": "REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准"}, diff --git a/frontend/app.js b/frontend/app.js index cd9bb2e..03ea1c3 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,17 +1,19 @@ -// U-008: empty-shell client-side router. Hash-based (no History API / server -// catch-all needed) -- real page content is PH3 (U-022+), this just proves -// navigation swaps content without a page reload or any build step. +// U-008: hash-based client-side router (no History API / server catch-all +// needed). U-022 adds the first real page, #/setup (v_project_setup, 02-V +// §2.1) -- everything else is still a PH3 placeholder. const routes = { - "#/": () => "

Dashboard

placeholder -- real content is PH3 (v_dashboard, U-025)

", - "#/tasks": () => "

Tasks

placeholder -- real content is PH3 (v_task_board, U-025)

", - "#/settings": () => "

Settings

placeholder -- real content is PH3 (U-030)

", + "#/": { render: () => "

Dashboard

placeholder -- real content is PH3 (v_dashboard, U-025)

" }, + "#/tasks": { render: () => "

Tasks

placeholder -- real content is PH3 (v_task_board, U-025)

" }, + "#/settings": { render: () => "

Settings

placeholder -- real content is PH3 (U-030)

" }, + "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, }; function render() { const hash = window.location.hash || "#/"; - const view = routes[hash] || (() => "

Not found

" + hash + "

"); - document.getElementById("app").innerHTML = view(); + const route = routes[hash] || { render: () => "

Not found

" + hash + "

" }; + document.getElementById("app").innerHTML = route.render(); + if (route.mount) route.mount(); for (const a of document.querySelectorAll("#nav a")) { a.classList.toggle("active", a.getAttribute("href") === hash); @@ -20,3 +22,103 @@ function render() { window.addEventListener("hashchange", render); window.addEventListener("DOMContentLoaded", render); + +// ---- v_project_setup (02-V §2.1): create/switch project ---- + +function renderProjectSetup() { + return ` +
← 返回仪表盘
+
+

创建 / 切换项目

+
选择一个本地文件夹作为目标项目的代码空间
+
+ +
+ + +
点击"选择文件夹"会由后端代为弹出系统原生对话框,不支持手动输入路径(除非返回 DIALOG_UNAVAILABLE)
+
+ +
+ + +
+
+ +
+ +
+
+ `; +} + +function mountProjectSetup() { + const folderInput = document.getElementById("folder_path"); + const folderHint = document.getElementById("folder_path_hint"); + const nameInput = document.getElementById("project_name"); + const errorBox = document.getElementById("setup_error"); + const confirmBanner = document.getElementById("confirm_banner"); + + function showError(message) { + errorBox.textContent = message; + errorBox.style.display = "block"; + } + function clearError() { + errorBox.style.display = "none"; + } + + document.getElementById("btn_pick_folder").addEventListener("click", async () => { + clearError(); + const resp = await fetch("/api/system/pick-folder", { method: "POST", body: "{}" }); + const body = await resp.json(); + if (resp.status === 422 && body.error === "DIALOG_UNAVAILABLE") { + folderInput.removeAttribute("readonly"); + folderHint.textContent = "当前环境不支持弹窗选择,请手动输入路径"; + return; + } + if (body.selected_path) { + folderInput.value = body.selected_path; + } + // user canceled the native dialog -> folder_path stays as it was (02-V §2.1) + }); + + async function createProject(initGitIfMissing) { + clearError(); + confirmBanner.style.display = "none"; + const folderPath = folderInput.value.trim(); + if (!folderPath) { + showError("请先选择目标文件夹"); + return; + } + const resp = await fetch("/api/projects", { + method: "POST", + body: JSON.stringify({ + target_folder_path: folderPath, + name: nameInput.value.trim() || undefined, + init_git_if_missing: initGitIfMissing, + }), + }); + const body = await resp.json(); + if (resp.status === 200) { + window.location.hash = "#/"; + return; + } + if (body.error === "GIT_INIT_DECLINED") { + confirmBanner.style.display = "block"; + return; + } + showError(body.error + (body.detail ? ":" + body.detail : "")); + } + + document.getElementById("btn_create").addEventListener("click", () => createProject(false)); + document.getElementById("btn_confirm_init").addEventListener("click", () => createProject(true)); + document.getElementById("btn_cancel_init").addEventListener("click", () => { + confirmBanner.style.display = "none"; + }); +} diff --git a/frontend/style.css b/frontend/style.css index 281abe5..c9b8538 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -5,3 +5,20 @@ body { font-family: system-ui, sans-serif; margin: 0; } #nav a { text-decoration: none; color: #333; } #nav a.active { font-weight: bold; } #app { padding: 1rem; } + +/* U-022: v_project_setup -- class names match docs/wireframes/v_project_setup.html */ +.top-back a { color: #6b7280; text-decoration: none; } +.page { padding: 28px; max-width: 560px; margin: 0 auto; } +.page h1 { font-size: 22px; margin: 0 0 4px; } +.page .page-sub { color: #6b7280; font-size: 13px; margin-bottom: 28px; } +.panel { border: 1px solid #e5e7eb; border-radius: 10px; padding: 24px; background: #f9fafb; } +.field { margin-bottom: 18px; } +.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; } +.field input { width: 100%; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; } +.field input[readonly] { background: #f3f4f6; color: #6b7280; } +.field .hint { font-size: 12px; color: #6b7280; margin-top: 5px; } +.btn { display: inline-block; padding: 9px 16px; border-radius: 6px; border: 1px solid #e5e7eb; background: #fff; font-size: 14px; cursor: pointer; } +.btn-primary { background: #4f46e5; border-color: #4f46e5; color: #fff; } +.btn-row { display: flex; gap: 10px; margin-top: 22px; } +.confirm-banner { background: #fffbeb; border: 1px solid #f59e0b; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-top: 16px; } +.error-banner { background: #fef2f2; border: 1px solid #ef4444; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; } diff --git a/src/backend_server.py b/src/backend_server.py index c82f075..1311e03 100644 --- a/src/backend_server.py +++ b/src/backend_server.py @@ -1,20 +1,28 @@ -"""Local backend process skeleton (U-007): pure stdlib `http.server`, no -third-party deps (05 §5.7) -- this process is also where the Graph Engine -scheduler will live later (05 §5.4.1), but that's a different Unit. - -MVP scope here: process start/stop, `c_pick_folder` (03-C §3.-1) as the one -real API route -- macOS only for now (`osascript`); Windows/Linux native -dialogs are PH3 (U-022) -- and serving the U-008 frontend shell as static -files (no build step, 05 §5.7: whitelisted by exact path, not a generic -directory walker, so there's no path-traversal surface to reason about). +"""Local backend process skeleton (U-007, extended in U-022): pure stdlib +`http.server`, no third-party deps (05 §5.7) -- this process is also where +the Graph Engine scheduler will live later (05 §5.4.1), but that's a +different Unit. + +MVP scope: process start/stop, `c_pick_folder` (03-C §3.-1, now all three +platforms -- U-007 shipped macOS only via `osascript`; U-022 adds Windows +via a `powershell` FolderBrowserDialog script and Linux via `zenity`/ +`kdialog`, whichever is installed), `c_project_create` (03-C §3.0, wired to +the U-002 `project_scaffold.create_project()` function that had no HTTP +route until now) for `v_project_setup`'s submit flow, and serving the U-008 +frontend shell as static files (no build step, 05 §5.7: whitelisted by exact +path, not a generic directory walker, so there's no path-traversal surface +to reason about). """ import json import os import platform import subprocess +import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from src import paths, project_scaffold, schema + _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") # U-008: exact-path whitelist, not a generic static-file server -- the @@ -32,13 +40,19 @@ class DialogUnavailableError(Exception): def pick_folder(): - """macOS implementation of c_pick_folder. Returns {"selected_path": str} - on success, {"selected_path": None} on user cancel (03-C: not an error). - Raises DialogUnavailableError off macOS or if osascript itself fails for - a reason other than the user cancelling.""" - if platform.system() != "Darwin": - raise DialogUnavailableError("native folder picker only implemented for macOS so far (PH3 adds Windows/Linux)") - + """c_pick_folder (03-C §3.-1), all three platforms. Returns + {"selected_path": str} on success, {"selected_path": None} on user + cancel (not an error). Raises DialogUnavailableError if the platform's + native dialog tool itself fails or isn't installed.""" + system = platform.system() + if system == "Darwin": + return _pick_folder_macos() + if system == "Windows": + return _pick_folder_windows() + return _pick_folder_linux() + + +def _pick_folder_macos(): script = 'POSIX path of (choose folder with prompt "Select a graphlemon project folder")' result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True) if result.returncode != 0: @@ -48,6 +62,45 @@ def pick_folder(): return {"selected_path": result.stdout.strip()} +def _pick_folder_windows(): + # .NET FolderBrowserDialog via a one-shot PowerShell script -- no + # cancel-vs-error signal other than empty stdout on a clean exit. + script = ( + "Add-Type -AssemblyName System.Windows.Forms; " + "$f = New-Object System.Windows.Forms.FolderBrowserDialog; " + "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }" + ) + try: + result = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, text=True, + ) + except FileNotFoundError as e: + raise DialogUnavailableError(str(e)) + if result.returncode != 0: + raise DialogUnavailableError(result.stderr.strip()) + return {"selected_path": result.stdout.strip() or None} + + +def _pick_folder_linux(): + # zenity (GNOME) and kdialog (KDE) are the two dialogs actually present + # across mainstream Linux desktops; try whichever exists. Both exit + # non-zero on user cancel, indistinguishable here from "no folder + # chosen" -- both cases map to selected_path=None, not an error. + for cmd in ( + ["zenity", "--file-selection", "--directory"], + ["kdialog", "--getexistingdirectory", os.path.expanduser("~")], + ): + try: + result = subprocess.run(cmd, capture_output=True, text=True) + except FileNotFoundError: + continue + if result.returncode != 0: + return {"selected_path": None} + return {"selected_path": result.stdout.strip()} + raise DialogUnavailableError("neither zenity nor kdialog is installed") + + class _Handler(BaseHTTPRequestHandler): def do_GET(self): entry = _STATIC_FILES.get(self.path) @@ -66,6 +119,8 @@ class _Handler(BaseHTTPRequestHandler): def do_POST(self): if self.path == "/api/system/pick-folder": self._handle_pick_folder() + elif self.path == "/api/projects": + self._handle_project_create() else: self._send_json(404, {"error": "NOT_FOUND"}) @@ -75,6 +130,34 @@ class _Handler(BaseHTTPRequestHandler): except DialogUnavailableError as e: self._send_json(422, {"error": "DIALOG_UNAVAILABLE", "detail": str(e)}) + def _handle_project_create(self): + length = int(self.headers.get("Content-Length", 0)) + try: + payload = json.loads(self.rfile.read(length) or b"{}") + except json.JSONDecodeError: + self._send_json(400, {"error": "INVALID_INPUT", "detail": "body is not valid JSON"}) + return + target_folder_path = payload.get("target_folder_path") + if not target_folder_path: + self._send_json(400, {"error": "INVALID_INPUT", "detail": "target_folder_path is required"}) + return + try: + with self.server.conn_lock: + result = project_scaffold.create_project( + self.server.conn, + target_folder_path, + name=payload.get("name"), + init_git_if_missing=bool(payload.get("init_git_if_missing", False)), + ) + except project_scaffold.PathNotFoundError as e: + self._send_json(404, {"error": "PATH_NOT_FOUND", "detail": str(e)}) + except project_scaffold.PathNotWritableError as e: + self._send_json(403, {"error": "PATH_NOT_WRITABLE", "detail": str(e)}) + except project_scaffold.GitInitDeclinedError as e: + self._send_json(409, {"error": "GIT_INIT_DECLINED", "detail": str(e)}) + else: + self._send_json(200, result) + def _send_json(self, status, obj): body = json.dumps(obj).encode("utf-8") self.send_response(status) @@ -87,7 +170,30 @@ class _Handler(BaseHTTPRequestHandler): pass # quiets test output; real request logging is a later Unit's concern -def create_server(host="127.0.0.1", port=0): +def _ensure_schema(conn): + exists = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'project'" + ).fetchone() + if exists is None: + schema.create_schema(conn) + + +class _Server(ThreadingHTTPServer): + def server_close(self): + super().server_close() + self.conn.close() + + +def create_server(host="127.0.0.1", port=0, db_path=None): """port=0 lets the OS assign a free port -- avoids collisions in tests - and lets multiple graphlemon instances run side by side if ever needed.""" - return ThreadingHTTPServer((host, port), _Handler) + and lets multiple graphlemon instances run side by side if ever needed. + + db_path defaults to paths.db_path() (U-003's platform_home, so it never + lives inside a target project's own repo); tests pass ":memory:" or a + tempfile so runs don't touch the real developer's ~/.graphlemon/.""" + conn = schema.connect(db_path if db_path is not None else paths.db_path(), check_same_thread=False) + _ensure_schema(conn) + server = _Server((host, port), _Handler) + server.conn = conn + server.conn_lock = threading.Lock() + return server diff --git a/src/paths.py b/src/paths.py index 835fccc..13baa15 100644 --- a/src/paths.py +++ b/src/paths.py @@ -26,6 +26,12 @@ def log_dir(): return os.path.join(platform_home(), "logs") +def db_path(): + """graphlemon's own sqlite database file (U-001 schema), same + GRAPHLEMON_HOME-overridable root as everything else in this module.""" + return os.path.join(platform_home(), "graphlemon.db") + + def log_file_path(project_id, task_id, run_id, node_exec_id): """01-M 1.13 execution_log_file.file_path convention.""" return os.path.join(log_dir(), project_id, task_id, run_id, node_exec_id + ".jsonl") diff --git a/src/schema.py b/src/schema.py index 48cea2c..2d7104b 100644 --- a/src/schema.py +++ b/src/schema.py @@ -271,13 +271,19 @@ CREATE TABLE log_retention_config ( """ -def connect(db_path=":memory:"): +def connect(db_path=":memory:", check_same_thread=True): """Open a sqlite3 connection with foreign-key enforcement turned on. SQLite disables FK enforcement by default per-connection; every caller needs this pragma, so it lives here rather than being repeated at every call site. + + check_same_thread=False is for callers like backend_server.py that share + one connection across a ThreadingHTTPServer's per-request threads -- the + caller is then responsible for serializing access itself (a lock), since + turning this off only disables sqlite3's own thread-identity check, it + doesn't make concurrent use of one connection object safe by itself. """ - conn = sqlite3.connect(db_path) + conn = sqlite3.connect(db_path, check_same_thread=check_same_thread) conn.execute("PRAGMA foreign_keys = ON") return conn diff --git a/src/test_backend_server.py b/src/test_backend_server.py index fb709b1..c74cec7 100644 --- a/src/test_backend_server.py +++ b/src/test_backend_server.py @@ -9,8 +9,10 @@ import ast import http.client import json import os +import shutil import subprocess import sys +import tempfile import threading import unittest from unittest.mock import patch @@ -23,7 +25,7 @@ class ServerStartStopTest(unittest.TestCase): # deliberately hits an unrelated route, not /api/system/pick-folder -- # that one would pop a real, unmocked osascript dialog on this machine # and hang waiting for a human who isn't there. - server = backend_server.create_server() + server = backend_server.create_server(db_path=":memory:") thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: @@ -41,7 +43,7 @@ class ServerStartStopTest(unittest.TestCase): self.assertFalse(thread.is_alive()) def test_unknown_route_returns_404(self): - server = backend_server.create_server() + server = backend_server.create_server(db_path=":memory:") thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: @@ -67,7 +69,7 @@ class StaticFileServingTest(unittest.TestCase): engine, so this only covers what the server is responsible for.""" def setUp(self): - self.server = backend_server.create_server() + self.server = backend_server.create_server(db_path=":memory:") self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() @@ -132,11 +134,6 @@ class PickFolderTest(unittest.TestCase): result = backend_server.pick_folder() self.assertEqual(result, {"selected_path": None}) - def test_non_macos_raises_dialog_unavailable(self): - with patch("src.backend_server.platform.system", return_value="Linux"): - with self.assertRaises(backend_server.DialogUnavailableError): - backend_server.pick_folder() - def test_osascript_failure_other_than_cancel_raises_dialog_unavailable(self): with patch("src.backend_server.platform.system", return_value="Darwin"), \ patch("src.backend_server.subprocess.run") as run: @@ -146,10 +143,71 @@ class PickFolderTest(unittest.TestCase): with self.assertRaises(backend_server.DialogUnavailableError): backend_server.pick_folder() + def test_windows_success_returns_selected_path(self): + with patch("src.backend_server.platform.system", return_value="Windows"), \ + patch("src.backend_server.subprocess.run") as run: + run.return_value = subprocess.CompletedProcess( + args=["powershell"], returncode=0, stdout="C:\\Users\\x\\some-project\r\n", stderr="" + ) + result = backend_server.pick_folder() + self.assertEqual(result, {"selected_path": "C:\\Users\\x\\some-project"}) + + def test_windows_user_cancel_returns_null_path(self): + with patch("src.backend_server.platform.system", return_value="Windows"), \ + patch("src.backend_server.subprocess.run") as run: + run.return_value = subprocess.CompletedProcess( + args=["powershell"], returncode=0, stdout="", stderr="" + ) + result = backend_server.pick_folder() + self.assertEqual(result, {"selected_path": None}) + + def test_windows_missing_powershell_raises_dialog_unavailable(self): + with patch("src.backend_server.platform.system", return_value="Windows"), \ + patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("no powershell")): + with self.assertRaises(backend_server.DialogUnavailableError): + backend_server.pick_folder() + + def test_linux_zenity_success_returns_selected_path(self): + with patch("src.backend_server.platform.system", return_value="Linux"), \ + patch("src.backend_server.subprocess.run") as run: + run.return_value = subprocess.CompletedProcess( + args=["zenity"], returncode=0, stdout="/home/x/some-project\n", stderr="" + ) + result = backend_server.pick_folder() + self.assertEqual(result, {"selected_path": "/home/x/some-project"}) + run.assert_called_once() + self.assertEqual(run.call_args[0][0][0], "zenity") + + def test_linux_zenity_cancel_returns_null_path(self): + with patch("src.backend_server.platform.system", return_value="Linux"), \ + patch("src.backend_server.subprocess.run") as run: + run.return_value = subprocess.CompletedProcess( + args=["zenity"], returncode=1, stdout="", stderr="" + ) + result = backend_server.pick_folder() + self.assertEqual(result, {"selected_path": None}) + + def test_linux_falls_back_to_kdialog_when_zenity_missing(self): + with patch("src.backend_server.platform.system", return_value="Linux"), \ + patch("src.backend_server.subprocess.run") as run: + run.side_effect = [ + FileNotFoundError("no zenity"), + subprocess.CompletedProcess(args=["kdialog"], returncode=0, stdout="/home/x/proj\n", stderr=""), + ] + result = backend_server.pick_folder() + self.assertEqual(result, {"selected_path": "/home/x/proj"}) + self.assertEqual(run.call_count, 2) + + def test_linux_neither_zenity_nor_kdialog_raises_dialog_unavailable(self): + with patch("src.backend_server.platform.system", return_value="Linux"), \ + patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("not found")): + with self.assertRaises(backend_server.DialogUnavailableError): + backend_server.pick_folder() + class PickFolderRouteTest(unittest.TestCase): def setUp(self): - self.server = backend_server.create_server() + self.server = backend_server.create_server(db_path=":memory:") self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) self.thread.start() @@ -177,13 +235,84 @@ class PickFolderRouteTest(unittest.TestCase): self.assertEqual(status, 200) self.assertEqual(body, {"selected_path": "/tmp/chosen"}) - def test_route_returns_422_dialog_unavailable_off_macos(self): - with patch("src.backend_server.platform.system", return_value="Linux"): + def test_route_returns_422_dialog_unavailable_when_no_dialog_tool_present(self): + with patch("src.backend_server.platform.system", return_value="Linux"), \ + patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("not found")): status, body = self._post("/api/system/pick-folder") self.assertEqual(status, 422) self.assertEqual(body["error"], "DIALOG_UNAVAILABLE") +class ProjectCreateRouteTest(unittest.TestCase): + """c_project_create (03-C §3.0) wired to a real HTTP route for the first + time in U-022 -- project_scaffold.create_project() itself was already + fully implemented and tested in U-002, so this only exercises the + request/response/error-code plumbing, not the scaffolding logic again.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True) + self.server = backend_server.create_server(db_path=":memory:") + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + def _post(self, path, payload): + host, port = self.server.server_address + conn = http.client.HTTPConnection(host, port, timeout=5) + conn.request("POST", path, body=json.dumps(payload).encode("utf-8")) + resp = conn.getresponse() + body = json.loads(resp.read()) + conn.close() + return resp.status, body + + def test_creates_project_for_existing_git_repo(self): + status, body = self._post("/api/projects", {"target_folder_path": self.tmp.name}) + self.assertEqual(status, 200) + self.assertTrue(body["has_git_repo"]) + self.assertTrue(body["graphlemon_subdir_initialized"]) + self.assertTrue(os.path.isdir(os.path.join(self.tmp.name, ".graphlemon"))) + + def test_repeat_call_is_idempotent(self): + status1, body1 = self._post("/api/projects", {"target_folder_path": self.tmp.name}) + status2, body2 = self._post("/api/projects", {"target_folder_path": self.tmp.name}) + self.assertEqual(status1, 200) + self.assertEqual(status2, 200) + self.assertEqual(body1["project_id"], body2["project_id"]) + + def test_missing_target_folder_path_returns_400(self): + status, body = self._post("/api/projects", {}) + self.assertEqual(status, 400) + self.assertEqual(body["error"], "INVALID_INPUT") + + def test_nonexistent_path_returns_404(self): + status, body = self._post("/api/projects", {"target_folder_path": "/no/such/path/at/all"}) + self.assertEqual(status, 404) + self.assertEqual(body["error"], "PATH_NOT_FOUND") + + def test_non_git_folder_without_confirmation_returns_409(self): + non_git = tempfile.mkdtemp() + self.addCleanup(lambda: shutil.rmtree(non_git, ignore_errors=True)) + status, body = self._post("/api/projects", {"target_folder_path": non_git}) + self.assertEqual(status, 409) + self.assertEqual(body["error"], "GIT_INIT_DECLINED") + + def test_non_git_folder_with_confirmation_initializes_git(self): + non_git = tempfile.mkdtemp() + self.addCleanup(lambda: shutil.rmtree(non_git, ignore_errors=True)) + status, body = self._post( + "/api/projects", {"target_folder_path": non_git, "init_git_if_missing": True} + ) + self.assertEqual(status, 200) + self.assertTrue(body["has_git_repo"]) + self.assertTrue(os.path.isdir(os.path.join(non_git, ".git"))) + + class ZeroThirdPartyDependencyTest(unittest.TestCase): """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this doesn't silently rot if someone adds an import later.""" @@ -202,7 +331,10 @@ class ZeroThirdPartyDependencyTest(unittest.TestCase): top_level_names.add(node.module.split(".")[0]) self.assertTrue(top_level_names, "expected at least one import to check") - non_stdlib = {name for name in top_level_names if name not in sys.stdlib_module_names} + # "src" is graphlemon's own package (e.g. project_scaffold/schema/paths, + # wired in U-022) -- not a third-party dependency, so it's allowed; + # everything else must be in the standard library. + non_stdlib = {name for name in top_level_names if name not in sys.stdlib_module_names and name != "src"} self.assertEqual(non_stdlib, set(), "non-stdlib imports found: {}".format(non_stdlib)) -- libgit2 0.21.4