feat: renew mvp
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
.playwright-cli
|
||||
node_modules
|
||||
@@ -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/<runId>/`
|
||||
- `.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。
|
||||
|
||||
@@ -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 中穩定執行 |
|
||||
這些能力只有在實際需求明確出現後才新增。
|
||||
|
||||
@@ -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
|
||||
<!-- packages/prototype/index.html -->
|
||||
<main>
|
||||
<h1>Customer Portal</h1>
|
||||
<form>
|
||||
<label>Email</label>
|
||||
<input name="email" required>
|
||||
<button>Submit</button>
|
||||
</form>
|
||||
</main>
|
||||
```
|
||||
|
||||
### 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/<runId>/
|
||||
```
|
||||
|
||||
若只想重跑失敗 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
|
||||
```
|
||||
|
||||
+3
-2
@@ -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"
|
||||
|
||||
+6
-28
@@ -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 <command>
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
|
||||
@@ -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<boolean>} 找得到命令時為 `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))
|
||||
})
|
||||
}
|
||||
+1
-37
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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<void>}
|
||||
|
||||
+302
-1
@@ -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, /<table\b[^>]*>([\s\S]*?)<\/table>/gi).map((item, index) => ({
|
||||
id: `table-${index + 1}`,
|
||||
headers: collect(item[1], /<th\b[^>]*>([\s\S]*?)<\/th>/gi).map((header) => cleanText(header[1])).filter(Boolean),
|
||||
sampleRows: collect(item[1], /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi).slice(0, 3).map((row) =>
|
||||
collect(row[1], /<td\b[^>]*>([\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 (/<table\b/i.test(html)) components.add('VTable')
|
||||
if (/<(button|a)\b/i.test(html)) components.add('VBtn')
|
||||
if (/<(input|select|textarea)\b/i.test(html)) components.add('VTextField')
|
||||
if (/<(ul|ol)\b/i.test(html)) components.add('VList')
|
||||
if (regions.some((region) => 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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 `<tr${warning}><td>${region.id}</td><td>${region.name}</td><td>${score}</td><td>${region.screenshotPath}</td></tr>`
|
||||
}).join('\n')
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${spec.page} diff</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 24px; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
td, th { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
.warning { background: #fff3cd; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${spec.page} Diff</h1>
|
||||
<table>
|
||||
<thead><tr><th>Region</th><th>Name</th><th>Similarity</th><th>Prototype Screenshot</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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')
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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 `<template>
|
||||
<v-container>
|
||||
<section>
|
||||
<h1>${escapeHtml(text[0] ?? spec.name)}</h1>
|
||||
${text.slice(1).map((item) => ` <p>${escapeHtml(item)}</p>`).join('\n')}
|
||||
${fields.map((field) => ` <v-text-field label="${escapeHtml(field.name ?? field.type ?? 'Field')}"${field.required ? ' required' : ''} />`).join('\n')}
|
||||
${actions.map((action, index) => ` <v-btn${index === 0 ? ' color="primary"' : ''}>${escapeHtml(action)}</v-btn>`).join('\n')}
|
||||
</section>
|
||||
</v-container>
|
||||
</template>
|
||||
`
|
||||
}
|
||||
|
||||
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('"', '"')
|
||||
}
|
||||
+282
-71
@@ -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)
|
||||
|
||||
const capture = {
|
||||
source: file,
|
||||
hash,
|
||||
viewports,
|
||||
states: config.vision.captureStates,
|
||||
externalResourceFailures: findExternalResources(html),
|
||||
screenshots: [],
|
||||
captureEngine: 'html-summary',
|
||||
cacheHit
|
||||
return { file, hash, name, cacheDir, metadataPath, cacheHit }
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
const domSummary = summarizeHtml(html)
|
||||
if (!cacheHit) {
|
||||
await writeJson(join(cacheDir, 'dom-summary.json'), domSummary)
|
||||
await writeJson(join(cacheDir, 'accessibility-tree.json'), buildAccessibilityTree(domSummary))
|
||||
await writeJson(join(cacheDir, 'accessibility-tree.json'), capture.accessibilityTree)
|
||||
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'}`)
|
||||
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',
|
||||
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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) }
|
||||
}
|
||||
+42
-19
@@ -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'), `
|
||||
<main>
|
||||
<h1>Customer Portal</h1>
|
||||
@@ -25,24 +23,49 @@ test('CLI runs scan, plan, run, diff, and verify against one prototype', async (
|
||||
</form>
|
||||
</main>
|
||||
`)
|
||||
await writeFile(join(cwd, 'packages/web/src/App.vue'), '<template><v-app /></template>')
|
||||
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
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
+3
-6
@@ -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'])
|
||||
})
|
||||
|
||||
|
||||
+82
-1
@@ -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 = `
|
||||
<main>
|
||||
<h1>Orders</h1>
|
||||
<form><label>Email</label><input name="email" required><button>Search</button></form>
|
||||
<table><thead><tr><th>Status</th></tr></thead><tbody><tr><td>Pending</td></tr></tbody></table>
|
||||
</main>
|
||||
`
|
||||
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', '<main><input type="password" name="pwd"><button>登入</button></main>'),
|
||||
buildSpec('/repo/prototype/portal/app-layout.html', '<main><button>隱藏選單</button><button>登 出</button></main>'),
|
||||
buildSpec('/repo/prototype/venue/applications-list.html', '<main><h1>我的申請紀錄</h1><table><tr><th>申請單號</th></tr></table><button>查詢</button></main>')
|
||||
]
|
||||
|
||||
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', '<main><h1>忘記密碼</h1><input name="idno"><button>發送至信箱</button></main>')
|
||||
], 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`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user