From 65b72b82cb6081e56f7a72afb26e97a7996392e8 Mon Sep 17 00:00:00 2001 From: skytek_xinliang Date: Mon, 4 May 2026 17:36:36 +0800 Subject: [PATCH] feat: renew mvp --- .gitignore | 2 + AGENTS.md | 128 ++++------- PRD.md | 437 ++++++++++++-------------------------- README.md | 337 ++++++++++------------------- package.json | 5 +- src/cli.js | 34 +-- src/lib/agent.js | 31 --- src/lib/config.js | 38 +--- src/lib/files.js | 2 +- src/lib/html.js | 303 +++++++++++++++++++++++++- src/lib/playwright-cli.js | 16 +- src/stages/diff.js | 51 ----- src/stages/doctor.js | 14 +- src/stages/plan.js | 126 ----------- src/stages/run.js | 103 --------- src/stages/scan.js | 361 ++++++++++++++++++++++++------- src/stages/status.js | 22 -- src/stages/verify.js | 50 ----- test/cli-e2e.test.js | 61 ++++-- test/config.test.js | 9 +- test/html.test.js | 83 +++++++- 21 files changed, 1036 insertions(+), 1177 deletions(-) create mode 100644 .gitignore delete mode 100644 src/lib/agent.js delete mode 100644 src/stages/diff.js delete mode 100644 src/stages/plan.js delete mode 100644 src/stages/run.js delete mode 100644 src/stages/status.js delete mode 100644 src/stages/verify.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62ff859 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.playwright-cli +node_modules diff --git a/AGENTS.md b/AGENTS.md index 96320f9..8c4e88f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,49 +2,48 @@ ## 專案概念 -HTML Transform 是 prototype-driven orchestration framework。目標是把 HTML prototype 轉成可 review、可驗證、可追溯的 Vuetify frontend output。 +HTML Transform 是 prototype evidence 工具。目標是把 HTML prototype 轉成可 review、可驗證、可追溯的前端實作依據。 -這個 repo 目前是 MVP,不是完整產品。核心價值先放在 CLI pipeline 與 artifact-first workflow: +目前只保留新 MVP 有價值的功能: ```text -scan -> plan -> run -> diff -> verify +doctor -> scan ``` -所有中間產物都寫到 `.ht/`,生成結果寫到 `output` 設定指向的資料夾,預設是 `packages/result`。 - ## 目前 MVP 狀態 已完成: -- `ht` CLI 骨架:`scan`、`plan`、`run`、`diff`、`verify`、`go`、`status`、`doctor` -- `ht.config.js/json/ts` 設定載入 -- deterministic HTML evidence extraction +- `ht doctor` +- `ht scan` +- `ht.config.js/json/ts/mjs` 設定載入 +- Vite static server +- Playwright full-page screenshot +- DOM summary artifact +- accessibility tree artifact +- browser resource failure metadata - `.ht/cache/prototype` - `.ht/spec/*.spec.json` - `.ht/spec/*.validation.json` - `.ht/spec/*.ui-contract.md` -- `.ht/plan/tasklist.json` -- `.ht/plan/project-conventions.md` -- `.ht/plan/api-contract.md` -- `.ht/plan/component-mapping.json` -- `.ht/runs//` -- `.ht/diff/*.diff.html` -- `.ht/verify/verification-report.json` +- 頁面級 `pageContract` - 基本 E2E 測試 -尚未完成,不能假裝已完成: +刻意不保留: -- Playwright static server -- 真實 full-page screenshot -- 真實 DOM snapshot API -- 真實 accessibility tree API -- vision-based decomposition/extraction -- Zod schema validation -- 真實 coding agent invocation -- 真實 visual similarity scoring -- 完整 DOM-level 與 flow-level smoke tests +- `ht plan` +- `ht run` +- `ht diff` +- `ht verify` +- `ht go` +- `ht status` +- Stage 2 decomposition +- TaskList +- output renderer +- visual similarity scoring +- coding agent invocation -目前 screenshot 是 placeholder PNG,`diff` score 是 deterministic placeholder。 +不要重新加入未完成的 pipeline 骨架。只有在使用者明確要求某個能力,且需求足夠具體時才新增。 ## 套件管理 @@ -62,21 +61,14 @@ pnpm typecheck 這個專案使用 `@playwright/cli`,透過 shell command 操作 `playwright-cli`,不使用 MCP。 -原因: - -- PRD 的 pipeline 是 coding-agent orchestration,CLI 比 MCP 更適合放進可重跑 stage。 -- CLI invocation 較容易寫入 `.ht/runs`、logs、metadata 與 verification report。 -- 專案應避免依賴使用者全域環境。 - 解析順序在 `src/lib/playwright-cli.js`: 1. `node_modules/.bin/playwright-cli` -2. 全域 `playwright-cli` -3. `npx --no-install playwright-cli` +2. package-local `node_modules/.bin/playwright-cli` +3. 全域 `playwright-cli` +4. `npx --no-install playwright-cli` -`doctor` 必須能顯示 `playwright-cli (local)`、`playwright-cli (global)` 或 missing。 - -專案級 Codex runtime skill 放在 `.codex/skills/playwright-cli`,本機全域也可能有一份在 `~/.codex/skills/playwright-cli`。處理 browser automation、snapshot、screenshot、trace、video、test generation 或 local web app testing 時,Codex 應優先使用專案級 skill;若沒有自動觸發,先讀取 `.codex/skills/playwright-cli/SKILL.md` 再操作。 +`doctor` 必須能顯示 `playwright-cli (local)`、`playwright-cli (package-local)`、`playwright-cli (global)`、`playwright-cli (npx-local)` 或 missing。 安裝瀏覽器: @@ -84,60 +76,38 @@ pnpm typecheck pnpm playwright-cli:install-browser ``` -Linux CI 或乾淨容器若需要系統 dependency,先 dry-run: - -```bash -npx --no-install playwright-cli install-browser --with-deps --dry-run -``` - ## 預設資料夾 預設 config: ```text packages/prototype # HTML prototype -packages/web # 既有 Vuetify frontend -packages/api # backend API/schema -packages/result # output .ht # tool artifacts ``` -目前 repo 可能沒有預設輸入資料夾。這時 `doctor` 回報 missing 是正確行為,不是 bug。 +本 repo 根目錄可用 `ht.config.mjs` 把 prototype 指到 `./prototype`。 ## 開發原則 -- 遵守 PRD 的 artifact-first 思路。 +- 保持 MVP 邊界,只保留 `doctor` 與 `scan` 的實際價值。 - 不要把 placeholder 包裝成完整實作。 -- 每個 stage 的失敗、缺少環境、skipped 都要明確寫入 report 或 CLI output。 -- 優先做可重跑、可檢查、可追溯的 deterministic behavior。 -- 不要讓 agent 修改 `prototype/`、`frontend/`、`backend/` 原始輸入資料夾;生成與執行 artifact 應限制在 `output/` 與 `.ht/`。 -- 保持 MVP 邊界,避免加泛用抽象或一開始支援所有 monorepo 形態。 - -## JSDoc 原則 - -- 依專案的 `jsdoc` skill 撰寫 JSDoc;這裡只補充 HTML Transform 專案特有規則。 -- JavaScript 的 JSDoc 優先補在 exported API、stage 入口、跨模組 helper,或描述非顯而易見契約與限制的位置;不為測試檔或區域性小函式刻意補註解。 -- JSDoc 使用繁體中文,並把說明重點放在 artifact、pipeline stage、MVP 限制、cache/placeholder 行為、輸入輸出契約等專案脈絡。 -- 不新增 `jsdoc` 產生 HTML 文件的設定、script 或 build step,除非使用者明確要求。 -- 補 JSDoc 不應順手 refactor、改行為、加抽象或擴大 scope。 +- 不要新增未完成 command。 +- Stage 2 decomposition 目前不做。 +- 每個輸出 artifact 都要能重跑與人工 review。 +- 不修改 `prototype/` 原始輸入資料夾。 +- 產物只寫入 `.ht/`。 ## 驗證 完成任何改動後至少執行: ```bash -pnpm test -pnpm typecheck +pnpm --filter html-transform typecheck +pnpm --filter html-transform test +node packages/html-transform/src/cli.js doctor +node packages/html-transform/src/cli.js scan ``` -若改到環境偵測、Playwright CLI、設定載入,也執行: - -```bash -node src/cli.js doctor -``` - -若改到 stage pipeline,應確認 E2E 測試仍通過,或新增/更新 `test/cli-e2e.test.js`。 - ## 重要檔案 - `PRD.md`:產品需求來源。 @@ -146,18 +116,10 @@ node src/cli.js doctor - `src/lib/config.js`:config loading。 - `src/lib/html.js`:deterministic HTML evidence extraction。 - `src/lib/playwright-cli.js`:Playwright CLI resolution。 -- `src/stages/*.js`:各 stage 實作。 -- `test/cli-e2e.test.js`:完整 CLI pipeline 測試。 +- `src/stages/doctor.js`:前置檢查。 +- `src/stages/scan.js`:capture、page contract、validation。 +- `test/cli-e2e.test.js`:doctor/scan E2E 測試。 -## 下一個最重要的方向 +## 下一個方向 -下一步最值得做的是把 Stage 1 做真: - -- local static server -- `playwright-cli` 三 viewport screenshot -- network/resource failure metadata -- DOM snapshot artifact -- accessibility snapshot artifact -- cache hit 時跳過 screenshot - -這完成後,工具才會真正開始觀察 browser-rendered prototype,而不是只解析 HTML source。 +下一個有價值的方向是改善 `pageContract` 品質,例如更準確的 form label、table header、action 分類與 evidence warning。不要先做 TaskList 或自動寫 code。 diff --git a/PRD.md b/PRD.md index 5340940..40d48d2 100644 --- a/PRD.md +++ b/PRD.md @@ -3,359 +3,188 @@ | 欄位 | 內容 | |------|------| -| 版本 | v0.1 | -| 日期 | 2026-05-02 | -| 狀態 | Draft | +| 版本 | v0.3 | +| 日期 | 2026-05-04 | +| 狀態 | MVP | --- ## 1. 背景與目標 -HTML Transform 是一個安裝在 pnpm-workspace / monorepo 中的 prototype-driven orchestration framework。它讓工程師指定四個資料夾,由單一 coding agent 依序驅動完整 pipeline,將 HTML prototype 轉換為可運作、可維護的 Vuetify 前端實作,並用 backend API schema 對齊資料流。 +HTML Transform 是 prototype evidence 工具。它的目標不是完整自動化前端生成,而是把 HTML prototype 轉成可 review、可追溯、可交給 coding agent 實作的前置證據。 -**四個資料夾:** +目前 MVP 只保留有實際價值的功能: -| 識別子 | 說明 | -|--------|------| -| `prototype` | HTML prototype 來源 | -| `frontend` | 已預先建立的 Vuetify 前端專案 | -| `backend` | 已預先建立的 backend API 專案 | -| `output` | 生成結果的寫入目標 | +- 檢查 `scan` 前置條件。 +- 用真實瀏覽器擷取 prototype evidence。 +- 推論跨頁面的 App Structure Map。 +- 產生頁面級 UI Contract。 +- 驗證 UI Contract 與 DOM evidence 是否明顯衝突。 -**核心設計原則:** pipeline 的每個 stage 都產出可被人工 review 的 artifact,agent 的不確定性由結構化中間產物吸收,而非在最終輸出才暴露。HTML prototype 不是完整規格;rendered browser evidence、DOM/accessibility snapshot、`LayoutSpec`、project conventions、API contract、`TaskList` 才共同構成實作依據。 - -**成功標準:** -- 相同 prototype 執行兩次,`output` 資料夾的結果語意一致 -- Diff report 的平均 similarity score 高於 75% -- `LayoutSpec` 的 `null` 欄位比率低於 30% -- Stage 7 的 quality gates(typecheck、lint、test、route smoke test)全部通過,或明確標示為環境缺失而非實作失敗 -- 每次 run 都能回溯到對應的 screenshot、DOM snapshot、spec、plan、agent raw output 與 verification report +不保留未完成的 pipeline 骨架。`plan`、`run`、`diff`、`verify`、`go`、`status` 已不屬於目前產品面。 --- -## 2. 系統架構 +## 2. CLI Scope -``` -ht.config.ts - │ - ▼ -┌─────────────────────────────────────────────────────┐ -│ Orchestrator │ -│ (讀取 config,依序呼叫各 stage,傳遞中間產物) │ -└──────┬──────────┬──────────┬──────────┬─────────────┘ - │ │ │ │ - ▼ ▼ ▼ ▼ - [Stage 1] [Stage 2] [Stage 3] [Stage 4] - Capture Decompose Extract Validate - │ │ │ │ - └──────────┴──────────┴──────────┘ - │ - LayoutSpec - │ - ┌────────┴────────┐ - ▼ ▼ - [Stage 5] [Stage 6] - Plan Run - TaskList coding agent - │ - output/ - │ - [Stage 7] - Diff & Verify - reports + quality gates -``` +| 指令 | 說明 | +|------|------| +| `ht doctor` | 檢查 `scan` 所需環境 | +| `ht scan` | 產生 browser evidence、page contract、validation report | -**Agent 角色:** 單一 coding agent(由 `ht.config.ts` 中的 `agent` 欄位指定,支援 claude-code、codex、gemini)全程驅動所有 stage,包含 vision 截圖分析、spec 提取、程式碼實作、diff 評分。Orchestrator 負責呼叫順序與中間產物的序列化,agent 負責每個 stage 的實際執行。 +沒有 Codex slash command。根目錄輸入 `/scan` 不會自動執行本工具。 --- ## 3. 設定檔 -```typescript -// ht.config.ts -import { defineConfig } from 'html-transform' +MVP 只使用 `prototype`: -export default defineConfig({ - prototype: './packages/prototype', - frontend: './packages/web', - backend: './packages/api', - output: './packages/result', - - agent: 'claude-code', // 'claude-code' | 'codex' | 'gemini' - - vision: { - viewport: { width: 1440, height: 900 }, - captureStates: ['default', 'scrolled', 'mobile'], - includeDomSnapshot: true, - includeAccessibilityTree: true, - }, - - project: { - packageManager: 'pnpm', - frontendFramework: 'vue3', - uiLibrary: 'vuetify3', - qualityCommands: { - typecheck: 'pnpm typecheck', - lint: 'pnpm lint', - test: 'pnpm test', - }, - }, - - plan: { - interactiveReview: true, // Stage 5 完成後暫停,等待人工確認 TaskList - requireApiContractMatch: true, - }, - - diff: { - scoreThreshold: 75, // 低於此分數的 region 在 report 中標示警告 - requireDomChecks: true, - requireFlowChecks: true, - }, -}) -``` - ---- - -## 4. Pipeline 詳細需求 - -### Stage 1:Capture - -**目的:** 產出 prototype 的視覺真相,取代 agent 腦補的渲染結果。 - -| 需求 ID | 說明 | -|---------|------| -| F1-1 | 對每個 `.html` 檔案,以 desktop(1440×900)、tablet(768×1024)、mobile(375×812)各截一張全頁截圖 | -| F1-2 | 以本地 static server 提供 prototype 檔案,避免 `file://` 協定的 CORS 問題 | -| F1-3 | 截圖前等待 `networkidle`,再額外等待 500ms,確保 CSS animation 穩定 | -| F1-4 | 以原始 `.html` 檔案的 SHA-256 hash 作為 cache key;hash 未變動時跳過截圖 | -| F1-5 | 截圖儲存至 `.ht/cache/prototype/{filename}/{viewport}-{state}.png` | -| F1-6 | 對每個 viewport/state 產出 DOM summary 與 accessibility tree snapshot,作為 Stage 2–5 的非視覺 evidence | -| F1-7 | 記錄外部資源載入失敗清單(font、image、script、stylesheet),寫入 capture metadata | - -**產出物:** `.ht/cache/prototype/` 下的截圖、DOM snapshot、accessibility snapshot 與 capture metadata - ---- - -### Stage 2:Decompose - -**目的:** 識別頁面的主要區域,為 Stage 3 的局部提取做準備。直接對整頁截圖做一次性提取,精度低且不穩定;分區後再提取可大幅降低錯誤率。 - -| 需求 ID | 說明 | -|---------|------| -| F2-1 | 以 desktop 截圖呼叫 agent(vision 模式),識別頁面主要區域 | -| F2-2 | 回傳 JSON array,每個 item 包含 `id`、`name`、`rough_position`、`element_count_estimate`、`semantic_role` | -| F2-3 | 依據 `rough_position`,用 Playwright `clip` 截取每個 region 的局部截圖 | -| F2-4 | Prompt 明確要求只回 JSON,不附任何說明文字 | -| F2-5 | 使用 Stage 1 的 DOM summary 與 accessibility tree 輔助確認 region 名稱、可互動元素與文字,不只依賴截圖 | - -**產出物:** `PageDecomposition` JSON(含各 region 的局部截圖路徑) - ---- - -### Stage 3:Extract - -**目的:** 從每個 region 截圖提取結構化的 `LayoutSpec`。 - -| 需求 ID | 說明 | -|---------|------| -| F3-1 | 每個 region 獨立呼叫 agent,輸入該 region 的局部截圖 | -| F3-2 | Prompt 以填空題形式提供 `LayoutSpec` JSON 結構,要求 agent 填值而非自由生成 | -| F3-3 | `vuetifyComponent` 必須是 Vuetify 3 的真實 component 名稱(對照白名單驗證) | -| F3-4 | 不確定的欄位填 `null`,Prompt 明確禁止猜測 | -| F3-5 | `colorPalette` 只填截圖中實際出現的色碼(hex 或 rgba) | -| F3-6 | `textSamples` 填截圖中可見的真實文字,最多 3 筆 | -| F3-7 | 對表單、列表、表格、導覽與 action button 產出 `uiContract` 欄位,描述 label、field type、required 狀態、主要/次要 action、可觀察互動 | -| F3-8 | 若 DOM/accessibility evidence 與 vision 判讀衝突,保留 `warnings`,不得任意合併成確定結論 | - -**產出物:** 每個 region 的 `RegionSpec` JSON - ---- - -### Stage 4:Validate - -**目的:** 確保 `LayoutSpec` 符合 schema,自動修復可修復的問題,標記需要人工介入的頁面。 - -| 需求 ID | 說明 | -|---------|------| -| F4-1 | 以 Zod schema 驗證每個 region 的 extract 結果 | -| F4-2 | `vuetifyComponent` 不合法時,查 Vuetify 3 白名單找最相近的名稱並自動修正 | -| F4-3 | 色碼格式不合法時,嘗試 normalize;無法修正時填 `null` | -| F4-4 | 產出 `ValidationReport`,記錄 `autoFixedCount`、`nullFieldCount`、`warnings` | -| F4-5 | `nullFieldCount` 超過 30% 時,將該頁標記為 `requiresHumanReview: true`,並在 CLI 輸出警告 | -| F4-6 | 語意驗證 region 數量、文字樣本、主要 action、表單欄位與 DOM/accessibility evidence 是否一致 | -| F4-7 | 產出人工可讀的 `ui-contract.md` 摘要,讓使用者可在實作前 review prototype contract | - -**產出物:** 完整的 `LayoutSpec` JSON(儲存至 `.ht/spec/{filename}.spec.json`)+ `ValidationReport` + `ui-contract.md` - ---- - -### Stage 5:Plan - -**目的:** 將 `LayoutSpec` 拆解為 agent 可逐一執行的 `TaskList`,讓 Stage 6 的執行單元保持小而可控。 - -| 需求 ID | 說明 | -|---------|------| -| F5-1 | 讀取 `LayoutSpec`、`frontend/` 的現有元件、`backend/` 的 API schema,產出 `TaskList` | -| F5-2 | 每個 Task 對應一個 page 或 component,包含:`id`、`type`、`targetFile`、`specReference`、`apiDependencies` | -| F5-3 | Task 之間的依賴關係以 `dependsOn` 陣列表示,Orchestrator 依此決定執行順序 | -| F5-4 | `ht.config.ts` 中 `plan.interactiveReview: true` 時,產出 TaskList 後暫停,輸出預覽並等待使用者輸入 `y` 繼續 | -| F5-5 | `TaskList` 儲存至 `.ht/plan/tasklist.json`,支援手動編輯後重新執行 Stage 6 | -| F5-6 | 掃描 `frontend/` 產出 `project-conventions.md`,至少包含 routing、Vuetify 使用方式、表單封裝、API client/composable、Pinia、validation、i18n、測試慣例 | -| F5-7 | 掃描 `backend/` 產出 `api-contract.md`;若存在 OpenAPI 優先使用,否則從 TypeScript interface、controller、route 或 markdown 抽象 endpoint contract | -| F5-8 | 產出 `component-mapping.json`,描述 prototype region/section 對應 Vuetify component 或既有自訂 wrapper 的映射 | -| F5-9 | `plan.requireApiContractMatch: true` 時,Task 不得引用 `api-contract.md` 未列出的 endpoint;缺口須產出 `contractProposal` 並標記需人工確認 | - -**TaskList 結構(摘要):** - -```typescript -interface Task { - id: string - type: 'page' | 'component' | 'api-integration' - targetFile: string // 相對於 output/ 的路徑 - specReference: string // 對應 LayoutSpec 的 regionId - apiDependencies: string[] // backend API endpoint 清單 - inputArtifacts: string[] // spec、contract、conventions、mapping 等 artifact 路徑 - acceptanceChecks: string[] // 此 task 完成後必須通過的檢查 - dependsOn: string[] // 其他 task id - status: 'pending' | 'running' | 'done' | 'error' +```js +export default { + prototype: './prototype' } ``` -**產出物:** `.ht/plan/tasklist.json`、`.ht/plan/project-conventions.md`、`.ht/plan/api-contract.md`、`.ht/plan/component-mapping.json` +本 repo 的根目錄設定檔可以保留其他欄位給外部工作流參考,但 `packages/html-transform` 不會執行 frontend/backend/output 相關流程。 --- -### Stage 6:Run +## 4. Stage 1:Capture -**目的:** 讓 coding agent 依序執行 TaskList,將 Vuetify 元件與 API 整合寫入 `output/`。 +目的:取得 prototype 的瀏覽器真相,避免只看 HTML source 或讓 agent 腦補畫面。 -| 需求 ID | 說明 | -|---------|------| -| F6-1 | Orchestrator 依 `dependsOn` 拓撲排序,逐一將 task 交給 agent 執行 | -| F6-2 | 每個 task 的 prompt context 包含:對應的 `RegionSpec`、`ui-contract.md` 摘要、`project-conventions.md`、`component-mapping.json`、`targetFile` 的現有內容(若存在)、相關 API contract | -| F6-3 | Agent 的輸出直接寫入 `output/{targetFile}`;寫入前備份至 `.ht/backup/` | -| F6-4 | Task 執行失敗時標記 `status: 'error'`,記錄錯誤訊息,繼續執行無依賴的後續 task | -| F6-5 | 支援 `ht run --retry-failed` 指令,只重新執行 `status: 'error'` 的 task | -| F6-6 | 每個 task 完成後更新 `.ht/plan/tasklist.json` 的 `status` 欄位 | -| F6-7 | Agent invocation 必須限制 `allowedPaths`,預設只能寫入 `output/` 與 `.ht/`;不得修改 `prototype/`、`frontend/`、`backend/` 原始輸入資料夾 | -| F6-8 | 每次 agent raw output、changed files、exit code、stderr/stdout summary 寫入 `.ht/runs/{runId}/agent/` | +需求: -**產出物:** `output/` 資料夾中的 Vuetify 元件與整合程式碼 +| ID | 說明 | +|----|------| +| F1-1 | 對每個 `.html` 檔案截取 desktop `1440 x 900` full-page screenshot | +| F1-2 | 使用本地 Vite static server,不使用 `file://` | +| F1-3 | 截圖前等待 `networkidle`,再等待 500ms | +| F1-4 | 以 HTML SHA-256 hash 作為 cache key,未變更時跳過重新截圖 | +| F1-5 | 截圖寫入 `.ht/cache/prototype/{page}/desktop-default.png` | +| F1-6 | 產出 `dom-summary.json` 與 `accessibility-tree.json` | +| F1-7 | 產出 `capture-metadata.json`,記錄 resource failure 與 console error/warning | +| F1-8 | 產出 `.ht/app-map.json`,推論頁面角色與 layout 策略 | + +產出: + +```text +.ht/cache/prototype/{page}/desktop-default.png +.ht/cache/prototype/{page}/dom-summary.json +.ht/cache/prototype/{page}/accessibility-tree.json +.ht/cache/prototype/{page}/capture-metadata.json +.ht/app-map.json +``` --- -### Stage 7:Diff & Verify +## 5. Stage 2:Decompose -**目的:** 量化 `output/` 的結果與 prototype 的視覺相似度,並以 deterministic checks 優先驗證實作品質,讓工程師能快速判斷生成結果是否可交付。 +MVP 不執行。 -| 需求 ID | 說明 | -|---------|------| -| F7-1 | 對 `output/` 中每個頁面以相同 viewport 截圖 | -| F7-2 | 將 output 截圖與 Stage 1 的 prototype 截圖交給 agent 評分(0–100),判斷視覺相似度 | -| F7-3 | 產出靜態 HTML report,每行並排顯示:prototype 截圖、output 截圖、similarity score | -| F7-4 | Score 低於 `ht.config.ts` 中 `diff.scoreThreshold`(預設 75)的項目以警告色標示 | -| F7-5 | Report 儲存至 `.ht/diff/{filename}.diff.html`,執行完畢後在 CLI 輸出 report 路徑 | -| F7-6 | 執行 `project.qualityCommands` 中設定的 typecheck、lint、test;缺少指令時記錄為 `skipped` 並說明原因 | -| F7-7 | 執行 DOM-level checks:主要文字、form label、table/list item、primary/secondary action 是否存在且可互動 | -| F7-8 | 執行 flow-level checks:可由 `LayoutSpec.interactions` 推導的 drawer、tab、modal、form submit、navigation 等互動 smoke test | -| F7-9 | 採 deterministic first, agent fallback second:固定檢查失敗時才呼叫 agent 協助分析原因,不讓 agent 取代可重跑驗證 | -| F7-10 | 產出 `VerificationReport`,彙整 quality commands、DOM checks、flow checks、visual diff 與人工需處理項目 | +不做的項目: -**產出物:** `.ht/diff/` 下的靜態 HTML report + `.ht/verify/verification-report.json` +- 不呼叫 vision agent 切主要區域。 +- 不產生 `decomposition.json`。 +- 不用 Playwright `clip` 截取 table/form/main 等局部圖。 +- 不建立 region-first extraction pipeline。 --- -## 5. CLI 指令設計 +## 6. Stage 3-lite:Page Contract -| 指令 | 說明 | +目的:從 Stage 1 evidence 與 HTML source 產生頁面級 UI Contract。 + +需求: + +| ID | 說明 | +|----|------| +| F3-1 | 每個 HTML 產出一份 `pageContract` | +| F3-2 | `pageContract` 以整頁為單位,不以 region screenshot 為單位 | +| F3-3 | 記錄 source、screenshot、title、sections、forms、tables、actions、textSamples | +| F3-4 | 建議 Vuetify components 必須來自 MVP 白名單 | +| F3-5 | forms 記錄 labels、fields、required 狀態與 actions | +| F3-6 | tables 記錄 headers 與少量 sample rows | +| F3-7 | 不確定或 evidence 對不上時保留 warnings | + +產出: + +```text +.ht/spec/{page}.spec.json +.ht/spec/{page}.ui-contract.md +``` + +`.spec.json` 目前仍保留 `regions` 欄位作為相容層,但它不是 MVP 的主要設計中心。 + +--- + +## 6.1 App Structure Map + +目的:讓通用 prompt 不必靠人工說明,就能知道每個 prototype 應如何放進前端 app。 + +產出: + +```text +.ht/app-map.json +``` + +每個 route 應包含: + +| 欄位 | 說明 | |------|------| -| `ht scan` | 執行 Stage 1–4(Capture → Validate),產出 `LayoutSpec` | -| `ht plan` | 執行 Stage 5,產出 `TaskList`;`interactiveReview: true` 時暫停等待確認 | -| `ht run` | 執行 Stage 6,依 `TaskList` 驅動 agent 寫入 `output/` | -| `ht run --retry-failed` | 只重新執行 `status: 'error'` 的 task | -| `ht diff` | 執行 Stage 7 的 visual diff,產出 similarity report | -| `ht verify` | 執行 Stage 7 的 quality commands、DOM checks 與 flow checks | -| `ht go` | 依序執行全部 stage(scan → plan → run → diff → verify) | -| `ht status` | 顯示目前 `TaskList` 的執行狀態摘要 | -| `ht doctor` | 檢查 pnpm workspace、Playwright、agent CLI、Vuetify component whitelist、quality commands 是否可用 | +| `prototype` | prototype 相對路徑 | +| `kind` | `auth`、`auth-support`、`legacy-shell-reference`、`prototype-navigation`、`feature-page` 或 `reference` | +| `module` | 由資料夾推論的功能模組 | +| `targetRole` | 對前端實作的角色提示 | +| `layout` | `template-auth`、`template-app` 或 `ignore` | +| `usePrototypeStyle` | MVP 固定為 `false` | +| `usePrototypeContent` | 是否採用 prototype 的內容資訊架構 | +| `routeHint` | 可用的路由建議,無正式頁面時為 `null` | + +推論規則: + +- password input 或登入/忘記密碼文字:`auth`。 +- `portal/app-layout.html`、`portal/menu-pane.html`、`portal/index.html` 或含登出/隱藏選單:`legacy-shell-reference`。 +- 非 portal 且有 table/form/action:`feature-page`。 +- module index 若像 prototype 導覽頁:`prototype-navigation`。 +- layout 與 login style 來源固定為 `preparation/skt-vuetify-templates`。 --- -## 6. 資料夾結構 +## 7. Stage 4-lite:Validate Contract -``` -monorepo/ -├── ht.config.ts -├── packages/ -│ ├── prototype/ ← 輸入:HTML prototype -│ ├── web/ ← 輸入:Vuetify 前端專案(預先建立) -│ ├── api/ ← 輸入:Backend API(預先建立) -│ └── result/ ← 輸出:生成結果 -└── .ht/ ← 工具產出的所有中間產物與 cache - ├── cache/prototype/ ← Stage 1 截圖 - ├── spec/ ← Stage 4 LayoutSpec JSON - ├── plan/ ← Stage 5 TaskList JSON - ├── runs/ ← 每次 run 的狀態、log、agent raw output、artifact 索引 - ├── backup/ ← Stage 6 寫入前的備份 - ├── verify/ ← Stage 7 verification report - └── diff/ ← Stage 7 similarity report -``` +目的:確認 UI Contract 與 browser evidence 沒有明顯衝突。 -**Run artifact 結構:** +需求: -``` -.ht/runs/ -└── 2026-05-02-001/ - ├── run.json - ├── screenshots/ - ├── dom-snapshots/ - ├── agent/ - ├── logs/ - └── artifact-index.json +| ID | 說明 | +|----|------| +| F4-1 | 驗證 `textSamples` 是否可在 DOM summary 找到 | +| F4-2 | 驗證 actions 是否可在 DOM summary 找到 | +| F4-3 | 驗證 form labels 是否可在 DOM summary 找到 | +| F4-4 | 驗證建議 Vuetify components 是否在白名單內 | +| F4-5 | 產出 `ValidationReport`,包含 warnings、nullFieldCount、nullFieldRatio、requiresHumanReview | + +產出: + +```text +.ht/spec/{page}.validation.json ``` --- -## 7. 非功能需求 +## 8. 非目標 -| 類別 | 需求 | -|------|------| -| 效能 | 單一 HTML 頁面的 Stage 1–4 完整執行不超過 60 秒 | -| 可復現性 | Cache 命中時跳過截圖與 extract,相同輸入產出相同結果 | -| 可觀測性 | 每個 stage 完成後輸出 log,記錄耗時、agent call 次數、cache 命中狀態 | -| 錯誤隔離 | 單一 task 或 region 失敗不中斷整體流程,標記後繼續執行 | -| 可介入性 | `TaskList` 支援手動編輯;`interactiveReview` 模式允許人工在 Plan 後介入 | -| 可追溯性 | 每個輸出檔案都能追溯到 input artifact、agent invocation、task id 與 run id | -| 邊界控制 | Agent 預設只可寫入 `output/` 與 `.ht/`,所有輸入資料夾視為 read-only | +MVP 不追求: ---- +- tablet/mobile 多 viewport capture。 +- region decomposition。 +- 每個 region 獨立 vision extraction。 +- TaskList 產生。 +- 自動寫入 frontend/output。 +- visual similarity scoring。 +- flow smoke test。 +- 全自動 coding agent invocation。 -## 8. 風險與假設 - -| 風險 | 程度 | 緩解策略 | -|------|------|---------| -| Vision 分析精度不足,`null` 欄位過多 | 高 | Spike 驗證後調整 prompt;人工補充 spec | -| Prototype 引用外部資源,截圖不完整 | 中 | Local static server + `networkidle` 等待 | -| Agent 生成的程式碼不符合既有前端專案的規範 | 中 | Prompt context 包含現有元件與 coding style;task 單元保持小 | -| Backend API 不足以支援 prototype 行為 | 中 | Stage 5 產出 `contractProposal` 並要求人工確認,不讓 agent 自行發明 endpoint | -| 只靠 screenshot similarity 造成假陽性 | 中 | Stage 7 加入 DOM-level、flow-level 與 project quality checks | -| Vuetify component 白名單不完整,自動修正失敗 | 低 | 預先整理 Vuetify 3 全量 component 清單 | - -**核心假設:** -- Prototype 在現代瀏覽器中能正確渲染 -- `frontend/` 專案已有基本的 Vuetify 3 設定與 project structure -- `backend/` 專案有可讀的 API schema(OpenAPI 或 TypeScript interface) -- 第一版 MVP 聚焦單一 HTML prototype → 單一 Vue page/component → 既有 API client → `output/` 結果,不追求一開始支援所有 monorepo 形態 - ---- - -## 9. 開發里程碑 - -| 里程碑 | 涵蓋 Stage | 完成條件 | -|--------|-----------|---------| -| M0:Spike | Stage 1–3(手動) | 手動截圖 + 手動 prompt,驗證 `null` 欄位 < 30% | -| M1:Vision Pipeline | Stage 1–4 | `ht scan` 可對一個 HTML 頁面產出完整 `LayoutSpec`、DOM snapshot 與 `ui-contract.md` | -| M2:Plan | Stage 5 | `ht plan` 可產出 `TaskList`、`project-conventions.md`、`api-contract.md`、`component-mapping.json`,interactive review 可運作 | -| M3:Run | Stage 6 | `ht run` 可驅動 agent 對一個頁面完整執行並寫入 `output/` | -| M4:Diff & Verify | Stage 7 | `ht diff` 可產出 similarity report,`ht verify` 可執行 quality、DOM、flow checks | -| M5:整合 | 全部 | `ht go` 全流程可在真實 monorepo 中穩定執行 | +這些能力只有在實際需求明確出現後才新增。 diff --git a/README.md b/README.md index 8fcead8..8d16b19 100644 --- a/README.md +++ b/README.md @@ -1,290 +1,173 @@ -## A prototype-driven orchestration framework for CLI coding agents in TypeScript monorepos +## HTML Transform -- **prototype-driven**:不是純 spec,也不是純 code-first。 -- **orchestration framework**:主體是 workflow 與 gates,不是模型本身。 -- **CLI coding agents**:Claude Code、Codex、Gemini 只是 adapter targets,不是核心。 +HTML Transform 是 prototype evidence 工具。現行 MVP 只做兩件事: -## MVP +- `doctor`:檢查執行 `scan` 需要的前置條件。 +- `scan`:讀取 HTML prototype,產生瀏覽器證據與頁面級 UI Contract。 +- `app-map.json`:推論每個 prototype 的頁面角色與 layout 使用策略。 -這個版本提供一個可執行的 `ht` CLI 骨架,依照 PRD 的 stage 產出可 review 的中間 artifact: +不提供 `plan`、`run`、`diff`、`verify`、`go`、`status`。這些舊骨架已移除,避免把尚未完成的自動化流程誤認成可用功能。 -- `ht scan`:讀取 `prototype/` HTML,產生 `.ht/cache/prototype/`、`.ht/spec/*.spec.json`、`ui-contract.md` -- `ht plan`:掃描 frontend/backend,產生 `.ht/plan/tasklist.json`、conventions、API contract、component mapping -- `ht run`:依 TaskList 產生 Vuetify `.vue` 檔到 `output/` -- `ht diff`:產生 `.ht/diff/*.diff.html` -- `ht verify`:執行 quality commands 並產生 `.ht/verify/verification-report.json` -- `ht go --yes`:串起完整流程 +## MVP 範圍 -外部 vision/agent/Playwright 尚未假裝成已整合;目前以 deterministic HTML evidence 建立 MVP artifact,缺少的執行環境會在 report 或 `ht doctor` 中明確標示。 +`scan` 會執行: -## 使用方式 +```text +Stage 1 Capture +Stage 3-lite Page Contract +Stage 4-lite Contract Validation +``` -### 1. 確認 Node 版本 +Stage 2 decomposition 不執行。現有 prototype 主要是後台系統頁面,重點是表單、查詢條件、表格、actions 與資訊架構;切 region screenshot 對 MVP 價值不高。 + +目前沒有 Codex slash command。也就是說,在 repo 根目錄輸入 `/scan` 不會自動執行本工具。 + +## 指令 + +在 `packages/html-transform` 目錄內: + +```bash +node src/cli.js doctor +node src/cli.js scan +``` + +在 repo 根目錄: + +```bash +node packages/html-transform/src/cli.js doctor +node packages/html-transform/src/cli.js scan +``` + +若要在根目錄用簡短指令,建議由根目錄 `package.json` 包一層: + +```json +{ + "scripts": { + "scan": "node packages/html-transform/src/cli.js doctor && node packages/html-transform/src/cli.js scan" + } +} +``` + +之後即可執行: + +```bash +pnpm scan +``` + +## 前置需求 需要 Node.js 20 以上。 ```bash node --version +pnpm --version ``` -### 2. 安裝專案相依套件 - -建議使用專案級安裝,不依賴全域 `playwright-cli`: +安裝相依套件: ```bash pnpm install ``` -本專案使用 `@playwright/cli`,指令名稱是 `playwright-cli`。安裝後可以用: +確認 `playwright-cli` 可用: ```bash -npx --no-install playwright-cli --version +pnpm --filter html-transform exec playwright-cli --version ``` -或透過 npm script: +若環境還沒有 Chromium: ```bash -pnpm playwright-cli -- --version +pnpm --filter html-transform exec playwright-cli install-browser chromium ``` -若環境還沒有瀏覽器 binary,先安裝預設瀏覽器: +## 設定檔 -```bash -pnpm playwright-cli:install-browser -``` - -Linux CI 或乾淨容器可能還需要系統套件,先用 dry-run 看會做什麼: - -```bash -npx --no-install playwright-cli install-browser --with-deps --dry-run -``` - -### 3. 準備資料夾 - -預設設定會讀取以下路徑: +沒有設定檔時,預設讀取: ```text -packages/ -├── prototype/ # 放 HTML prototype -├── web/ # 既有 Vuetify frontend 專案 -├── api/ # backend API 或 schema -└── result/ # 產生結果 +packages/prototype/ ``` -最小可跑範例: - -```bash -mkdir -p packages/prototype packages/web/src packages/api packages/result -``` - -新增一個 prototype: - -```html - -
-

