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)
+
+
+
+
+
+
+
+
+
+
+ 该文件夹还不是 Git 仓库,是否代为初始化(
git init)?确认前不写入任何文件。
+
+
+
+
+
+
+
+ `;
+}
+
+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))