Commit 231cf0c6ba57c9e8efbe02f46b44ee1fe640ec63
1 parent
15385d60
U-022: v_project_setup + c_pick_folder 全平台补齐
c_pick_folder 补上 Windows(PowerShell FolderBrowserDialog)和 Linux (zenity 优先、回退 kdialog)实现,三平台都覆盖到。c_project_create (U-002 已实现的 project_scaffold.create_project())第一次接上 HTTP 路由 POST /api/projects——backend_server.py 因此第一次需要持有 sqlite 连接,schema.connect() 加 check_same_thread=False 参数,配合 server.conn_lock 序列化 ThreadingHTTPServer 各请求线程的访问。 frontend/app.js 补上 #/setup 真实交互(选文件夹→校验 Git 仓库→非 Git 仓库二次确认→创建→跳转仪表盘),DOM class 命名对齐 docs/wireframes/v_project_setup.html。 REQ-11 完整 happy 用例(含创建 task)还需要 U-023 的 v_goal_composer/c_task_create,本 Unit 只完成 v_project_setup/ c_pick_folder/c_project_create 部分,development-plan.md 已记录。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
8 changed files
with
412 additions
and
42 deletions
docs/development-plan.md
| @@ -217,6 +217,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | @@ -217,6 +217,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | ||
| 217 | - **U-022 [Value Path]** `v_project_setup` + `c_pick_folder` 全平台补齐(Windows `PowerShell`/Linux `zenity`/`kdialog`) | 217 | - **U-022 [Value Path]** `v_project_setup` + `c_pick_folder` 全平台补齐(Windows `PowerShell`/Linux `zenity`/`kdialog`) |
| 218 | - 依赖:U-007, U-008 | 218 | - 依赖:U-007, U-008 |
| 219 | - Done:REQ-11 happy/boundary/failure 通过 | 219 | - Done:REQ-11 happy/boundary/failure 通过 |
| 220 | + - **实现补充(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` 语法),不是真人点击浏览器验证过的。 | ||
| 220 | - 覆盖:02-V `v_project_setup`;03-C `c_pick_folder`;05 节 §5.4.1;REQ-11 | 221 | - 覆盖:02-V `v_project_setup`;03-C `c_pick_folder`;05 节 §5.4.1;REQ-11 |
| 221 | 222 | ||
| 222 | - **U-023 [Value Path]** `v_goal_composer` + `v_bug_feedback` | 223 | - **U-023 [Value Path]** `v_goal_composer` + `v_bug_feedback` |
docs/sdd/unit_matrix.json
| @@ -36,7 +36,7 @@ | @@ -36,7 +36,7 @@ | ||
| 36 | {"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 类型"}, | 36 | {"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 类型"}, |
| 37 | {"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() 返回未完成节点列表"}, | 37 | {"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() 返回未完成节点列表"}, |
| 38 | 38 | ||
| 39 | - {"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 全部通过"}, | 39 | + {"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 累计满足"}, |
| 40 | {"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"}, | 40 | {"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"}, |
| 41 | {"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)"}, | 41 | {"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)"}, |
| 42 | {"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 为准"}, | 42 | {"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 为准"}, |
frontend/app.js
| 1 | -// U-008: empty-shell client-side router. Hash-based (no History API / server | ||
| 2 | -// catch-all needed) -- real page content is PH3 (U-022+), this just proves | ||
| 3 | -// navigation swaps content without a page reload or any build step. | 1 | +// U-008: hash-based client-side router (no History API / server catch-all |
| 2 | +// needed). U-022 adds the first real page, #/setup (v_project_setup, 02-V | ||
| 3 | +// §2.1) -- everything else is still a PH3 placeholder. | ||
| 4 | 4 | ||
| 5 | const routes = { | 5 | const routes = { |
| 6 | - "#/": () => "<h1>Dashboard</h1><p>placeholder -- real content is PH3 (v_dashboard, U-025)</p>", | ||
| 7 | - "#/tasks": () => "<h1>Tasks</h1><p>placeholder -- real content is PH3 (v_task_board, U-025)</p>", | ||
| 8 | - "#/settings": () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>", | 6 | + "#/": { render: () => "<h1>Dashboard</h1><p>placeholder -- real content is PH3 (v_dashboard, U-025)</p>" }, |
| 7 | + "#/tasks": { render: () => "<h1>Tasks</h1><p>placeholder -- real content is PH3 (v_task_board, U-025)</p>" }, | ||
| 8 | + "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" }, | ||
| 9 | + "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, | ||
| 9 | }; | 10 | }; |
| 10 | 11 | ||
| 11 | function render() { | 12 | function render() { |
| 12 | const hash = window.location.hash || "#/"; | 13 | const hash = window.location.hash || "#/"; |
| 13 | - const view = routes[hash] || (() => "<h1>Not found</h1><p>" + hash + "</p>"); | ||
| 14 | - document.getElementById("app").innerHTML = view(); | 14 | + const route = routes[hash] || { render: () => "<h1>Not found</h1><p>" + hash + "</p>" }; |
| 15 | + document.getElementById("app").innerHTML = route.render(); | ||
| 16 | + if (route.mount) route.mount(); | ||
| 15 | 17 | ||
| 16 | for (const a of document.querySelectorAll("#nav a")) { | 18 | for (const a of document.querySelectorAll("#nav a")) { |
| 17 | a.classList.toggle("active", a.getAttribute("href") === hash); | 19 | a.classList.toggle("active", a.getAttribute("href") === hash); |
| @@ -20,3 +22,103 @@ function render() { | @@ -20,3 +22,103 @@ function render() { | ||
| 20 | 22 | ||
| 21 | window.addEventListener("hashchange", render); | 23 | window.addEventListener("hashchange", render); |
| 22 | window.addEventListener("DOMContentLoaded", render); | 24 | window.addEventListener("DOMContentLoaded", render); |
| 25 | + | ||
| 26 | +// ---- v_project_setup (02-V §2.1): create/switch project ---- | ||
| 27 | + | ||
| 28 | +function renderProjectSetup() { | ||
| 29 | + return ` | ||
| 30 | + <div class="top-back"><a href="#/">← 返回仪表盘</a></div> | ||
| 31 | + <div class="page"> | ||
| 32 | + <h1>创建 / 切换项目</h1> | ||
| 33 | + <div class="page-sub">选择一个本地文件夹作为目标项目的代码空间</div> | ||
| 34 | + <div class="panel"> | ||
| 35 | + <div id="setup_error" class="error-banner" style="display:none;"></div> | ||
| 36 | + <div class="field"> | ||
| 37 | + <label>目标文件夹(folder_path)</label> | ||
| 38 | + <input type="text" id="folder_path" readonly value=""> | ||
| 39 | + <div class="hint" id="folder_path_hint">点击"选择文件夹"会由后端代为弹出系统原生对话框,不支持手动输入路径(除非返回 DIALOG_UNAVAILABLE)</div> | ||
| 40 | + </div> | ||
| 41 | + <button class="btn" id="btn_pick_folder" type="button">📁 选择文件夹</button> | ||
| 42 | + <div class="field" style="margin-top:20px;"> | ||
| 43 | + <label>项目名称(project_name,非必填)</label> | ||
| 44 | + <input type="text" id="project_name" placeholder="留空则取文件夹名"> | ||
| 45 | + </div> | ||
| 46 | + <div class="btn-row"> | ||
| 47 | + <button class="btn btn-primary" id="btn_create" type="button">创建项目</button> | ||
| 48 | + </div> | ||
| 49 | + <div class="confirm-banner" id="confirm_banner" style="display:none;"> | ||
| 50 | + 该文件夹还不是 Git 仓库,是否代为初始化(<code>git init</code>)?确认前不写入任何文件。 | ||
| 51 | + <div class="btn-row" style="margin-top:10px;"> | ||
| 52 | + <button class="btn" id="btn_cancel_init" type="button">取消</button> | ||
| 53 | + <button class="btn btn-primary" id="btn_confirm_init" type="button">确认初始化并创建项目</button> | ||
| 54 | + </div> | ||
| 55 | + </div> | ||
| 56 | + </div> | ||
| 57 | + </div> | ||
| 58 | + `; | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +function mountProjectSetup() { | ||
| 62 | + const folderInput = document.getElementById("folder_path"); | ||
| 63 | + const folderHint = document.getElementById("folder_path_hint"); | ||
| 64 | + const nameInput = document.getElementById("project_name"); | ||
| 65 | + const errorBox = document.getElementById("setup_error"); | ||
| 66 | + const confirmBanner = document.getElementById("confirm_banner"); | ||
| 67 | + | ||
| 68 | + function showError(message) { | ||
| 69 | + errorBox.textContent = message; | ||
| 70 | + errorBox.style.display = "block"; | ||
| 71 | + } | ||
| 72 | + function clearError() { | ||
| 73 | + errorBox.style.display = "none"; | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + document.getElementById("btn_pick_folder").addEventListener("click", async () => { | ||
| 77 | + clearError(); | ||
| 78 | + const resp = await fetch("/api/system/pick-folder", { method: "POST", body: "{}" }); | ||
| 79 | + const body = await resp.json(); | ||
| 80 | + if (resp.status === 422 && body.error === "DIALOG_UNAVAILABLE") { | ||
| 81 | + folderInput.removeAttribute("readonly"); | ||
| 82 | + folderHint.textContent = "当前环境不支持弹窗选择,请手动输入路径"; | ||
| 83 | + return; | ||
| 84 | + } | ||
| 85 | + if (body.selected_path) { | ||
| 86 | + folderInput.value = body.selected_path; | ||
| 87 | + } | ||
| 88 | + // user canceled the native dialog -> folder_path stays as it was (02-V §2.1) | ||
| 89 | + }); | ||
| 90 | + | ||
| 91 | + async function createProject(initGitIfMissing) { | ||
| 92 | + clearError(); | ||
| 93 | + confirmBanner.style.display = "none"; | ||
| 94 | + const folderPath = folderInput.value.trim(); | ||
| 95 | + if (!folderPath) { | ||
| 96 | + showError("请先选择目标文件夹"); | ||
| 97 | + return; | ||
| 98 | + } | ||
| 99 | + const resp = await fetch("/api/projects", { | ||
| 100 | + method: "POST", | ||
| 101 | + body: JSON.stringify({ | ||
| 102 | + target_folder_path: folderPath, | ||
| 103 | + name: nameInput.value.trim() || undefined, | ||
| 104 | + init_git_if_missing: initGitIfMissing, | ||
| 105 | + }), | ||
| 106 | + }); | ||
| 107 | + const body = await resp.json(); | ||
| 108 | + if (resp.status === 200) { | ||
| 109 | + window.location.hash = "#/"; | ||
| 110 | + return; | ||
| 111 | + } | ||
| 112 | + if (body.error === "GIT_INIT_DECLINED") { | ||
| 113 | + confirmBanner.style.display = "block"; | ||
| 114 | + return; | ||
| 115 | + } | ||
| 116 | + showError(body.error + (body.detail ? ":" + body.detail : "")); | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + document.getElementById("btn_create").addEventListener("click", () => createProject(false)); | ||
| 120 | + document.getElementById("btn_confirm_init").addEventListener("click", () => createProject(true)); | ||
| 121 | + document.getElementById("btn_cancel_init").addEventListener("click", () => { | ||
| 122 | + confirmBanner.style.display = "none"; | ||
| 123 | + }); | ||
| 124 | +} |
frontend/style.css
| @@ -5,3 +5,20 @@ body { font-family: system-ui, sans-serif; margin: 0; } | @@ -5,3 +5,20 @@ body { font-family: system-ui, sans-serif; margin: 0; } | ||
| 5 | #nav a { text-decoration: none; color: #333; } | 5 | #nav a { text-decoration: none; color: #333; } |
| 6 | #nav a.active { font-weight: bold; } | 6 | #nav a.active { font-weight: bold; } |
| 7 | #app { padding: 1rem; } | 7 | #app { padding: 1rem; } |
| 8 | + | ||
| 9 | +/* U-022: v_project_setup -- class names match docs/wireframes/v_project_setup.html */ | ||
| 10 | +.top-back a { color: #6b7280; text-decoration: none; } | ||
| 11 | +.page { padding: 28px; max-width: 560px; margin: 0 auto; } | ||
| 12 | +.page h1 { font-size: 22px; margin: 0 0 4px; } | ||
| 13 | +.page .page-sub { color: #6b7280; font-size: 13px; margin-bottom: 28px; } | ||
| 14 | +.panel { border: 1px solid #e5e7eb; border-radius: 10px; padding: 24px; background: #f9fafb; } | ||
| 15 | +.field { margin-bottom: 18px; } | ||
| 16 | +.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; } | ||
| 17 | +.field input { width: 100%; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; } | ||
| 18 | +.field input[readonly] { background: #f3f4f6; color: #6b7280; } | ||
| 19 | +.field .hint { font-size: 12px; color: #6b7280; margin-top: 5px; } | ||
| 20 | +.btn { display: inline-block; padding: 9px 16px; border-radius: 6px; border: 1px solid #e5e7eb; background: #fff; font-size: 14px; cursor: pointer; } | ||
| 21 | +.btn-primary { background: #4f46e5; border-color: #4f46e5; color: #fff; } | ||
| 22 | +.btn-row { display: flex; gap: 10px; margin-top: 22px; } | ||
| 23 | +.confirm-banner { background: #fffbeb; border: 1px solid #f59e0b; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-top: 16px; } | ||
| 24 | +.error-banner { background: #fef2f2; border: 1px solid #ef4444; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; } |
src/backend_server.py
| 1 | -"""Local backend process skeleton (U-007): pure stdlib `http.server`, no | ||
| 2 | -third-party deps (05 §5.7) -- this process is also where the Graph Engine | ||
| 3 | -scheduler will live later (05 §5.4.1), but that's a different Unit. | ||
| 4 | - | ||
| 5 | -MVP scope here: process start/stop, `c_pick_folder` (03-C §3.-1) as the one | ||
| 6 | -real API route -- macOS only for now (`osascript`); Windows/Linux native | ||
| 7 | -dialogs are PH3 (U-022) -- and serving the U-008 frontend shell as static | ||
| 8 | -files (no build step, 05 §5.7: whitelisted by exact path, not a generic | ||
| 9 | -directory walker, so there's no path-traversal surface to reason about). | 1 | +"""Local backend process skeleton (U-007, extended in U-022): pure stdlib |
| 2 | +`http.server`, no third-party deps (05 §5.7) -- this process is also where | ||
| 3 | +the Graph Engine scheduler will live later (05 §5.4.1), but that's a | ||
| 4 | +different Unit. | ||
| 5 | + | ||
| 6 | +MVP scope: process start/stop, `c_pick_folder` (03-C §3.-1, now all three | ||
| 7 | +platforms -- U-007 shipped macOS only via `osascript`; U-022 adds Windows | ||
| 8 | +via a `powershell` FolderBrowserDialog script and Linux via `zenity`/ | ||
| 9 | +`kdialog`, whichever is installed), `c_project_create` (03-C §3.0, wired to | ||
| 10 | +the U-002 `project_scaffold.create_project()` function that had no HTTP | ||
| 11 | +route until now) for `v_project_setup`'s submit flow, and serving the U-008 | ||
| 12 | +frontend shell as static files (no build step, 05 §5.7: whitelisted by exact | ||
| 13 | +path, not a generic directory walker, so there's no path-traversal surface | ||
| 14 | +to reason about). | ||
| 10 | """ | 15 | """ |
| 11 | 16 | ||
| 12 | import json | 17 | import json |
| 13 | import os | 18 | import os |
| 14 | import platform | 19 | import platform |
| 15 | import subprocess | 20 | import subprocess |
| 21 | +import threading | ||
| 16 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | 22 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 17 | 23 | ||
| 24 | +from src import paths, project_scaffold, schema | ||
| 25 | + | ||
| 18 | _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") | 26 | _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") |
| 19 | 27 | ||
| 20 | # U-008: exact-path whitelist, not a generic static-file server -- the | 28 | # U-008: exact-path whitelist, not a generic static-file server -- the |
| @@ -32,13 +40,19 @@ class DialogUnavailableError(Exception): | @@ -32,13 +40,19 @@ class DialogUnavailableError(Exception): | ||
| 32 | 40 | ||
| 33 | 41 | ||
| 34 | def pick_folder(): | 42 | def pick_folder(): |
| 35 | - """macOS implementation of c_pick_folder. Returns {"selected_path": str} | ||
| 36 | - on success, {"selected_path": None} on user cancel (03-C: not an error). | ||
| 37 | - Raises DialogUnavailableError off macOS or if osascript itself fails for | ||
| 38 | - a reason other than the user cancelling.""" | ||
| 39 | - if platform.system() != "Darwin": | ||
| 40 | - raise DialogUnavailableError("native folder picker only implemented for macOS so far (PH3 adds Windows/Linux)") | ||
| 41 | - | 43 | + """c_pick_folder (03-C §3.-1), all three platforms. Returns |
| 44 | + {"selected_path": str} on success, {"selected_path": None} on user | ||
| 45 | + cancel (not an error). Raises DialogUnavailableError if the platform's | ||
| 46 | + native dialog tool itself fails or isn't installed.""" | ||
| 47 | + system = platform.system() | ||
| 48 | + if system == "Darwin": | ||
| 49 | + return _pick_folder_macos() | ||
| 50 | + if system == "Windows": | ||
| 51 | + return _pick_folder_windows() | ||
| 52 | + return _pick_folder_linux() | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +def _pick_folder_macos(): | ||
| 42 | script = 'POSIX path of (choose folder with prompt "Select a graphlemon project folder")' | 56 | script = 'POSIX path of (choose folder with prompt "Select a graphlemon project folder")' |
| 43 | result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True) | 57 | result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True) |
| 44 | if result.returncode != 0: | 58 | if result.returncode != 0: |
| @@ -48,6 +62,45 @@ def pick_folder(): | @@ -48,6 +62,45 @@ def pick_folder(): | ||
| 48 | return {"selected_path": result.stdout.strip()} | 62 | return {"selected_path": result.stdout.strip()} |
| 49 | 63 | ||
| 50 | 64 | ||
| 65 | +def _pick_folder_windows(): | ||
| 66 | + # .NET FolderBrowserDialog via a one-shot PowerShell script -- no | ||
| 67 | + # cancel-vs-error signal other than empty stdout on a clean exit. | ||
| 68 | + script = ( | ||
| 69 | + "Add-Type -AssemblyName System.Windows.Forms; " | ||
| 70 | + "$f = New-Object System.Windows.Forms.FolderBrowserDialog; " | ||
| 71 | + "if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $f.SelectedPath }" | ||
| 72 | + ) | ||
| 73 | + try: | ||
| 74 | + result = subprocess.run( | ||
| 75 | + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], | ||
| 76 | + capture_output=True, text=True, | ||
| 77 | + ) | ||
| 78 | + except FileNotFoundError as e: | ||
| 79 | + raise DialogUnavailableError(str(e)) | ||
| 80 | + if result.returncode != 0: | ||
| 81 | + raise DialogUnavailableError(result.stderr.strip()) | ||
| 82 | + return {"selected_path": result.stdout.strip() or None} | ||
| 83 | + | ||
| 84 | + | ||
| 85 | +def _pick_folder_linux(): | ||
| 86 | + # zenity (GNOME) and kdialog (KDE) are the two dialogs actually present | ||
| 87 | + # across mainstream Linux desktops; try whichever exists. Both exit | ||
| 88 | + # non-zero on user cancel, indistinguishable here from "no folder | ||
| 89 | + # chosen" -- both cases map to selected_path=None, not an error. | ||
| 90 | + for cmd in ( | ||
| 91 | + ["zenity", "--file-selection", "--directory"], | ||
| 92 | + ["kdialog", "--getexistingdirectory", os.path.expanduser("~")], | ||
| 93 | + ): | ||
| 94 | + try: | ||
| 95 | + result = subprocess.run(cmd, capture_output=True, text=True) | ||
| 96 | + except FileNotFoundError: | ||
| 97 | + continue | ||
| 98 | + if result.returncode != 0: | ||
| 99 | + return {"selected_path": None} | ||
| 100 | + return {"selected_path": result.stdout.strip()} | ||
| 101 | + raise DialogUnavailableError("neither zenity nor kdialog is installed") | ||
| 102 | + | ||
| 103 | + | ||
| 51 | class _Handler(BaseHTTPRequestHandler): | 104 | class _Handler(BaseHTTPRequestHandler): |
| 52 | def do_GET(self): | 105 | def do_GET(self): |
| 53 | entry = _STATIC_FILES.get(self.path) | 106 | entry = _STATIC_FILES.get(self.path) |
| @@ -66,6 +119,8 @@ class _Handler(BaseHTTPRequestHandler): | @@ -66,6 +119,8 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 66 | def do_POST(self): | 119 | def do_POST(self): |
| 67 | if self.path == "/api/system/pick-folder": | 120 | if self.path == "/api/system/pick-folder": |
| 68 | self._handle_pick_folder() | 121 | self._handle_pick_folder() |
| 122 | + elif self.path == "/api/projects": | ||
| 123 | + self._handle_project_create() | ||
| 69 | else: | 124 | else: |
| 70 | self._send_json(404, {"error": "NOT_FOUND"}) | 125 | self._send_json(404, {"error": "NOT_FOUND"}) |
| 71 | 126 | ||
| @@ -75,6 +130,34 @@ class _Handler(BaseHTTPRequestHandler): | @@ -75,6 +130,34 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 75 | except DialogUnavailableError as e: | 130 | except DialogUnavailableError as e: |
| 76 | self._send_json(422, {"error": "DIALOG_UNAVAILABLE", "detail": str(e)}) | 131 | self._send_json(422, {"error": "DIALOG_UNAVAILABLE", "detail": str(e)}) |
| 77 | 132 | ||
| 133 | + def _handle_project_create(self): | ||
| 134 | + length = int(self.headers.get("Content-Length", 0)) | ||
| 135 | + try: | ||
| 136 | + payload = json.loads(self.rfile.read(length) or b"{}") | ||
| 137 | + except json.JSONDecodeError: | ||
| 138 | + self._send_json(400, {"error": "INVALID_INPUT", "detail": "body is not valid JSON"}) | ||
| 139 | + return | ||
| 140 | + target_folder_path = payload.get("target_folder_path") | ||
| 141 | + if not target_folder_path: | ||
| 142 | + self._send_json(400, {"error": "INVALID_INPUT", "detail": "target_folder_path is required"}) | ||
| 143 | + return | ||
| 144 | + try: | ||
| 145 | + with self.server.conn_lock: | ||
| 146 | + result = project_scaffold.create_project( | ||
| 147 | + self.server.conn, | ||
| 148 | + target_folder_path, | ||
| 149 | + name=payload.get("name"), | ||
| 150 | + init_git_if_missing=bool(payload.get("init_git_if_missing", False)), | ||
| 151 | + ) | ||
| 152 | + except project_scaffold.PathNotFoundError as e: | ||
| 153 | + self._send_json(404, {"error": "PATH_NOT_FOUND", "detail": str(e)}) | ||
| 154 | + except project_scaffold.PathNotWritableError as e: | ||
| 155 | + self._send_json(403, {"error": "PATH_NOT_WRITABLE", "detail": str(e)}) | ||
| 156 | + except project_scaffold.GitInitDeclinedError as e: | ||
| 157 | + self._send_json(409, {"error": "GIT_INIT_DECLINED", "detail": str(e)}) | ||
| 158 | + else: | ||
| 159 | + self._send_json(200, result) | ||
| 160 | + | ||
| 78 | def _send_json(self, status, obj): | 161 | def _send_json(self, status, obj): |
| 79 | body = json.dumps(obj).encode("utf-8") | 162 | body = json.dumps(obj).encode("utf-8") |
| 80 | self.send_response(status) | 163 | self.send_response(status) |
| @@ -87,7 +170,30 @@ class _Handler(BaseHTTPRequestHandler): | @@ -87,7 +170,30 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 87 | pass # quiets test output; real request logging is a later Unit's concern | 170 | pass # quiets test output; real request logging is a later Unit's concern |
| 88 | 171 | ||
| 89 | 172 | ||
| 90 | -def create_server(host="127.0.0.1", port=0): | 173 | +def _ensure_schema(conn): |
| 174 | + exists = conn.execute( | ||
| 175 | + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'project'" | ||
| 176 | + ).fetchone() | ||
| 177 | + if exists is None: | ||
| 178 | + schema.create_schema(conn) | ||
| 179 | + | ||
| 180 | + | ||
| 181 | +class _Server(ThreadingHTTPServer): | ||
| 182 | + def server_close(self): | ||
| 183 | + super().server_close() | ||
| 184 | + self.conn.close() | ||
| 185 | + | ||
| 186 | + | ||
| 187 | +def create_server(host="127.0.0.1", port=0, db_path=None): | ||
| 91 | """port=0 lets the OS assign a free port -- avoids collisions in tests | 188 | """port=0 lets the OS assign a free port -- avoids collisions in tests |
| 92 | - and lets multiple graphlemon instances run side by side if ever needed.""" | ||
| 93 | - return ThreadingHTTPServer((host, port), _Handler) | 189 | + and lets multiple graphlemon instances run side by side if ever needed. |
| 190 | + | ||
| 191 | + db_path defaults to paths.db_path() (U-003's platform_home, so it never | ||
| 192 | + lives inside a target project's own repo); tests pass ":memory:" or a | ||
| 193 | + tempfile so runs don't touch the real developer's ~/.graphlemon/.""" | ||
| 194 | + conn = schema.connect(db_path if db_path is not None else paths.db_path(), check_same_thread=False) | ||
| 195 | + _ensure_schema(conn) | ||
| 196 | + server = _Server((host, port), _Handler) | ||
| 197 | + server.conn = conn | ||
| 198 | + server.conn_lock = threading.Lock() | ||
| 199 | + return server |
src/paths.py
| @@ -26,6 +26,12 @@ def log_dir(): | @@ -26,6 +26,12 @@ def log_dir(): | ||
| 26 | return os.path.join(platform_home(), "logs") | 26 | return os.path.join(platform_home(), "logs") |
| 27 | 27 | ||
| 28 | 28 | ||
| 29 | +def db_path(): | ||
| 30 | + """graphlemon's own sqlite database file (U-001 schema), same | ||
| 31 | + GRAPHLEMON_HOME-overridable root as everything else in this module.""" | ||
| 32 | + return os.path.join(platform_home(), "graphlemon.db") | ||
| 33 | + | ||
| 34 | + | ||
| 29 | def log_file_path(project_id, task_id, run_id, node_exec_id): | 35 | def log_file_path(project_id, task_id, run_id, node_exec_id): |
| 30 | """01-M 1.13 execution_log_file.file_path convention.""" | 36 | """01-M 1.13 execution_log_file.file_path convention.""" |
| 31 | return os.path.join(log_dir(), project_id, task_id, run_id, node_exec_id + ".jsonl") | 37 | return os.path.join(log_dir(), project_id, task_id, run_id, node_exec_id + ".jsonl") |
src/schema.py
| @@ -271,13 +271,19 @@ CREATE TABLE log_retention_config ( | @@ -271,13 +271,19 @@ CREATE TABLE log_retention_config ( | ||
| 271 | """ | 271 | """ |
| 272 | 272 | ||
| 273 | 273 | ||
| 274 | -def connect(db_path=":memory:"): | 274 | +def connect(db_path=":memory:", check_same_thread=True): |
| 275 | """Open a sqlite3 connection with foreign-key enforcement turned on. | 275 | """Open a sqlite3 connection with foreign-key enforcement turned on. |
| 276 | 276 | ||
| 277 | SQLite disables FK enforcement by default per-connection; every caller needs | 277 | SQLite disables FK enforcement by default per-connection; every caller needs |
| 278 | this pragma, so it lives here rather than being repeated at every call site. | 278 | this pragma, so it lives here rather than being repeated at every call site. |
| 279 | + | ||
| 280 | + check_same_thread=False is for callers like backend_server.py that share | ||
| 281 | + one connection across a ThreadingHTTPServer's per-request threads -- the | ||
| 282 | + caller is then responsible for serializing access itself (a lock), since | ||
| 283 | + turning this off only disables sqlite3's own thread-identity check, it | ||
| 284 | + doesn't make concurrent use of one connection object safe by itself. | ||
| 279 | """ | 285 | """ |
| 280 | - conn = sqlite3.connect(db_path) | 286 | + conn = sqlite3.connect(db_path, check_same_thread=check_same_thread) |
| 281 | conn.execute("PRAGMA foreign_keys = ON") | 287 | conn.execute("PRAGMA foreign_keys = ON") |
| 282 | return conn | 288 | return conn |
| 283 | 289 |
src/test_backend_server.py
| @@ -9,8 +9,10 @@ import ast | @@ -9,8 +9,10 @@ import ast | ||
| 9 | import http.client | 9 | import http.client |
| 10 | import json | 10 | import json |
| 11 | import os | 11 | import os |
| 12 | +import shutil | ||
| 12 | import subprocess | 13 | import subprocess |
| 13 | import sys | 14 | import sys |
| 15 | +import tempfile | ||
| 14 | import threading | 16 | import threading |
| 15 | import unittest | 17 | import unittest |
| 16 | from unittest.mock import patch | 18 | from unittest.mock import patch |
| @@ -23,7 +25,7 @@ class ServerStartStopTest(unittest.TestCase): | @@ -23,7 +25,7 @@ class ServerStartStopTest(unittest.TestCase): | ||
| 23 | # deliberately hits an unrelated route, not /api/system/pick-folder -- | 25 | # deliberately hits an unrelated route, not /api/system/pick-folder -- |
| 24 | # that one would pop a real, unmocked osascript dialog on this machine | 26 | # that one would pop a real, unmocked osascript dialog on this machine |
| 25 | # and hang waiting for a human who isn't there. | 27 | # and hang waiting for a human who isn't there. |
| 26 | - server = backend_server.create_server() | 28 | + server = backend_server.create_server(db_path=":memory:") |
| 27 | thread = threading.Thread(target=server.serve_forever, daemon=True) | 29 | thread = threading.Thread(target=server.serve_forever, daemon=True) |
| 28 | thread.start() | 30 | thread.start() |
| 29 | try: | 31 | try: |
| @@ -41,7 +43,7 @@ class ServerStartStopTest(unittest.TestCase): | @@ -41,7 +43,7 @@ class ServerStartStopTest(unittest.TestCase): | ||
| 41 | self.assertFalse(thread.is_alive()) | 43 | self.assertFalse(thread.is_alive()) |
| 42 | 44 | ||
| 43 | def test_unknown_route_returns_404(self): | 45 | def test_unknown_route_returns_404(self): |
| 44 | - server = backend_server.create_server() | 46 | + server = backend_server.create_server(db_path=":memory:") |
| 45 | thread = threading.Thread(target=server.serve_forever, daemon=True) | 47 | thread = threading.Thread(target=server.serve_forever, daemon=True) |
| 46 | thread.start() | 48 | thread.start() |
| 47 | try: | 49 | try: |
| @@ -67,7 +69,7 @@ class StaticFileServingTest(unittest.TestCase): | @@ -67,7 +69,7 @@ class StaticFileServingTest(unittest.TestCase): | ||
| 67 | engine, so this only covers what the server is responsible for.""" | 69 | engine, so this only covers what the server is responsible for.""" |
| 68 | 70 | ||
| 69 | def setUp(self): | 71 | def setUp(self): |
| 70 | - self.server = backend_server.create_server() | 72 | + self.server = backend_server.create_server(db_path=":memory:") |
| 71 | self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) | 73 | self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) |
| 72 | self.thread.start() | 74 | self.thread.start() |
| 73 | 75 | ||
| @@ -132,11 +134,6 @@ class PickFolderTest(unittest.TestCase): | @@ -132,11 +134,6 @@ class PickFolderTest(unittest.TestCase): | ||
| 132 | result = backend_server.pick_folder() | 134 | result = backend_server.pick_folder() |
| 133 | self.assertEqual(result, {"selected_path": None}) | 135 | self.assertEqual(result, {"selected_path": None}) |
| 134 | 136 | ||
| 135 | - def test_non_macos_raises_dialog_unavailable(self): | ||
| 136 | - with patch("src.backend_server.platform.system", return_value="Linux"): | ||
| 137 | - with self.assertRaises(backend_server.DialogUnavailableError): | ||
| 138 | - backend_server.pick_folder() | ||
| 139 | - | ||
| 140 | def test_osascript_failure_other_than_cancel_raises_dialog_unavailable(self): | 137 | def test_osascript_failure_other_than_cancel_raises_dialog_unavailable(self): |
| 141 | with patch("src.backend_server.platform.system", return_value="Darwin"), \ | 138 | with patch("src.backend_server.platform.system", return_value="Darwin"), \ |
| 142 | patch("src.backend_server.subprocess.run") as run: | 139 | patch("src.backend_server.subprocess.run") as run: |
| @@ -146,10 +143,71 @@ class PickFolderTest(unittest.TestCase): | @@ -146,10 +143,71 @@ class PickFolderTest(unittest.TestCase): | ||
| 146 | with self.assertRaises(backend_server.DialogUnavailableError): | 143 | with self.assertRaises(backend_server.DialogUnavailableError): |
| 147 | backend_server.pick_folder() | 144 | backend_server.pick_folder() |
| 148 | 145 | ||
| 146 | + def test_windows_success_returns_selected_path(self): | ||
| 147 | + with patch("src.backend_server.platform.system", return_value="Windows"), \ | ||
| 148 | + patch("src.backend_server.subprocess.run") as run: | ||
| 149 | + run.return_value = subprocess.CompletedProcess( | ||
| 150 | + args=["powershell"], returncode=0, stdout="C:\\Users\\x\\some-project\r\n", stderr="" | ||
| 151 | + ) | ||
| 152 | + result = backend_server.pick_folder() | ||
| 153 | + self.assertEqual(result, {"selected_path": "C:\\Users\\x\\some-project"}) | ||
| 154 | + | ||
| 155 | + def test_windows_user_cancel_returns_null_path(self): | ||
| 156 | + with patch("src.backend_server.platform.system", return_value="Windows"), \ | ||
| 157 | + patch("src.backend_server.subprocess.run") as run: | ||
| 158 | + run.return_value = subprocess.CompletedProcess( | ||
| 159 | + args=["powershell"], returncode=0, stdout="", stderr="" | ||
| 160 | + ) | ||
| 161 | + result = backend_server.pick_folder() | ||
| 162 | + self.assertEqual(result, {"selected_path": None}) | ||
| 163 | + | ||
| 164 | + def test_windows_missing_powershell_raises_dialog_unavailable(self): | ||
| 165 | + with patch("src.backend_server.platform.system", return_value="Windows"), \ | ||
| 166 | + patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("no powershell")): | ||
| 167 | + with self.assertRaises(backend_server.DialogUnavailableError): | ||
| 168 | + backend_server.pick_folder() | ||
| 169 | + | ||
| 170 | + def test_linux_zenity_success_returns_selected_path(self): | ||
| 171 | + with patch("src.backend_server.platform.system", return_value="Linux"), \ | ||
| 172 | + patch("src.backend_server.subprocess.run") as run: | ||
| 173 | + run.return_value = subprocess.CompletedProcess( | ||
| 174 | + args=["zenity"], returncode=0, stdout="/home/x/some-project\n", stderr="" | ||
| 175 | + ) | ||
| 176 | + result = backend_server.pick_folder() | ||
| 177 | + self.assertEqual(result, {"selected_path": "/home/x/some-project"}) | ||
| 178 | + run.assert_called_once() | ||
| 179 | + self.assertEqual(run.call_args[0][0][0], "zenity") | ||
| 180 | + | ||
| 181 | + def test_linux_zenity_cancel_returns_null_path(self): | ||
| 182 | + with patch("src.backend_server.platform.system", return_value="Linux"), \ | ||
| 183 | + patch("src.backend_server.subprocess.run") as run: | ||
| 184 | + run.return_value = subprocess.CompletedProcess( | ||
| 185 | + args=["zenity"], returncode=1, stdout="", stderr="" | ||
| 186 | + ) | ||
| 187 | + result = backend_server.pick_folder() | ||
| 188 | + self.assertEqual(result, {"selected_path": None}) | ||
| 189 | + | ||
| 190 | + def test_linux_falls_back_to_kdialog_when_zenity_missing(self): | ||
| 191 | + with patch("src.backend_server.platform.system", return_value="Linux"), \ | ||
| 192 | + patch("src.backend_server.subprocess.run") as run: | ||
| 193 | + run.side_effect = [ | ||
| 194 | + FileNotFoundError("no zenity"), | ||
| 195 | + subprocess.CompletedProcess(args=["kdialog"], returncode=0, stdout="/home/x/proj\n", stderr=""), | ||
| 196 | + ] | ||
| 197 | + result = backend_server.pick_folder() | ||
| 198 | + self.assertEqual(result, {"selected_path": "/home/x/proj"}) | ||
| 199 | + self.assertEqual(run.call_count, 2) | ||
| 200 | + | ||
| 201 | + def test_linux_neither_zenity_nor_kdialog_raises_dialog_unavailable(self): | ||
| 202 | + with patch("src.backend_server.platform.system", return_value="Linux"), \ | ||
| 203 | + patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("not found")): | ||
| 204 | + with self.assertRaises(backend_server.DialogUnavailableError): | ||
| 205 | + backend_server.pick_folder() | ||
| 206 | + | ||
| 149 | 207 | ||
| 150 | class PickFolderRouteTest(unittest.TestCase): | 208 | class PickFolderRouteTest(unittest.TestCase): |
| 151 | def setUp(self): | 209 | def setUp(self): |
| 152 | - self.server = backend_server.create_server() | 210 | + self.server = backend_server.create_server(db_path=":memory:") |
| 153 | self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) | 211 | self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) |
| 154 | self.thread.start() | 212 | self.thread.start() |
| 155 | 213 | ||
| @@ -177,13 +235,84 @@ class PickFolderRouteTest(unittest.TestCase): | @@ -177,13 +235,84 @@ class PickFolderRouteTest(unittest.TestCase): | ||
| 177 | self.assertEqual(status, 200) | 235 | self.assertEqual(status, 200) |
| 178 | self.assertEqual(body, {"selected_path": "/tmp/chosen"}) | 236 | self.assertEqual(body, {"selected_path": "/tmp/chosen"}) |
| 179 | 237 | ||
| 180 | - def test_route_returns_422_dialog_unavailable_off_macos(self): | ||
| 181 | - with patch("src.backend_server.platform.system", return_value="Linux"): | 238 | + def test_route_returns_422_dialog_unavailable_when_no_dialog_tool_present(self): |
| 239 | + with patch("src.backend_server.platform.system", return_value="Linux"), \ | ||
| 240 | + patch("src.backend_server.subprocess.run", side_effect=FileNotFoundError("not found")): | ||
| 182 | status, body = self._post("/api/system/pick-folder") | 241 | status, body = self._post("/api/system/pick-folder") |
| 183 | self.assertEqual(status, 422) | 242 | self.assertEqual(status, 422) |
| 184 | self.assertEqual(body["error"], "DIALOG_UNAVAILABLE") | 243 | self.assertEqual(body["error"], "DIALOG_UNAVAILABLE") |
| 185 | 244 | ||
| 186 | 245 | ||
| 246 | +class ProjectCreateRouteTest(unittest.TestCase): | ||
| 247 | + """c_project_create (03-C §3.0) wired to a real HTTP route for the first | ||
| 248 | + time in U-022 -- project_scaffold.create_project() itself was already | ||
| 249 | + fully implemented and tested in U-002, so this only exercises the | ||
| 250 | + request/response/error-code plumbing, not the scaffolding logic again.""" | ||
| 251 | + | ||
| 252 | + def setUp(self): | ||
| 253 | + self.tmp = tempfile.TemporaryDirectory() | ||
| 254 | + self.addCleanup(self.tmp.cleanup) | ||
| 255 | + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True) | ||
| 256 | + self.server = backend_server.create_server(db_path=":memory:") | ||
| 257 | + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) | ||
| 258 | + self.thread.start() | ||
| 259 | + | ||
| 260 | + def tearDown(self): | ||
| 261 | + self.server.shutdown() | ||
| 262 | + self.server.server_close() | ||
| 263 | + self.thread.join(timeout=5) | ||
| 264 | + | ||
| 265 | + def _post(self, path, payload): | ||
| 266 | + host, port = self.server.server_address | ||
| 267 | + conn = http.client.HTTPConnection(host, port, timeout=5) | ||
| 268 | + conn.request("POST", path, body=json.dumps(payload).encode("utf-8")) | ||
| 269 | + resp = conn.getresponse() | ||
| 270 | + body = json.loads(resp.read()) | ||
| 271 | + conn.close() | ||
| 272 | + return resp.status, body | ||
| 273 | + | ||
| 274 | + def test_creates_project_for_existing_git_repo(self): | ||
| 275 | + status, body = self._post("/api/projects", {"target_folder_path": self.tmp.name}) | ||
| 276 | + self.assertEqual(status, 200) | ||
| 277 | + self.assertTrue(body["has_git_repo"]) | ||
| 278 | + self.assertTrue(body["graphlemon_subdir_initialized"]) | ||
| 279 | + self.assertTrue(os.path.isdir(os.path.join(self.tmp.name, ".graphlemon"))) | ||
| 280 | + | ||
| 281 | + def test_repeat_call_is_idempotent(self): | ||
| 282 | + status1, body1 = self._post("/api/projects", {"target_folder_path": self.tmp.name}) | ||
| 283 | + status2, body2 = self._post("/api/projects", {"target_folder_path": self.tmp.name}) | ||
| 284 | + self.assertEqual(status1, 200) | ||
| 285 | + self.assertEqual(status2, 200) | ||
| 286 | + self.assertEqual(body1["project_id"], body2["project_id"]) | ||
| 287 | + | ||
| 288 | + def test_missing_target_folder_path_returns_400(self): | ||
| 289 | + status, body = self._post("/api/projects", {}) | ||
| 290 | + self.assertEqual(status, 400) | ||
| 291 | + self.assertEqual(body["error"], "INVALID_INPUT") | ||
| 292 | + | ||
| 293 | + def test_nonexistent_path_returns_404(self): | ||
| 294 | + status, body = self._post("/api/projects", {"target_folder_path": "/no/such/path/at/all"}) | ||
| 295 | + self.assertEqual(status, 404) | ||
| 296 | + self.assertEqual(body["error"], "PATH_NOT_FOUND") | ||
| 297 | + | ||
| 298 | + def test_non_git_folder_without_confirmation_returns_409(self): | ||
| 299 | + non_git = tempfile.mkdtemp() | ||
| 300 | + self.addCleanup(lambda: shutil.rmtree(non_git, ignore_errors=True)) | ||
| 301 | + status, body = self._post("/api/projects", {"target_folder_path": non_git}) | ||
| 302 | + self.assertEqual(status, 409) | ||
| 303 | + self.assertEqual(body["error"], "GIT_INIT_DECLINED") | ||
| 304 | + | ||
| 305 | + def test_non_git_folder_with_confirmation_initializes_git(self): | ||
| 306 | + non_git = tempfile.mkdtemp() | ||
| 307 | + self.addCleanup(lambda: shutil.rmtree(non_git, ignore_errors=True)) | ||
| 308 | + status, body = self._post( | ||
| 309 | + "/api/projects", {"target_folder_path": non_git, "init_git_if_missing": True} | ||
| 310 | + ) | ||
| 311 | + self.assertEqual(status, 200) | ||
| 312 | + self.assertTrue(body["has_git_repo"]) | ||
| 313 | + self.assertTrue(os.path.isdir(os.path.join(non_git, ".git"))) | ||
| 314 | + | ||
| 315 | + | ||
| 187 | class ZeroThirdPartyDependencyTest(unittest.TestCase): | 316 | class ZeroThirdPartyDependencyTest(unittest.TestCase): |
| 188 | """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this | 317 | """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this |
| 189 | doesn't silently rot if someone adds an import later.""" | 318 | doesn't silently rot if someone adds an import later.""" |
| @@ -202,7 +331,10 @@ class ZeroThirdPartyDependencyTest(unittest.TestCase): | @@ -202,7 +331,10 @@ class ZeroThirdPartyDependencyTest(unittest.TestCase): | ||
| 202 | top_level_names.add(node.module.split(".")[0]) | 331 | top_level_names.add(node.module.split(".")[0]) |
| 203 | 332 | ||
| 204 | self.assertTrue(top_level_names, "expected at least one import to check") | 333 | self.assertTrue(top_level_names, "expected at least one import to check") |
| 205 | - non_stdlib = {name for name in top_level_names if name not in sys.stdlib_module_names} | 334 | + # "src" is graphlemon's own package (e.g. project_scaffold/schema/paths, |
| 335 | + # wired in U-022) -- not a third-party dependency, so it's allowed; | ||
| 336 | + # everything else must be in the standard library. | ||
| 337 | + non_stdlib = {name for name in top_level_names if name not in sys.stdlib_module_names and name != "src"} | ||
| 206 | self.assertEqual(non_stdlib, set(), "non-stdlib imports found: {}".format(non_stdlib)) | 338 | self.assertEqual(non_stdlib, set(), "non-stdlib imports found: {}".format(non_stdlib)) |
| 207 | 339 | ||
| 208 | 340 |