Customer Portal

-
- - - -
-
-``` - -### 4. 選擇性建立設定檔 - -沒有 `ht.config.js` 時會使用預設值。若要覆寫路徑或關閉互動確認,可以建立: +本 repo 根目錄目前使用: ```js -// ht.config.js export default { - prototype: './packages/prototype', - frontend: './packages/web', - backend: './packages/api', - output: './packages/result', - agent: 'codex', + prototype: './prototype', + frontend: './apps/frontend/hwu-re', + backend: './apps/backend', + output: './output', plan: { - interactiveReview: false, - requireApiContractMatch: true - }, - project: { - qualityCommands: { - typecheck: 'pnpm typecheck', - lint: 'pnpm lint', - test: 'pnpm test' - } + interactiveReview: false } } ``` -也可以使用 `ht.config.ts`: +目前 MVP 只使用 `prototype`。其他欄位可以留著給外部工作流參考,但 `packages/html-transform` 不會執行 frontend/backend/output 相關步驟。 -```ts -import { defineConfig } from 'html-transform' - -export default defineConfig({ - prototype: './packages/prototype', - frontend: './packages/web', - backend: './packages/api', - output: './packages/result' -}) -``` - -### 5. 檢查環境 +## Doctor ```bash -node src/cli.js doctor +node packages/html-transform/src/cli.js doctor ``` -`doctor` 會檢查 prototype/frontend/backend/output parent、playwright-cli、agent CLI、pnpm 是否存在。缺少項目會標示為 `missing`。 +檢查項目: -`playwright-cli` 的偵測順序: +- `prototype` directory +- package-local `vite` +- `playwright-cli` +- `pnpm` -1. `node_modules/.bin/playwright-cli` -2. 全域 `playwright-cli` -3. `npx --no-install playwright-cli` - -### 6. 執行 scan +## Scan ```bash -node src/cli.js scan +node packages/html-transform/src/cli.js scan ``` -產出: +`scan` 會自動: + +- 用 Vite static server 提供 prototype。 +- 用 `playwright-cli` 開啟每個 HTML。 +- 等待 `networkidle`,再等待 500ms。 +- 截取 desktop `1440 x 900` full-page screenshot。 +- 產生 DOM summary。 +- 產生 accessibility snapshot。 +- 記錄 resource failure 與 console error/warning。 +- 建立頁面級 `pageContract`。 +- 驗證 contract 與 DOM evidence 是否明顯衝突。 +- 產出 `.ht/app-map.json`,供通用 prompt 判斷 auth、legacy shell、feature page 與 layout 策略。 + +## 產物 + +每個 HTML 的 capture artifact: ```text -.ht/cache/prototype/ -.ht/spec/*.spec.json -.ht/spec/*.validation.json -.ht/spec/*.ui-contract.md +.ht/cache/prototype/{page}/desktop-default.png +.ht/cache/prototype/{page}/dom-summary.json +.ht/cache/prototype/{page}/accessibility-tree.json +.ht/cache/prototype/{page}/capture-metadata.json ``` -這一步會從 HTML 擷取 DOM summary、accessibility-like tree、region、LayoutSpec 與 UI contract。MVP 目前使用 deterministic HTML evidence;截圖檔是 placeholder PNG。 - -後續接真實 browser automation 時,會透過 `playwright-cli` shell 指令操作,不透過 MCP。 - -### 7. 產生 TaskList - -```bash -node src/cli.js plan -``` - -產出: +每個 HTML 的 contract artifact: ```text -.ht/plan/tasklist.json -.ht/plan/project-conventions.md -.ht/plan/api-contract.md -.ht/plan/component-mapping.json +.ht/app-map.json +.ht/spec/{page}.spec.json +.ht/spec/{page}.validation.json +.ht/spec/{page}.ui-contract.md ``` -若 `plan.interactiveReview` 設為 `true`,CLI 會在產生 TaskList 後要求輸入 `y` 才繼續。 +`.spec.json` 會包含 `pageContract`。`regions` 欄位目前仍保留,目的是相容既有資料形狀;不要把它視為 Stage 2 decomposition。 -### 8. 產生 output +`.ht/app-map.json` 是跨頁面的應用結構推論。通用 prompt 應先讀它,再決定每個 prototype 是 `auth`、`legacy-shell-reference`、`feature-page` 或其他角色。MVP 固定策略是 template layout/style 優先,prototype 只提供內容與功能證據。 + +## 驗證 + +修改本 package 後至少執行: ```bash -node src/cli.js run -``` - -產出會寫入 `output` 設定的資料夾,例如: - -```text -packages/result/index/main-1.vue -``` - -每個 task 的執行記錄會寫入: - -```text -.ht/runs// -``` - -若只想重跑失敗 task: - -```bash -node src/cli.js run --retry-failed -``` - -### 9. 產生 diff report - -```bash -node src/cli.js diff -``` - -產出: - -```text -.ht/diff/*.diff.html -``` - -MVP 目前會建立可 review 的 HTML report 結構;真實 `playwright-cli` 截圖與 vision similarity scoring 尚未串接。 - -### 10. 執行驗證 - -```bash -node src/cli.js verify -``` - -產出: - -```text -.ht/verify/verification-report.json -``` - -驗證包含: - -- `project.qualityCommands` 中設定的 typecheck/lint/test -- output target 是否存在 -- flow checks 目前在 MVP 中會標示為 skipped - -### 11. 一次跑完整流程 - -```bash -node src/cli.js go --yes -``` - -等同依序執行: - -```bash -node src/cli.js scan -node src/cli.js plan -node src/cli.js run -node src/cli.js diff -node src/cli.js verify -``` - -### 12. 查看 task 狀態 - -```bash -node src/cli.js status -``` - -會顯示 `pending`、`running`、`done`、`error` 的 task 數量。 - -## 開發驗證 - -```bash -pnpm test -pnpm typecheck -``` - -## 目前 MVP 限制 - -- 尚未整合 Playwright static server、真實 full-page screenshot、DOM snapshot API 與 accessibility tree API。 -- Playwright 操作會以 `playwright-cli` shell command 為準,不使用 MCP;目前已加入專案級 `@playwright/cli` 安裝與環境偵測。 -- 尚未真的呼叫 Claude Code/Codex/Gemini 進行 vision decomposition 或 coding task。 -- `diff` 的 similarity score 目前是 deterministic report placeholder,不是 vision scoring。 -- `run` 目前以 LayoutSpec 產生基本 Vuetify Vue component,還不是完整專案級整合。 -- 缺少 quality command 或外部工具時,會在 report 或 `doctor` 中標示為 skipped/missing。 - -## 常用指令摘要 - -```bash -pnpm test -pnpm typecheck -node src/cli.js doctor -node src/cli.js scan -node src/cli.js plan -node src/cli.js run -node src/cli.js diff -node src/cli.js verify +pnpm --filter html-transform typecheck +pnpm --filter html-transform test +node packages/html-transform/src/cli.js doctor +node packages/html-transform/src/cli.js scan ``` diff --git a/package.json b/package.json index 9d7d94d..937293b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "html-transform", "version": "0.1.0", - "description": "Prototype-driven orchestration framework for transforming HTML prototypes into Vuetify implementations.", + "description": "Prototype evidence tool for capturing HTML screenshots and page contracts.", "packageManager": "pnpm@10.10.0", "type": "module", "bin": { @@ -17,7 +17,8 @@ "playwright-cli:install-browser": "playwright-cli install-browser" }, "devDependencies": { - "@playwright/cli": "^0.1.8" + "@playwright/cli": "^0.1.8", + "vite": "^8.0.10" }, "engines": { "node": ">=20" diff --git a/src/cli.js b/src/cli.js index d859022..768b925 100755 --- a/src/cli.js +++ b/src/cli.js @@ -1,49 +1,27 @@ #!/usr/bin/env node import { scan } from './stages/scan.js' -import { plan } from './stages/plan.js' -import { runTasks } from './stages/run.js' -import { diff } from './stages/diff.js' -import { verify } from './stages/verify.js' -import { status } from './stages/status.js' import { doctor } from './stages/doctor.js' const command = process.argv[2] ?? 'help' -const flags = new Set(process.argv.slice(3)) async function main() { if (command === 'scan') await scan() - else if (command === 'plan') await plan() - else if (command === 'run') await runTasks({ retryFailed: flags.has('--retry-failed') }) - else if (command === 'diff') await diff() - else if (command === 'verify') await verify() - else if (command === 'go') { - await scan() - await plan({ assumeYes: flags.has('--yes') }) - await runTasks({ retryFailed: false }) - await diff() - await verify() - } else if (command === 'status') await status() else if (command === 'doctor') await doctor() - else printHelp() + else { + printHelp() + if (command !== 'help') process.exitCode = 1 + } } function printHelp() { console.log(`Usage: ht Commands: - scan Capture and extract LayoutSpec artifacts - plan Build TaskList and project/API contracts - run Execute pending tasks into output/ - run --retry-failed - diff Build visual similarity report - verify Run quality, DOM, and flow checks - go [--yes] Run scan, plan, run, diff, verify - status Show TaskList summary - doctor Check local prerequisites`) + doctor Check scan prerequisites + scan Capture prototype evidence and build page contracts`) } main().catch((error) => { console.error(error instanceof Error ? error.message : error) process.exitCode = 1 }) - diff --git a/src/lib/agent.js b/src/lib/agent.js deleted file mode 100644 index d1f1e15..0000000 --- a/src/lib/agent.js +++ /dev/null @@ -1,31 +0,0 @@ -import { spawn } from 'node:child_process' - -const commands = { - codex: ['codex'], - 'claude-code': ['claude'], - gemini: ['gemini'] -} - -/** - * 將設定中的 agent 名稱解析成可執行命令。 - * - * @param {string} agent `ht.config.*` 中的 agent 名稱。 - * @returns {string[]} 命令與預設參數。 - */ -export function resolveAgentCommand(agent) { - return commands[agent] ?? [agent] -} - -/** - * 檢查命令是否能從目前 shell PATH 找到。 - * - * @param {string} command 要檢查的可執行檔名稱。 - * @returns {Promise} 找得到命令時為 `true`。 - */ -export async function commandExists(command) { - return new Promise((resolve) => { - const child = spawn('which', [command], { stdio: 'ignore' }) - child.on('close', (code) => resolve(code === 0)) - child.on('error', () => resolve(false)) - }) -} diff --git a/src/lib/config.js b/src/lib/config.js index 69e3f7e..f208677 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -5,34 +5,11 @@ import { defineConfig } from '../index.js' const defaultConfig = { prototype: './packages/prototype', - frontend: './packages/web', - backend: './packages/api', - output: './packages/result', - agent: 'codex', vision: { viewport: { width: 1440, height: 900 }, captureStates: ['default'], includeDomSnapshot: true, includeAccessibilityTree: true - }, - project: { - packageManager: 'pnpm', - frontendFramework: 'vue3', - uiLibrary: 'vuetify3', - qualityCommands: { - typecheck: 'pnpm typecheck', - lint: 'pnpm lint', - test: 'pnpm test' - } - }, - plan: { - interactiveReview: false, - requireApiContractMatch: true - }, - diff: { - scoreThreshold: 75, - requireDomChecks: true, - requireFlowChecks: true } } @@ -72,25 +49,12 @@ function normalizeConfig(config, cwd) { const merged = { ...defaultConfig, ...config, - vision: { ...defaultConfig.vision, ...config.vision }, - project: { - ...defaultConfig.project, - ...config.project, - qualityCommands: { - ...defaultConfig.project.qualityCommands, - ...config.project?.qualityCommands - } - }, - plan: { ...defaultConfig.plan, ...config.plan }, - diff: { ...defaultConfig.diff, ...config.diff } + vision: { ...defaultConfig.vision, ...config.vision } } return { ...merged, cwd, prototypeDir: resolve(cwd, merged.prototype), - frontendDir: resolve(cwd, merged.frontend), - backendDir: resolve(cwd, merged.backend), - outputDir: resolve(cwd, merged.output), htDir: resolve(cwd, '.ht') } } diff --git a/src/lib/files.js b/src/lib/files.js index d0101eb..830d216 100644 --- a/src/lib/files.js +++ b/src/lib/files.js @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto' import { dirname, extname, join, relative } from 'node:path' /** - * 確保資料夾存在,供 stage 在寫入 `.ht/` 與 output artifact 前使用。 + * 確保資料夾存在,供 stage 在寫入 `.ht/` artifact 前使用。 * * @param {string} path 要建立的資料夾路徑。 * @returns {Promise} diff --git a/src/lib/html.js b/src/lib/html.js index ded90fb..8fdc342 100644 --- a/src/lib/html.js +++ b/src/lib/html.js @@ -85,7 +85,7 @@ export function visibleText(html) { } /** - * 將 deterministic region evidence 轉成目前 MVP 使用的 Vuetify-oriented RegionSpec。 + * 將 deterministic region evidence 轉成相容用的 Vuetify-oriented RegionSpec。 * * @param {object} region `extractRegions` 產生的區塊。 * @returns {object} 後續 plan/run stage 使用的 RegionSpec。 @@ -119,10 +119,311 @@ export function inferRegionSpec(region) { } } +export function buildPageContract({ page, source, html, regions, domSummary, screenshotPath }) { + const sourceSummary = summarizeHtml(html) + const summary = mergeSummary(sourceSummary, domSummary) + return { + page, + source, + screenshot: screenshotPath, + title: summary.title, + sections: regions.map((region) => ({ + id: region.id, + name: region.name, + role: region.semantic_role, + textSamples: visibleText(region.html).slice(0, 8) + })), + forms: buildForms(summary), + tables: buildTables(html), + actions: summary.buttons.map((label, index) => ({ + label, + kind: index === 0 ? 'primary' : 'secondary', + observableResult: null + })), + textSamples: summary.textSamples, + vuetifyComponents: inferPageComponents(html, regions), + apiDependencies: [], + warnings: [] + } +} + +export function validatePageContract(contract, evidence = {}) { + const warnings = [] + let nullFieldCount = 0 + let fieldCount = 0 + const textEvidence = new Set([ + ...(evidence.headings ?? []), + ...(evidence.buttons ?? []), + ...(evidence.labels ?? []), + ...(evidence.textSamples ?? []) + ].map(normalizeText)) + + for (const sample of contract.textSamples) { + if (!textEvidence.has(normalizeText(sample))) warnings.push(`textSamples: evidence 中找不到「${sample}」`) + } + for (const action of contract.actions) { + if (!textEvidence.has(normalizeText(action.label))) warnings.push(`actions: evidence 中找不到「${action.label}」`) + } + for (const form of contract.forms) { + for (const field of form.fields) { + if (field.label && !textEvidence.has(normalizeText(field.label))) { + warnings.push(`forms: evidence 中找不到欄位 label「${field.label}」`) + } + } + } + for (const component of contract.vuetifyComponents) { + if (!vuetifyWhitelist.has(component)) warnings.push(`vuetifyComponents: invalid Vuetify component ${component}`) + } + + countNulls(contract) + return { + autoFixedCount: 0, + fieldCount, + nullFieldCount, + nullFieldRatio: fieldCount === 0 ? 0 : nullFieldCount / fieldCount, + requiresHumanReview: warnings.length > 0 || (fieldCount > 0 && nullFieldCount / fieldCount > 0.3), + warnings + } + + function countNulls(value) { + if (Array.isArray(value)) return value.forEach(countNulls) + if (value && typeof value === 'object') return Object.values(value).forEach(countNulls) + fieldCount += 1 + if (value === null) nullFieldCount += 1 + } +} + +export function buildAppMap(layoutSpecs, prototypeDir) { + const routes = layoutSpecs.map((spec) => { + const relativeSource = spec.source.startsWith(prototypeDir) + ? spec.source.slice(prototypeDir.length).replace(/^\/+/, '') + : spec.source + const route = inferRoute(spec, relativeSource) + return { + prototype: relativeSource, + page: spec.page, + title: spec.pageContract.title, + kind: route.kind, + module: route.module, + targetRole: route.targetRole, + layout: route.layout, + usePrototypeStyle: false, + usePrototypeContent: route.usePrototypeContent, + routeHint: route.routeHint, + evidence: { + uiContract: `.ht/spec/${relativeSource.replace(/\.html$/, '.ui-contract.md')}`, + spec: `.ht/spec/${relativeSource.replace(/\.html$/, '.spec.json')}`, + screenshot: spec.pageContract.screenshot, + textSamples: spec.pageContract.textSamples, + actions: spec.pageContract.actions.map((action) => action.label), + formFieldCount: spec.pageContract.forms.reduce((total, form) => total + form.fields.length, 0), + tableCount: spec.pageContract.tables.length + } + } + }) + return { + version: 1, + generatedAt: new Date().toISOString(), + rules: { + layoutSource: 'preparation/skt-vuetify-templates', + loginStyleSource: 'preparation/skt-vuetify-templates', + prototypeStylePolicy: 'content-only', + prototypeOuterFramePolicy: 'ignore' + }, + modules: buildModules(routes), + routes + } +} + +function inferRoute(spec, relativeSource) { + const path = relativeSource.replace(/\\/g, '/') + const segments = path.split('/') + const module = segments.length > 1 ? segments[0] : 'root' + const basename = segments.at(-1) ?? spec.page + const text = [ + spec.pageContract.title, + ...spec.pageContract.textSamples, + ...spec.pageContract.actions.map((action) => action.label), + ...spec.pageContract.sections.flatMap((section) => section.textSamples) + ].filter(Boolean).join(' ') + + if (/portal\/forget-password\.html$/i.test(path)) { + return { + kind: 'auth', + module, + targetRole: 'forgot-password', + layout: 'template-auth', + usePrototypeContent: true, + routeHint: '/forgot-password' + } + } + if (/portal\/login\.html$/i.test(path) || hasAny(text, ['登入']) || hasPasswordField(spec)) { + return { + kind: 'auth', + module, + targetRole: 'login', + layout: 'template-auth', + usePrototypeContent: true, + routeHint: '/login' + } + } + if (/portal\/(app-layout|menu-pane|index)\.html$/i.test(path) || hasAny(text, ['隱藏選單', '登  出', '登出', '修改密碼'])) { + return { + kind: 'legacy-shell-reference', + module, + targetRole: 'layout-reference', + layout: 'ignore', + usePrototypeContent: false, + routeHint: null + } + } + if (/portal\/privacy-consent\.html$/i.test(path)) { + return { + kind: 'auth-support', + module, + targetRole: 'privacy-consent', + layout: 'template-auth', + usePrototypeContent: true, + routeHint: '/privacy-consent' + } + } + if (/\/index\.html$/i.test(path) && module !== 'portal' && hasAny(text, ['導覽', '返回雛型導覽'])) { + return { + kind: 'prototype-navigation', + module, + targetRole: 'navigation-reference', + layout: 'ignore', + usePrototypeContent: false, + routeHint: null + } + } + if (module !== 'portal' || hasFeatureEvidence(spec)) { + return { + kind: 'feature-page', + module, + targetRole: 'app-content', + layout: 'template-app', + usePrototypeContent: true, + routeHint: `/${module}/${basename.replace(/\.html$/i, '').replaceAll('_', '-')}` + } + } + return { + kind: 'reference', + module, + targetRole: 'content-reference', + layout: 'template-app', + usePrototypeContent: true, + routeHint: `/${basename.replace(/\.html$/i, '').replaceAll('_', '-')}` + } +} + +function buildModules(routes) { + const modules = new Map() + for (const route of routes) { + const current = modules.get(route.module) ?? { + name: route.module, + featurePageCount: 0, + authPageCount: 0, + referencePageCount: 0 + } + if (route.kind === 'feature-page') current.featurePageCount += 1 + else if (route.kind === 'auth' || route.kind === 'auth-support') current.authPageCount += 1 + else current.referencePageCount += 1 + modules.set(route.module, current) + } + return [...modules.values()].map((module) => ({ + ...module, + kind: module.featurePageCount > 0 ? 'feature-module' : module.authPageCount > 0 ? 'entry-module' : 'reference-module' + })) +} + +function hasPasswordField(spec) { + return spec.pageContract.forms.some((form) => form.fields.some((field) => field.type === 'password')) +} + +function hasFeatureEvidence(spec) { + return spec.pageContract.tables.some((table) => table.headers.length > 0) || + spec.pageContract.forms.some((form) => form.fields.length > 0) || + spec.pageContract.actions.length > 0 +} + +function hasAny(text, needles) { + return needles.some((needle) => text.includes(needle)) +} + +function mergeSummary(sourceSummary, domSummary = {}) { + return { + title: domSummary.title ?? sourceSummary.title, + headings: unique([...(domSummary.headings ?? []), ...sourceSummary.headings]), + buttons: unique([...(domSummary.buttons ?? []), ...sourceSummary.buttons]), + labels: unique([...(domSummary.labels ?? []), ...sourceSummary.labels]), + inputs: domSummary.inputs?.length ? domSummary.inputs : sourceSummary.inputs, + textSamples: unique([...(domSummary.textSamples ?? []), ...sourceSummary.textSamples]).slice(0, 3) + } +} + +function buildForms(summary) { + const fields = summary.inputs.filter((field) => !['hidden', 'button', 'submit', 'reset', 'image'].includes(field.type ?? '')) + if (fields.length === 0) return [] + return [{ + labels: summary.labels, + fields: fields.map((field, index) => ({ + name: field.name, + label: summary.labels[index] ?? null, + type: field.type ?? field.tag, + required: field.required + })), + primaryActions: summary.buttons.slice(0, 1), + secondaryActions: summary.buttons.slice(1) + }] +} + +function buildTables(html) { + return collect(html, /]*>([\s\S]*?)<\/table>/gi).map((item, index) => ({ + id: `table-${index + 1}`, + headers: collect(item[1], /]*>([\s\S]*?)<\/th>/gi).map((header) => cleanText(header[1])).filter(Boolean), + sampleRows: collect(item[1], /]*>([\s\S]*?)<\/tr>/gi).slice(0, 3).map((row) => + collect(row[1], /]*>([\s\S]*?)<\/td>/gi).map((cell) => cleanText(cell[1])).filter(Boolean) + ).filter((row) => row.length > 0) + })) +} + +function inferPageComponents(html, regions) { + const components = new Set(['VContainer']) + if (/<(form|input|select|textarea)\b/i.test(html)) components.add('VForm') + if (/ region.semantic_role === 'navigation')) components.add('VNavigationDrawer') + return [...components] +} + function extractColors(html) { return [...new Set((html.match(/#[0-9a-f]{3,8}\b|rgba?\([^)]+\)/gi) ?? []).slice(0, 12))] } +const vuetifyWhitelist = new Set([ + 'VApp', + 'VBtn', + 'VCard', + 'VContainer', + 'VForm', + 'VList', + 'VNavigationDrawer', + 'VTable', + 'VTextField', + 'VToolbar' +]) + +function unique(values) { + return [...new Set(values.filter(Boolean))] +} + +function normalizeText(value) { + return String(value ?? '').replace(/\s+/g, ' ').trim() +} + function attr(attrs, name) { const match = attrs.match(new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, 'i')) return match?.[1] ?? null diff --git a/src/lib/playwright-cli.js b/src/lib/playwright-cli.js index f578803..1278e03 100644 --- a/src/lib/playwright-cli.js +++ b/src/lib/playwright-cli.js @@ -1,7 +1,17 @@ import { spawn } from 'node:child_process' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { exists } from './files.js' -import { commandExists } from './agent.js' + +const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))) + +export async function commandExists(command) { + return new Promise((resolve) => { + const child = spawn('which', [command], { stdio: 'ignore' }) + child.on('close', (code) => resolve(code === 0)) + child.on('error', () => resolve(false)) + }) +} /** * 依專案規則解析 Playwright CLI,不依賴使用者全域環境作為第一選擇。 @@ -12,6 +22,8 @@ import { commandExists } from './agent.js' export async function resolvePlaywrightCli(cwd = process.cwd()) { const localBin = join(cwd, 'node_modules/.bin/playwright-cli') if (await exists(localBin)) return { command: localBin, args: [], source: 'local' } + const packageBin = join(packageRoot, 'node_modules/.bin/playwright-cli') + if (await exists(packageBin)) return { command: packageBin, args: [], source: 'package-local' } if (await commandExists('playwright-cli')) return { command: 'playwright-cli', args: [], source: 'global' } if (await commandExists('npx')) return { command: 'npx', args: ['--no-install', 'playwright-cli'], source: 'npx-local' } return null diff --git a/src/stages/diff.js b/src/stages/diff.js deleted file mode 100644 index 74de719..0000000 --- a/src/stages/diff.js +++ /dev/null @@ -1,51 +0,0 @@ -import { writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { loadConfig } from '../lib/config.js' -import { ensureDir, listFiles, readJson } from '../lib/files.js' - -/** - * 執行 Stage 4:依現有 LayoutSpec 產生 deterministic placeholder diff report。 - * - * @returns {Promise} - */ -export async function diff() { - const config = await loadConfig() - const specs = await listFiles(join(config.htDir, 'spec'), ['.json']) - if (specs.length === 0) throw new Error('找不到 spec,請先執行 ht scan') - await ensureDir(join(config.htDir, 'diff')) - for (const file of specs.filter((file) => file.endsWith('.spec.json'))) { - const spec = await readJson(file) - const reportPath = join(config.htDir, 'diff', `${spec.page}.diff.html`) - await writeFile(reportPath, renderDiffReport(config, spec)) - console.log(`diff report: ${reportPath}`) - } -} - -function renderDiffReport(config, spec) { - const rows = spec.decomposition.map((region) => { - const score = 75 - const warning = score < config.diff.scoreThreshold ? ' class="warning"' : '' - return `${region.id}${region.name}${score}${region.screenshotPath}` - }).join('\n') - return ` - - - - ${spec.page} diff - - - -

${spec.page} Diff

- - - ${rows} -
RegionNameSimilarityPrototype Screenshot
- - -` -} diff --git a/src/stages/doctor.js b/src/stages/doctor.js index 46f6695..a4ffbde 100644 --- a/src/stages/doctor.js +++ b/src/stages/doctor.js @@ -1,8 +1,10 @@ -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' import { loadConfig } from '../lib/config.js' -import { commandExists, resolveAgentCommand } from '../lib/agent.js' import { exists } from '../lib/files.js' -import { playwrightCliAvailable, resolvePlaywrightCli } from '../lib/playwright-cli.js' +import { commandExists, playwrightCliAvailable, resolvePlaywrightCli } from '../lib/playwright-cli.js' + +const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))) /** * 檢查本機執行 HTML Transform pipeline 所需的資料夾與命令。 @@ -11,15 +13,11 @@ import { playwrightCliAvailable, resolvePlaywrightCli } from '../lib/playwright- */ export async function doctor() { const config = await loadConfig() - const agentCommand = resolveAgentCommand(config.agent)[0] const playwrightCli = await resolvePlaywrightCli(config.cwd) const checks = [ ['prototype directory', await exists(config.prototypeDir)], - ['frontend directory', await exists(config.frontendDir)], - ['backend directory', await exists(config.backendDir)], - ['output directory parent', await exists(join(config.outputDir, '..'))], [`playwright-cli${playwrightCli ? ` (${playwrightCli.source})` : ''}`, await playwrightCliAvailable(config.cwd)], - [`agent ${config.agent}`, await commandExists(agentCommand)], + ['vite package', await exists(join(packageRoot, 'node_modules/vite/package.json'))], ['pnpm', await commandExists('pnpm')] ] for (const [name, ok] of checks) { diff --git a/src/stages/plan.js b/src/stages/plan.js deleted file mode 100644 index 1f6a579..0000000 --- a/src/stages/plan.js +++ /dev/null @@ -1,126 +0,0 @@ -import { createInterface } from 'node:readline/promises' -import { stdin as input, stdout as output } from 'node:process' -import { basename, join } from 'node:path' -import { loadConfig } from '../lib/config.js' -import { listFiles, readJson, writeJson } from '../lib/files.js' - -/** - * 執行 Stage 2:依 LayoutSpec 建立 tasklist、component mapping 與專案/API contract artifacts。 - * - * @param {object} options 執行選項。 - * @param {boolean} [options.assumeYes] 跳過互動 review 提示。 - * @returns {Promise} - */ -export async function plan(options = {}) { - const config = await loadConfig() - const specs = await listFiles(join(config.htDir, 'spec'), ['.json']) - const layoutSpecs = [] - for (const file of specs.filter((file) => file.endsWith('.spec.json'))) { - layoutSpecs.push(await readJson(file)) - } - if (layoutSpecs.length === 0) throw new Error('找不到 LayoutSpec,請先執行 ht scan') - - const conventions = await buildProjectConventions(config) - const apiContract = await buildApiContract(config) - const mapping = buildComponentMapping(layoutSpecs) - const taskList = buildTaskList(config, layoutSpecs, apiContract) - - await writeJson(join(config.htDir, 'plan/component-mapping.json'), mapping) - await writeJson(join(config.htDir, 'plan/tasklist.json'), taskList) - await writeMarkdown(join(config.htDir, 'plan/project-conventions.md'), conventions) - await writeMarkdown(join(config.htDir, 'plan/api-contract.md'), apiContract.markdown) - - if (config.plan.interactiveReview && !options.assumeYes) { - console.log(renderPreview(taskList)) - const rl = createInterface({ input, output }) - const answer = await rl.question('繼續執行 Stage 6?輸入 y 繼續:') - rl.close() - if (answer.trim().toLowerCase() !== 'y') { - console.log('plan 已產出,等待手動 review') - return - } - } - console.log(`plan 完成:${taskList.tasks.length} 個 task`) -} - -async function buildProjectConventions(config) { - const files = await listFiles(config.frontendDir, ['.vue', '.js', '.ts']) - const sample = files.slice(0, 20).map((file) => `- ${file.replace(config.frontendDir, '')}`).join('\n') || '- frontend directory missing or empty' - return `# Project Conventions - -- Routing: ${files.some((file) => /router/i.test(file)) ? 'router files detected' : 'not detected'} -- Vuetify: ${files.some((file) => /vuetify/i.test(file)) ? 'Vuetify usage detected' : 'not detected'} -- Forms: ${files.some((file) => /form/i.test(file)) ? 'form files detected' : 'not detected'} -- API client/composable: ${files.some((file) => /(api|composable|service)/i.test(file)) ? 'detected' : 'not detected'} -- Pinia: ${files.some((file) => /pinia|store/i.test(file)) ? 'detected' : 'not detected'} -- Validation: ${files.some((file) => /valid/i.test(file)) ? 'detected' : 'not detected'} -- i18n: ${files.some((file) => /i18n|locale/i.test(file)) ? 'detected' : 'not detected'} -- Tests: ${files.some((file) => /\.(test|spec)\./i.test(file)) ? 'detected' : 'not detected'} - -## Sample Files - -${sample} -` -} - -async function buildApiContract(config) { - const files = await listFiles(config.backendDir, ['.json', '.yaml', '.yml', '.ts', '.js', '.md']) - const endpoints = [] - for (const file of files) { - const match = file.match(/openapi|swagger/i) - if (match) endpoints.push({ method: 'OPENAPI', path: file.replace(config.backendDir, ''), source: file }) - } - return { - endpoints, - markdown: `# API Contract - -${endpoints.length ? endpoints.map((endpoint) => `- ${endpoint.method} ${endpoint.path}`).join('\n') : '- No API contract detected'} -` - } -} - -function buildComponentMapping(layoutSpecs) { - return Object.fromEntries( - layoutSpecs.flatMap((spec) => - spec.regions.map((region) => [ - region.regionId, - { page: spec.page, vuetifyComponent: region.vuetifyComponent, targetKind: 'component' } - ]) - ) - ) -} - -function buildTaskList(config, layoutSpecs, apiContract) { - const tasks = layoutSpecs.flatMap((spec) => - spec.regions.map((region, index) => ({ - id: `${spec.page.replace(/\W+/g, '-').replace(/-$/, '')}-${region.regionId}`, - type: index === 0 ? 'page' : 'component', - targetFile: `${basename(spec.page, '.html')}/${region.regionId}.vue`, - specReference: region.regionId, - apiDependencies: [], - inputArtifacts: [ - `.ht/spec/${basename(spec.page, '.html')}.spec.json`, - '.ht/plan/project-conventions.md', - '.ht/plan/api-contract.md', - '.ht/plan/component-mapping.json' - ], - acceptanceChecks: ['target file exists', 'contains visible text samples'], - dependsOn: [], - status: 'pending', - contractProposal: config.plan.requireApiContractMatch && apiContract.endpoints.length === 0 ? [] : undefined - })) - ) - return { version: 1, generatedAt: new Date().toISOString(), tasks } -} - -async function writeMarkdown(path, content) { - const { ensureDir } = await import('../lib/files.js') - const { dirname } = await import('node:path') - const { writeFile } = await import('node:fs/promises') - await ensureDir(dirname(path)) - await writeFile(path, content) -} - -function renderPreview(taskList) { - return taskList.tasks.map((task) => `${task.id} -> ${task.targetFile}`).join('\n') -} diff --git a/src/stages/run.js b/src/stages/run.js deleted file mode 100644 index 858805d..0000000 --- a/src/stages/run.js +++ /dev/null @@ -1,103 +0,0 @@ -import { copyFile, readFile, writeFile } from 'node:fs/promises' -import { basename, dirname, join } from 'node:path' -import { loadConfig } from '../lib/config.js' -import { ensureDir, exists, listFiles, readJson, writeJson } from '../lib/files.js' - -/** - * 執行 Stage 3:根據 TaskList 產生 output 內的 Vue/Vuetify 檔案與 run metadata。 - * - * @param {object} options 執行選項。 - * @param {boolean} [options.retryFailed] 僅重跑狀態為 `error` 的 task。 - * @returns {Promise} - */ -export async function runTasks(options = {}) { - const config = await loadConfig() - const taskListPath = join(config.htDir, 'plan/tasklist.json') - if (!(await exists(taskListPath))) throw new Error('找不到 TaskList,請先執行 ht plan') - const taskList = await readJson(taskListPath) - const runId = new Date().toISOString().replaceAll(':', '-').replace(/\..+/, '') - const runDir = join(config.htDir, 'runs', runId) - await ensureDir(join(runDir, 'agent')) - const specs = await loadSpecs(config) - for (const task of topoSort(taskList.tasks)) { - if (options.retryFailed ? task.status !== 'error' : task.status !== 'pending') continue - task.status = 'running' - await writeJson(taskListPath, taskList) - try { - const spec = findRegionSpec(specs, task.specReference) - const targetPath = join(config.outputDir, task.targetFile) - await backupIfExists(config, targetPath, task.id) - await ensureDir(dirname(targetPath)) - await writeFile(targetPath, renderVueComponent(spec)) - task.status = 'done' - await writeJson(join(runDir, 'agent', `${task.id}.json`), { - taskId: task.id, - changedFiles: [targetPath], - exitCode: 0, - stdoutSummary: 'deterministic renderer wrote Vue component', - stderrSummary: '' - }) - } catch (error) { - task.status = 'error' - task.error = error instanceof Error ? error.message : String(error) - } - await writeJson(taskListPath, taskList) - } - await writeJson(join(runDir, 'run.json'), { runId, startedAt: runId, taskCount: taskList.tasks.length }) - console.log(`run 完成:${runDir}`) -} - -async function loadSpecs(config) { - const files = await listFiles(join(config.htDir, 'spec'), ['.json']) - const specs = [] - for (const file of files.filter((file) => file.endsWith('.spec.json'))) specs.push(await readJson(file)) - return specs -} - -function findRegionSpec(specs, regionId) { - for (const spec of specs) { - const region = spec.regions.find((region) => region.regionId === regionId) - if (region) return region - } - throw new Error(`找不到 RegionSpec:${regionId}`) -} - -async function backupIfExists(config, targetPath, taskId) { - if (!(await exists(targetPath))) return - const backupPath = join(config.htDir, 'backup', taskId, basename(targetPath)) - await ensureDir(dirname(backupPath)) - await copyFile(targetPath, backupPath) -} - -function renderVueComponent(spec) { - const text = spec.textSamples.length ? spec.textSamples : [spec.name] - const actions = [...spec.uiContract.primaryActions, ...spec.uiContract.secondaryActions] - const fields = spec.uiContract.fields - return ` -` -} - -function topoSort(tasks) { - const sorted = [] - const remaining = new Map(tasks.map((task) => [task.id, task])) - while (remaining.size) { - const ready = [...remaining.values()].find((task) => task.dependsOn.every((id) => !remaining.has(id))) - if (!ready) throw new Error('TaskList dependency cycle detected') - sorted.push(ready) - remaining.delete(ready.id) - } - return sorted -} - -function escapeHtml(value) { - return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('"', '"') -} diff --git a/src/stages/scan.js b/src/stages/scan.js index 7081a4a..73835c5 100644 --- a/src/stages/scan.js +++ b/src/stages/scan.js @@ -1,13 +1,13 @@ +import { spawn } from 'node:child_process' import { readFile, writeFile } from 'node:fs/promises' import { basename, join } from 'node:path' import { loadConfig } from '../lib/config.js' import { artifactPath, ensureDir, exists, listFiles, readJson, sha256File, writeJson } from '../lib/files.js' -import { extractRegions, inferRegionSpec, summarizeHtml } from '../lib/html.js' +import { buildAppMap, buildPageContract, extractRegions, inferRegionSpec, summarizeHtml, validatePageContract } from '../lib/html.js' +import { resolvePlaywrightCli } from '../lib/playwright-cli.js' const viewports = [ - { name: 'desktop', width: 1440, height: 900 }, - { name: 'tablet', width: 768, height: 1024 }, - { name: 'mobile', width: 375, height: 812 } + { name: 'desktop', width: 1440, height: 900 } ] /** @@ -20,58 +20,66 @@ export async function scan() { const htmlFiles = await listFiles(config.prototypeDir, ['.html']) if (htmlFiles.length === 0) throw new Error(`找不到 HTML prototype:${config.prototypeDir}`) await ensureDir(config.htDir) + const plans = [] for (const file of htmlFiles) { - await scanFile(config, file) + plans.push(await prepareScanFile(config, file)) } + const needsCapture = plans.some((plan) => !plan.cacheHit) + const server = needsCapture ? await startPrototypeServer(config) : null + const layoutSpecs = [] + try { + for (const plan of plans) { + layoutSpecs.push(await scanFile(config, plan, server?.url)) + } + } finally { + await server?.close() + } + await writeJson(join(config.htDir, 'app-map.json'), buildAppMap(layoutSpecs, config.prototypeDir)) console.log(`scan 完成:${htmlFiles.length} 個 HTML 檔案`) } -async function scanFile(config, file) { - const html = await readFile(file, 'utf8') +async function prepareScanFile(config, file) { const hash = await sha256File(file) const name = artifactPath(config.prototypeDir, file) const cacheDir = join(config.htDir, 'cache/prototype', name) await ensureDir(cacheDir) const metadataPath = join(cacheDir, 'capture-metadata.json') const cacheHit = await isCaptureCacheHit(metadataPath, hash) + return { file, hash, name, cacheDir, metadataPath, cacheHit } +} - const capture = { - source: file, - hash, - viewports, - states: config.vision.captureStates, - externalResourceFailures: findExternalResources(html), - screenshots: [], - captureEngine: 'html-summary', - cacheHit +async function scanFile(config, plan, serverUrl) { + const { file, hash, name, cacheDir, metadataPath, cacheHit } = plan + const html = await readFile(file, 'utf8') + const capture = cacheHit + ? await readJson(metadataPath) + : await captureRenderedPrototype(config, plan, serverUrl) + const domSummary = cacheHit ? await readCachedOrSummarize(cacheDir, html) : capture.domSummary + + if (!cacheHit) { + await writeJson(join(cacheDir, 'dom-summary.json'), domSummary) + await writeJson(join(cacheDir, 'accessibility-tree.json'), capture.accessibilityTree) + await writeJson(metadataPath, capture) } - for (const viewport of viewports) { - for (const state of config.vision.captureStates) { - const screenshotPath = join(cacheDir, `${viewport.name}-${state}.png`) - if (!cacheHit) await writePlaceholderPng(screenshotPath) - capture.screenshots.push({ viewport: viewport.name, state, path: screenshotPath, status: cacheHit ? 'cache-hit' : 'placeholder' }) - } - } - - const domSummary = summarizeHtml(html) - await writeJson(join(cacheDir, 'dom-summary.json'), domSummary) - await writeJson(join(cacheDir, 'accessibility-tree.json'), buildAccessibilityTree(domSummary)) - await writeJson(metadataPath, capture) - - const decomposition = extractRegions(html).map(({ html: _html, ...region }) => ({ - ...region, - screenshotPath: join(cacheDir, `${region.id}.png`) - })) const regions = extractRegions(html) const regionSpecs = regions.map(inferRegionSpec) - const validationReport = validateSpecs(regionSpecs) + const screenshot = capture.screenshots.find((item) => item.viewport === 'desktop' && item.state === 'default') + const pageContract = buildPageContract({ + page: basename(file), + source: file, + html, + regions, + domSummary, + screenshotPath: screenshot?.path ?? null + }) + const validationReport = validateSpecs(regionSpecs, pageContract, domSummary) const layoutSpec = { source: file, hash, generatedAt: new Date().toISOString(), page: basename(file), - decomposition, + pageContract, regions: regionSpecs, validation: validationReport } @@ -79,22 +87,219 @@ async function scanFile(config, file) { await writeJson(join(config.htDir, 'spec', `${name}.spec.json`), layoutSpec) await writeJson(join(config.htDir, 'spec', `${name}.validation.json`), validationReport) await writeFile(join(config.htDir, 'spec', `${name}.ui-contract.md`), renderUiContract(layoutSpec)) + return layoutSpec +} + +async function captureRenderedPrototype(config, plan, serverUrl) { + if (!serverUrl) throw new Error('Stage 1 capture 需要啟動 prototype static server') + const resolved = await resolvePlaywrightCli(config.cwd) + if (!resolved) throw new Error('找不到 playwright-cli,請先安裝 @playwright/cli') + + const playwrightCwd = join(config.htDir, 'cache/playwright-cli') + await ensureDir(playwrightCwd) + const commandCwd = resolved.source === 'npx-local' ? config.cwd : playwrightCwd + const session = `ht-scan-${process.pid}-${plan.name.replace(/[^a-z0-9_-]+/gi, '-')}` + const url = new URL(`${encodePath(plan.name)}.html`, serverUrl).href + const screenshots = [] + const externalResourceFailures = [] + const consoleMessages = [] + let domSummary = null + let accessibilityTree = null + + await runPlaywrightCli(commandCwd, resolved, ['--session', session, 'open', 'about:blank']) + try { + for (const viewport of viewports) { + for (const state of config.vision.captureStates) { + const screenshotPath = join(plan.cacheDir, `${viewport.name}-${state}.png`) + const result = await runPlaywrightJson(commandCwd, resolved, session, captureScript(url, viewport, state, screenshotPath)) + screenshots.push({ + viewport: viewport.name, + state, + path: screenshotPath, + status: 'captured' + }) + externalResourceFailures.push(...result.externalResourceFailures) + consoleMessages.push(...result.consoleMessages) + domSummary ??= result.domSummary + accessibilityTree ??= result.accessibilityTree + } + } + } finally { + await runPlaywrightCli(commandCwd, resolved, ['--session', session, 'close']) + } + + return { + source: plan.file, + url, + hash: plan.hash, + viewports, + states: config.vision.captureStates, + screenshots, + domSummary, + accessibilityTree, + externalResourceFailures: dedupeEvents(externalResourceFailures), + consoleMessages: dedupeEvents(consoleMessages), + captureEngine: 'vite-playwright-cli', + cacheHit: false, + capturedAt: new Date().toISOString() + } +} + +function captureScript(url, viewport, state, screenshotPath) { + return `async page => { + const requestFailures = []; + const consoleMessages = []; + const failedResponses = []; + page.on('requestfailed', request => requestFailures.push({ + url: request.url(), + method: request.method(), + resourceType: request.resourceType(), + reason: request.failure()?.errorText ?? 'request failed' + })); + page.on('response', response => { + if (response.status() >= 400) failedResponses.push({ + url: response.url(), + status: response.status(), + statusText: response.statusText() + }); + }); + page.on('console', message => { + if (['error', 'warning'].includes(message.type())) consoleMessages.push({ + type: message.type(), + text: message.text() + }); + }); + await page.setViewportSize(${JSON.stringify({ width: viewport.width, height: viewport.height })}); + await page.goto(${JSON.stringify(url)}, { waitUntil: 'networkidle' }); + if (${JSON.stringify(state)} === 'scrolled') { + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)); + } else { + await page.evaluate(() => window.scrollTo(0, 0)); + } + await page.waitForTimeout(500); + await page.screenshot({ path: ${JSON.stringify(screenshotPath)}, fullPage: true }); + const domSummary = await page.evaluate(() => { + const text = element => (element.innerText || element.textContent || '').replace(/\\s+/g, ' ').trim(); + const attr = (element, name) => element.getAttribute(name); + const visibleText = Array.from(document.body.querySelectorAll('body *')) + .map(text) + .filter(Boolean) + .slice(0, 50); + const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6')).map(text).filter(Boolean); + const buttons = Array.from(document.querySelectorAll('button, a, input[type="button"], input[type="submit"], input[type="reset"]')) + .map(element => text(element) || element.value || attr(element, 'aria-label') || attr(element, 'title') || '') + .filter(Boolean); + const labels = Array.from(document.querySelectorAll('label')).map(text).filter(Boolean); + const inputs = Array.from(document.querySelectorAll('input, select, textarea')).map(element => ({ + tag: element.tagName.toLowerCase(), + type: attr(element, 'type'), + name: attr(element, 'name') || attr(element, 'id'), + required: element.required + })); + return { + title: document.title || null, + headings, + buttons, + labels, + inputs, + textSamples: [...headings, ...labels, ...buttons, ...visibleText].filter(Boolean).slice(0, 3) + }; + }); + const ariaSnapshot = await page.locator('body').ariaSnapshot(); + return { + domSummary, + accessibilityTree: { format: 'playwright-aria-snapshot', snapshot: ariaSnapshot }, + externalResourceFailures: [...requestFailures, ...failedResponses], + consoleMessages + }; + }` +} + +async function startPrototypeServer(config) { + const { createServer } = await import('vite') + const server = await createServer({ + root: config.prototypeDir, + logLevel: 'silent', + server: { + host: '127.0.0.1', + port: 0, + strictPort: false + }, + appType: 'mpa' + }) + await server.listen() + const url = server.resolvedUrls?.local?.[0] + if (!url) { + await server.close() + throw new Error('無法取得 Vite prototype server URL') + } + return { + url, + close: () => server.close() + } +} + +async function runPlaywrightJson(cwd, resolved, session, code) { + const output = await runPlaywrightCli(cwd, resolved, ['--json', '--session', session, 'run-code', code]) + const parsed = JSON.parse(output) + if (parsed.error) throw new Error(parsed.error) + return JSON.parse(parsed.result) +} + +async function runPlaywrightCli(cwd, resolved, args) { + return new Promise((resolve, reject) => { + const child = spawn(resolved.command, [...resolved.args, ...args], { cwd }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('close', (code) => { + if (code === 0) resolve(stdout) + else reject(new Error((stderr || stdout).trim())) + }) + child.on('error', reject) + }) +} + +async function readCachedOrSummarize(cacheDir, html) { + const domSummaryPath = join(cacheDir, 'dom-summary.json') + if (await exists(domSummaryPath)) return readJson(domSummaryPath) + return summarizeHtml(html) +} + +function encodePath(path) { + return path.split('/').map(encodeURIComponent).join('/') +} + +function dedupeEvents(events) { + const seen = new Set() + return events.filter((event) => { + const key = JSON.stringify(event) + if (seen.has(key)) return false + seen.add(key) + return true + }) } async function isCaptureCacheHit(metadataPath, hash) { if (!(await exists(metadataPath))) return false try { const metadata = await readJson(metadataPath) - return metadata.hash === hash + return metadata.hash === hash && metadata.captureEngine === 'vite-playwright-cli' } catch { return false } } -function validateSpecs(specs) { +function validateSpecs(specs, pageContract, domSummary) { const warnings = [] let nullFieldCount = 0 let fieldCount = 0 + const contractValidation = validatePageContract(pageContract, domSummary) for (const spec of specs) { countNulls(spec) if (!vuetifyWhitelist.has(spec.vuetifyComponent)) { @@ -108,54 +313,60 @@ function validateSpecs(specs) { fieldCount += 1 if (value === null) nullFieldCount += 1 } + const totalFieldCount = fieldCount + contractValidation.fieldCount + const totalNullFieldCount = nullFieldCount + contractValidation.nullFieldCount return { - autoFixedCount: warnings.length, - nullFieldCount, - nullFieldRatio: fieldCount === 0 ? 0 : nullFieldCount / fieldCount, - requiresHumanReview: fieldCount > 0 && nullFieldCount / fieldCount > 0.3, - warnings + autoFixedCount: warnings.length + contractValidation.autoFixedCount, + fieldCount: totalFieldCount, + nullFieldCount: totalNullFieldCount, + nullFieldRatio: totalFieldCount === 0 ? 0 : totalNullFieldCount / totalFieldCount, + requiresHumanReview: contractValidation.requiresHumanReview || (totalFieldCount > 0 && totalNullFieldCount / totalFieldCount > 0.3), + warnings: [...warnings, ...contractValidation.warnings], + contract: contractValidation } } function renderUiContract(spec) { const lines = [`# UI Contract: ${spec.page}`, ''] - for (const region of spec.regions) { - lines.push(`## ${region.name}`, '') - lines.push(`- Region: ${region.regionId}`) - lines.push(`- Vuetify: ${region.vuetifyComponent}`) - lines.push(`- Text: ${region.textSamples.join(', ') || 'none'}`) - lines.push(`- Labels: ${region.uiContract.labels.join(', ') || 'none'}`) - lines.push(`- Actions: ${[...region.uiContract.primaryActions, ...region.uiContract.secondaryActions].join(', ') || 'none'}`) - lines.push('') + const contract = spec.pageContract + lines.push(`- Source: ${contract.source}`) + lines.push(`- Screenshot: ${contract.screenshot ?? 'none'}`) + lines.push(`- Title: ${contract.title ?? 'none'}`) + lines.push(`- Text: ${contract.textSamples.join(', ') || 'none'}`) + lines.push(`- Vuetify: ${contract.vuetifyComponents.join(', ') || 'none'}`) + lines.push('') + lines.push('## Sections', '') + for (const section of contract.sections) { + lines.push(`- ${section.id}: ${section.name} (${section.role})`) } + lines.push('') + lines.push('## Forms', '') + if (contract.forms.length === 0) lines.push('- none') + for (const form of contract.forms) { + lines.push(`- Labels: ${form.labels.join(', ') || 'none'}`) + lines.push(`- Fields: ${form.fields.map((field) => `${field.label ?? field.name ?? 'field'}${field.required ? ' required' : ''}`).join(', ') || 'none'}`) + lines.push(`- Actions: ${[...form.primaryActions, ...form.secondaryActions].join(', ') || 'none'}`) + } + lines.push('') + lines.push('## Tables', '') + if (contract.tables.length === 0) lines.push('- none') + for (const table of contract.tables) { + lines.push(`- ${table.id}: ${table.headers.join(', ') || 'no headers'}`) + } + lines.push('') + lines.push('## Actions', '') + if (contract.actions.length === 0) lines.push('- none') + for (const action of contract.actions) { + lines.push(`- ${action.kind}: ${action.label}`) + } + lines.push('') + lines.push('## Warnings', '') + const warnings = spec.validation.warnings + if (warnings.length === 0) lines.push('- none') + for (const warning of warnings) lines.push(`- ${warning}`) return `${lines.join('\n')}\n` } -function buildAccessibilityTree(summary) { - return { - role: 'document', - title: summary.title, - children: [ - ...summary.headings.map((name) => ({ role: 'heading', name })), - ...summary.buttons.map((name) => ({ role: 'button', name })), - ...summary.labels.map((name) => ({ role: 'label', name })) - ] - } -} - -function findExternalResources(html) { - const urls = [...html.matchAll(/\b(?:src|href)=["'](https?:\/\/[^"']+)["']/gi)].map((match) => match[1]) - return urls.map((url) => ({ url, reason: 'external resource not fetched in deterministic scan' })) -} - -async function writePlaceholderPng(path) { - const onePixelTransparentPng = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', - 'base64' - ) - await writeFile(path, onePixelTransparentPng) -} - const vuetifyWhitelist = new Set([ 'VApp', 'VBtn', diff --git a/src/stages/status.js b/src/stages/status.js deleted file mode 100644 index 41655a4..0000000 --- a/src/stages/status.js +++ /dev/null @@ -1,22 +0,0 @@ -import { join } from 'node:path' -import { loadConfig } from '../lib/config.js' -import { exists, readJson } from '../lib/files.js' - -/** - * 顯示目前 TaskList 中各 task 狀態的數量。 - * - * @returns {Promise} - */ -export async function status() { - const config = await loadConfig() - const path = join(config.htDir, 'plan/tasklist.json') - if (!(await exists(path))) { - console.log('尚未產生 TaskList') - return - } - const taskList = await readJson(path) - const counts = Object.groupBy(taskList.tasks, (task) => task.status) - for (const state of ['pending', 'running', 'done', 'error']) { - console.log(`${state}: ${counts[state]?.length ?? 0}`) - } -} diff --git a/src/stages/verify.js b/src/stages/verify.js deleted file mode 100644 index cec1062..0000000 --- a/src/stages/verify.js +++ /dev/null @@ -1,50 +0,0 @@ -import { spawn } from 'node:child_process' -import { join } from 'node:path' -import { loadConfig } from '../lib/config.js' -import { exists, listFiles, readJson, writeJson } from '../lib/files.js' - -/** - * 執行 Stage 5:執行設定中的 quality commands,並寫出 MVP verification report。 - * - * @returns {Promise} - */ -export async function verify() { - const config = await loadConfig() - const quality = {} - for (const [name, command] of Object.entries(config.project.qualityCommands ?? {})) { - quality[name] = await runQualityCommand(command, config.cwd) - } - const domChecks = await runDomChecks(config) - const flowChecks = { status: 'skipped', reason: 'no executable interaction model in MVP artifacts' } - const report = { generatedAt: new Date().toISOString(), quality, domChecks, flowChecks } - await writeJson(join(config.htDir, 'verify/verification-report.json'), report) - console.log(`verify report: ${join(config.htDir, 'verify/verification-report.json')}`) -} - -async function runQualityCommand(command, cwd) { - if (!command) return { status: 'skipped', reason: 'command not configured' } - const [bin, ...args] = command.split(/\s+/) - return new Promise((resolve) => { - const child = spawn(bin, args, { cwd, stdio: 'pipe' }) - let stdout = '' - let stderr = '' - child.stdout.on('data', (chunk) => { stdout += chunk }) - child.stderr.on('data', (chunk) => { stderr += chunk }) - child.on('error', (error) => resolve({ status: 'skipped', reason: error.message })) - child.on('close', (code) => resolve({ - status: code === 0 ? 'passed' : 'failed', - exitCode: code, - stdoutSummary: stdout.slice(0, 1000), - stderrSummary: stderr.slice(0, 1000) - })) - }) -} - -async function runDomChecks(config) { - const taskListPath = join(config.htDir, 'plan/tasklist.json') - if (!(await exists(taskListPath))) return { status: 'skipped', reason: 'TaskList missing' } - const taskList = await readJson(taskListPath) - const outputFiles = await listFiles(config.outputDir, ['.vue']) - const missing = taskList.tasks.filter((task) => !outputFiles.some((file) => file.endsWith(task.targetFile))) - return { status: missing.length === 0 ? 'passed' : 'failed', missingTargets: missing.map((task) => task.targetFile) } -} diff --git a/test/cli-e2e.test.js b/test/cli-e2e.test.js index e322e07..1bce474 100644 --- a/test/cli-e2e.test.js +++ b/test/cli-e2e.test.js @@ -10,11 +10,9 @@ import { promisify } from 'node:util' const exec = promisify(execFile) const cli = new URL('../src/cli.js', import.meta.url).pathname -test('CLI runs scan, plan, run, diff, and verify against one prototype', async () => { +test('CLI runs doctor and scan against one prototype', async () => { const cwd = await mkdtemp(join(tmpdir(), 'ht-e2e-')) await mkdir(join(cwd, 'packages/prototype'), { recursive: true }) - await mkdir(join(cwd, 'packages/web/src'), { recursive: true }) - await mkdir(join(cwd, 'packages/api'), { recursive: true }) await writeFile(join(cwd, 'packages/prototype/index.html'), `

Customer Portal

@@ -25,24 +23,49 @@ test('CLI runs scan, plan, run, diff, and verify against one prototype', async (
`) - await writeFile(join(cwd, 'packages/web/src/App.vue'), '') - await writeFile(join(cwd, 'packages/api/openapi.json'), '{"openapi":"3.0.0","paths":{}}') - await writeFile(join(cwd, 'ht.config.js'), ` - export default { - plan: { interactiveReview: false }, - project: { qualityCommands: {} } - } - `) - for (const command of ['scan', 'plan', 'run', 'diff', 'verify']) { - await exec('node', [cli, command], { cwd }) - } + const doctor = await exec('node', [cli, 'doctor'], { cwd }) + await exec('node', [cli, 'scan'], { cwd }) - const component = await readFile(join(cwd, 'packages/result/index/main-1.vue'), 'utf8') - const report = JSON.parse(await readFile(join(cwd, '.ht/verify/verification-report.json'), 'utf8')) + const contract = await readFile(join(cwd, '.ht/spec/index.ui-contract.md'), 'utf8') + const spec = JSON.parse(await readFile(join(cwd, '.ht/spec/index.spec.json'), 'utf8')) + const validation = JSON.parse(await readFile(join(cwd, '.ht/spec/index.validation.json'), 'utf8')) + const appMap = JSON.parse(await readFile(join(cwd, '.ht/app-map.json'), 'utf8')) - assert.match(component, /Customer Portal/) - assert.match(component, /v-text-field/) - assert.equal(report.domChecks.status, 'passed') + assert.match(doctor.stdout, /ok prototype directory/) + assert.match(contract, /Customer Portal/) + assert.equal(spec.pageContract.title, null) + assert.deepEqual(spec.pageContract.forms[0].fields[0], { + name: 'email', + label: 'Email', + type: 'input', + required: true + }) + assert.equal(validation.requiresHumanReview, false) + assert.equal(appMap.routes[0].prototype, 'index.html') + assert.equal(appMap.routes[0].kind, 'feature-page') + assert.equal(appMap.routes[0].layout, 'template-app') }) +test('CLI help only exposes MVP commands', async () => { + const result = await exec('node', [cli, 'help']) + + assert.match(result.stdout, /doctor/) + assert.match(result.stdout, /scan/) + assert.doesNotMatch(result.stdout, /plan/) + assert.doesNotMatch(result.stdout, /run/) + assert.doesNotMatch(result.stdout, /diff/) + assert.doesNotMatch(result.stdout, /verify/) +}) + +test('CLI rejects removed pipeline commands', async () => { + await assert.rejects( + exec('node', [cli, 'plan']), + (error) => { + assert.equal(error.code, 1) + assert.match(error.stdout, /doctor/) + assert.match(error.stdout, /scan/) + return true + } + ) +}) diff --git a/test/config.test.js b/test/config.test.js index 32da1d2..e8647ad 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -11,16 +11,13 @@ test('loadConfig supports ht.config.ts defineConfig shape', async () => { import { defineConfig } from 'html-transform' export default defineConfig({ prototype: './proto', - output: './out', - plan: { interactiveReview: true } + vision: { captureStates: ['default'] } }) `) const config = await loadConfig(cwd) assert.equal(config.prototypeDir, join(cwd, 'proto')) - assert.equal(config.outputDir, join(cwd, 'out')) - assert.equal(config.plan.interactiveReview, true) - assert.equal(config.project.qualityCommands.lint, 'pnpm lint') + assert.equal(config.htDir, join(cwd, '.ht')) + assert.deepEqual(config.vision.captureStates, ['default']) }) - diff --git a/test/html.test.js b/test/html.test.js index 0b6b3ed..594a94c 100644 --- a/test/html.test.js +++ b/test/html.test.js @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { extractRegions, inferRegionSpec, summarizeHtml } from '../src/lib/html.js' +import { buildAppMap, buildPageContract, extractRegions, inferRegionSpec, summarizeHtml, validatePageContract } from '../src/lib/html.js' test('summarizeHtml extracts user-visible contract evidence', () => { const summary = summarizeHtml(` @@ -33,3 +33,84 @@ test('inferRegionSpec maps forms to VForm', () => { assert.deepEqual(spec.uiContract.primaryActions, ['Search']) }) +test('buildPageContract creates page-level UI contract', () => { + const html = ` +
+

Orders

+
+
Status
Pending
+
+ ` + const regions = extractRegions(html) + const domSummary = summarizeHtml(html) + const contract = buildPageContract({ + page: 'orders.html', + source: '/prototype/orders.html', + html, + regions, + domSummary, + screenshotPath: '.ht/cache/prototype/orders/desktop-default.png' + }) + + assert.equal(contract.page, 'orders.html') + assert.equal(contract.forms[0].fields[0].required, true) + assert.deepEqual(contract.tables[0].headers, ['Status']) + assert.ok(contract.vuetifyComponents.includes('VTable')) +}) + +test('validatePageContract reports evidence mismatches', () => { + const report = validatePageContract({ + textSamples: ['Missing'], + actions: [{ label: 'Save' }], + forms: [], + vuetifyComponents: ['VContainer'] + }, { + textSamples: ['Orders'], + buttons: ['Search'], + labels: [] + }) + + assert.equal(report.requiresHumanReview, true) + assert.match(report.warnings.join('\n'), /Missing/) + assert.match(report.warnings.join('\n'), /Save/) +}) + +test('buildAppMap classifies auth, shell references, and feature pages', () => { + const prototypeDir = '/repo/prototype' + const specs = [ + buildSpec('/repo/prototype/portal/login.html', '
'), + buildSpec('/repo/prototype/portal/app-layout.html', '
'), + buildSpec('/repo/prototype/venue/applications-list.html', '

我的申請紀錄

申請單號
') + ] + + const appMap = buildAppMap(specs, prototypeDir) + + assert.equal(appMap.routes.find((route) => route.prototype === 'portal/login.html').kind, 'auth') + assert.equal(appMap.routes.find((route) => route.prototype === 'portal/login.html').layout, 'template-auth') + assert.equal(buildAppMap([ + buildSpec('/repo/prototype/portal/forget-password.html', '

忘記密碼

') + ], prototypeDir).routes[0].targetRole, 'forgot-password') + assert.equal(appMap.routes.find((route) => route.prototype === 'portal/app-layout.html').kind, 'legacy-shell-reference') + assert.equal(appMap.routes.find((route) => route.prototype === 'portal/app-layout.html').usePrototypeContent, false) + assert.equal(appMap.routes.find((route) => route.prototype === 'venue/applications-list.html').kind, 'feature-page') + assert.equal(appMap.routes.find((route) => route.prototype === 'venue/applications-list.html').layout, 'template-app') + assert.equal(appMap.modules.find((module) => module.name === 'venue').kind, 'feature-module') +}) + +function buildSpec(source, html) { + const regions = extractRegions(html) + const domSummary = summarizeHtml(html) + const page = source.split('/').at(-1) + return { + source, + page, + pageContract: buildPageContract({ + page, + source, + html, + regions, + domSummary, + screenshotPath: `.ht/cache/prototype/${page.replace(/\.html$/, '')}/desktop-default.png` + }) + } +}