diff --git a/README.md b/README.md
index b7f3ba8..6e97928 100644
--- a/README.md
+++ b/README.md
@@ -194,6 +194,7 @@ node packages/html-transform/src/cli.js scan
.ht/spec/{page}.spec.json
.ht/spec/{page}.validation.json
.ht/spec/{page}.ui-contract.md
+.ht/spec/{page}.bdd.md
```
`.ht/api-catalog.json` 是跨頁面的 backend API catalog,來源是 `backendDocs` 目錄下的 markdown 文件。它會包含:
@@ -210,8 +211,11 @@ node packages/html-transform/src/cli.js scan
- `prototypeGuide`:該 prototype 對應的人工 guide、舊 JSP/PB、target view 與 checklist。
- `prototypeGuide.flowRefs`:從 guide 的 legacy flow code block 比對出的相關流程節點,包含 menu、task、nodeType、JSP、PB、動作與原始流程行。
- `maintenanceContract`:頁面型態、capabilities、data model、row actions、business rules 與 warnings。
+- `bddContract`:以頁面 evidence 產生的 Gherkin 草稿、scenario evidence trace 與人工 review warnings。
- `regions`:目前仍保留,目的是相容既有資料形狀;不要把它視為 Stage 2 decomposition。
+`.bdd.md` 是 BDD 起始模版,會依 `pageKind` 產生 auth、query、maintenance、application、print 或 generic scenario。每個 scenario 會保留欄位、actions、tables、API endpoints、prototype checklist 與 legacy flow refs,供人工 review 後再轉成可執行測試。若 scan 看見錯誤流程、必填驗證、狀態限制、API error format 或尚未被 scenario 使用的 checklist/action/API,會列入 `candidateScenarios` 或 `uncoveredEvidence`,避免 evidence 被靜默漏掉。
+
`.ht/app-map.json` 是跨頁面的應用結構推論。通用 prompt 應先讀它,再決定每個 prototype 是 `auth`、`legacy-shell-reference`、`feature-page` 或其他角色。MVP 固定策略是 template layout/style 優先,prototype 只提供內容與功能證據。
route 也會包含 implementation hints,例如:
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fd85324..6ccaea2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,19 +11,235 @@ importers:
'@playwright/cli':
specifier: ^0.1.8
version: 0.1.11
+ vite:
+ specifier: ^8.0.10
+ version: 8.0.14
packages:
+ '@emnapi/core@1.10.0':
+ resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
+
+ '@emnapi/runtime@1.10.0':
+ resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
+
+ '@emnapi/wasi-threads@1.2.1':
+ resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+
+ '@napi-rs/wasm-runtime@1.1.4':
+ resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
+ peerDependencies:
+ '@emnapi/core': ^1.7.1
+ '@emnapi/runtime': ^1.7.1
+
+ '@oxc-project/types@0.132.0':
+ resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
+
'@playwright/cli@0.1.11':
resolution: {integrity: sha512-nz6xPChoijBsxZTSukJFBCCwLozsp7uwBLYrlCrmRU6MXc2a+mpaaLApqBxMfVFjgbP4pYY/3uQ/NnpD+aicxw==}
engines: {node: '>=18'}
hasBin: true
+ '@rolldown/binding-android-arm64@1.0.2':
+ resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.0.2':
+ resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.0.2':
+ resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.0.2':
+ resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.2':
+ resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.2':
+ resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-musl@1.0.2':
+ resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.2':
+ resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.2':
+ resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+
+ '@rolldown/binding-linux-x64-gnu@1.0.2':
+ resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@rolldown/binding-linux-x64-musl@1.0.2':
+ resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+
+ '@rolldown/binding-openharmony-arm64@1.0.2':
+ resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-wasm32-wasi@1.0.2':
+ resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.2':
+ resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.0.2':
+ resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
+ '@tybys/wasm-util@0.10.2':
+ resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
+
+ detect-libc@2.1.2:
+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
+ engines: {node: '>=8'}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
+ nanoid@3.3.12:
+ resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
playwright-core@1.60.0-alpha-1777669338000:
resolution: {integrity: sha512-CbBF+YtjGGK2mWKdH6iiSNIB9v9Sq8owcUrTy1cw6FgMZavspM6ZSpidQQgQXz/1scQFrM110jrtJqTYwnjbeA==}
engines: {node: '>=18'}
@@ -34,15 +250,223 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ postcss@8.5.15:
+ resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ rolldown@1.0.2:
+ resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ tinyglobby@0.2.16:
+ resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
+ engines: {node: '>=12.0.0'}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
+ vite@8.0.14:
+ resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.1.18
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
snapshots:
+ '@emnapi/core@1.10.0':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.1
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/runtime@1.10.0':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@emnapi/wasi-threads@1.2.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@tybys/wasm-util': 0.10.2
+ optional: true
+
+ '@oxc-project/types@0.132.0': {}
+
'@playwright/cli@0.1.11':
dependencies:
playwright: 1.60.0-alpha-1777669338000
+ '@rolldown/binding-android-arm64@1.0.2':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.0.2':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.0.2':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.0.2':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.2':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.2':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.0.2':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.2':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.2':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.0.2':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.0.2':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.0.2':
+ optional: true
+
+ '@rolldown/binding-wasm32-wasi@1.0.2':
+ dependencies:
+ '@emnapi/core': 1.10.0
+ '@emnapi/runtime': 1.10.0
+ '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.2':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.0.2':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.1': {}
+
+ '@tybys/wasm-util@0.10.2':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ detect-libc@2.1.2: {}
+
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
fsevents@2.3.2:
optional: true
+ fsevents@2.3.3:
+ optional: true
+
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
+ nanoid@3.3.12: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.4: {}
+
playwright-core@1.60.0-alpha-1777669338000: {}
playwright@1.60.0-alpha-1777669338000:
@@ -50,3 +474,50 @@ snapshots:
playwright-core: 1.60.0-alpha-1777669338000
optionalDependencies:
fsevents: 2.3.2
+
+ postcss@8.5.15:
+ dependencies:
+ nanoid: 3.3.12
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ rolldown@1.0.2:
+ dependencies:
+ '@oxc-project/types': 0.132.0
+ '@rolldown/pluginutils': 1.0.1
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.0.2
+ '@rolldown/binding-darwin-arm64': 1.0.2
+ '@rolldown/binding-darwin-x64': 1.0.2
+ '@rolldown/binding-freebsd-x64': 1.0.2
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.2
+ '@rolldown/binding-linux-arm64-gnu': 1.0.2
+ '@rolldown/binding-linux-arm64-musl': 1.0.2
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.2
+ '@rolldown/binding-linux-s390x-gnu': 1.0.2
+ '@rolldown/binding-linux-x64-gnu': 1.0.2
+ '@rolldown/binding-linux-x64-musl': 1.0.2
+ '@rolldown/binding-openharmony-arm64': 1.0.2
+ '@rolldown/binding-wasm32-wasi': 1.0.2
+ '@rolldown/binding-win32-arm64-msvc': 1.0.2
+ '@rolldown/binding-win32-x64-msvc': 1.0.2
+
+ source-map-js@1.2.1: {}
+
+ tinyglobby@0.2.16:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+
+ tslib@2.8.1:
+ optional: true
+
+ vite@8.0.14:
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.4
+ postcss: 8.5.15
+ rolldown: 1.0.2
+ tinyglobby: 0.2.16
+ optionalDependencies:
+ fsevents: 2.3.3
diff --git a/src/lib/bdd.js b/src/lib/bdd.js
new file mode 100644
index 0000000..1a6ae8a
--- /dev/null
+++ b/src/lib/bdd.js
@@ -0,0 +1,429 @@
+export function buildBddContract(spec) {
+ const pageKind = spec.maintenanceContract?.pageKind ?? 'page'
+ const feature = inferFeature(spec)
+ const scenarios = buildScenarios(spec, pageKind, feature)
+ const candidateScenarios = buildCandidateScenarios(spec, scenarios, feature)
+ const uncoveredEvidence = buildUncoveredEvidence(spec, scenarios, candidateScenarios)
+ const warnings = scenarios.flatMap((scenario) => scenario.warnings.map((warning) => `${scenario.name}: ${warning}`))
+ if (scenarios.length === 0) warnings.push('缺少可產生 BDD scenario 的表單、表格或操作 evidence')
+ for (const candidate of candidateScenarios) warnings.push(`候選 scenario 待人工確認:${candidate.name}`)
+ for (const item of uncoveredEvidence) warnings.push(`未覆蓋 evidence:${item.source} - ${item.text}`)
+
+ return {
+ feature,
+ pageKind,
+ source: spec.source,
+ requiresHumanReview: warnings.length > 0,
+ scenarios,
+ candidateScenarios,
+ uncoveredEvidence,
+ warnings
+ }
+}
+
+export function renderBddContract(spec, contract = spec.bddContract) {
+ const lines = [`# BDD Contract: ${spec.page}`, '']
+ lines.push(`Feature: ${contract.feature}`)
+ lines.push('')
+ for (const scenario of contract.scenarios) {
+ lines.push(`Scenario: ${scenario.name}`)
+ for (const step of scenario.given) lines.push(` Given ${step}`)
+ for (const step of scenario.when) lines.push(` When ${step}`)
+ scenario.then.forEach((step, index) => lines.push(` ${index === 0 ? 'Then' : 'And'} ${step}`))
+ lines.push('')
+ }
+ lines.push('## Evidence', '')
+ for (const scenario of contract.scenarios) {
+ lines.push(`### ${scenario.name}`)
+ lines.push(`- Type: ${scenario.type}`)
+ lines.push(`- Fields: ${scenario.evidence.fields.join(', ') || 'none'}`)
+ lines.push(`- Actions: ${scenario.evidence.actions.join(', ') || 'none'}`)
+ lines.push(`- Tables: ${scenario.evidence.tables.join(', ') || 'none'}`)
+ lines.push(`- Endpoints: ${scenario.evidence.endpoints.join(', ') || 'none'}`)
+ lines.push(`- Checklist: ${scenario.evidence.checklist.join(', ') || 'none'}`)
+ lines.push(`- Flow refs: ${scenario.evidence.flowRefs.join(', ') || 'none'}`)
+ if (scenario.warnings.length) {
+ lines.push(`- Warnings: ${scenario.warnings.join('; ')}`)
+ }
+ lines.push('')
+ }
+ lines.push('## Candidate Scenarios', '')
+ if ((contract.candidateScenarios ?? []).length === 0) lines.push('- none')
+ for (const candidate of contract.candidateScenarios ?? []) {
+ lines.push(`- ${candidate.type}: ${candidate.name}`)
+ lines.push(` - Source: ${candidate.source}`)
+ lines.push(` - Evidence: ${candidate.evidenceText}`)
+ lines.push(` - Reason: ${candidate.reason}`)
+ }
+ lines.push('')
+ lines.push('## Uncovered Evidence', '')
+ if ((contract.uncoveredEvidence ?? []).length === 0) lines.push('- none')
+ for (const item of contract.uncoveredEvidence ?? []) {
+ lines.push(`- ${item.source}: ${item.text} (${item.reason})`)
+ }
+ lines.push('')
+ lines.push('## Warnings', '')
+ if (contract.warnings.length === 0) lines.push('- none')
+ for (const warning of contract.warnings) lines.push(`- ${warning}`)
+ return `${lines.join('\n')}\n`
+}
+
+function buildScenarios(spec, pageKind, feature) {
+ if (isAuthPage(spec, pageKind)) return buildAuthScenarios(spec, feature)
+ if (pageKind === 'print') return buildPrintScenarios(spec, feature)
+ if (pageKind === 'maintenance') return buildMaintenanceScenarios(spec, feature)
+ if (pageKind === 'application') return buildApplicationScenarios(spec, feature)
+ if (pageKind === 'query') return buildQueryScenarios(spec, feature)
+ return buildGenericScenarios(spec, feature)
+}
+
+function buildAuthScenarios(spec, feature) {
+ const action = preferredAction(spec, ['save', 'custom'])
+ const fields = fieldLabels(spec)
+ const endpoint = endpointFor(spec, ['submit', 'login'], ['POST'])
+ return [scenario({
+ name: '使用者輸入正確帳密登入成功',
+ type: 'auth-success',
+ given: [`使用者已在${pageName(feature)}頁`],
+ when: [`使用者輸入正確的${credentialPhrase(fields)}並執行「${action?.label ?? '登入'}」`],
+ then: [
+ endpoint ? `系統應送出 ${endpoint.method} ${endpoint.path}` : '系統應送出登入請求',
+ '系統應導向登入後頁面',
+ '顯示登入成功結果'
+ ],
+ evidence: evidence(spec, { fields, actions: action ? [action.label] : [], endpoints: endpoint ? [endpoint] : [] }),
+ warnings: [
+ ...missingEvidence(endpoint, '登入 API'),
+ 'Then 的導向頁與成功訊息需依實作或 legacy flow 人工確認'
+ ]
+ })]
+}
+
+function buildMaintenanceScenarios(spec, feature) {
+ return [
+ ...buildQueryScenarios(spec, feature),
+ ...rowActionScenarios(spec, feature)
+ ]
+}
+
+function buildApplicationScenarios(spec, feature) {
+ const action = preferredAction(spec, ['save', 'create'])
+ const fields = requiredFieldLabels(spec)
+ const endpoint = endpointFor(spec, ['submit', 'create'], ['POST', 'PUT', 'PATCH'])
+ return [scenario({
+ name: '使用者填寫必要資料並送出成功',
+ type: 'application-submit',
+ given: [`使用者已在${pageName(feature)}頁`],
+ when: [`使用者填寫${listPhrase(fields, '必要欄位')}並執行「${action?.label ?? '送出'}」`],
+ then: [
+ endpoint ? `系統應送出 ${endpoint.method} ${endpoint.path}` : '系統應送出表單資料',
+ '顯示送出成功結果'
+ ],
+ evidence: evidence(spec, { fields, actions: action ? [action.label] : [], endpoints: endpoint ? [endpoint] : [] }),
+ warnings: [
+ ...missingEvidence(endpoint, '送出 API'),
+ 'Then 的成功訊息需人工確認'
+ ]
+ })]
+}
+
+function buildQueryScenarios(spec, feature) {
+ const action = preferredAction(spec, ['search']) ?? preferredAction(spec, ['custom'])
+ const fields = fieldLabels(spec)
+ const endpoint = endpointFor(spec, ['query', 'lookup'], ['GET'])
+ const hasResultTable = spec.pageContract.tables.some((table) => table.role === 'resultTable')
+ return [scenario({
+ name: '使用者輸入條件查詢資料',
+ type: 'query',
+ given: [`使用者已在${pageName(feature)}頁`],
+ when: [`使用者輸入${listPhrase(fields, '查詢條件')}並執行「${action?.label ?? '查詢'}」`],
+ then: [
+ endpoint ? `系統應送出 ${endpoint.method} ${endpoint.path}` : '系統應送出查詢請求',
+ hasResultTable ? '顯示符合條件的查詢結果' : '顯示查詢結果'
+ ],
+ evidence: evidence(spec, { fields, actions: action ? [action.label] : [], endpoints: endpoint ? [endpoint] : [] }),
+ warnings: [
+ ...missingEvidence(endpoint, '查詢 API'),
+ !hasResultTable ? '缺少結果表格 evidence' : null,
+ 'Then 的查詢結果狀態需人工確認'
+ ].filter(Boolean)
+ })]
+}
+
+function rowActionScenarios(spec, feature) {
+ return (spec.maintenanceContract?.rowActions ?? []).map((action) => {
+ const endpoint = endpointFor(spec, [action.actionType], methodCandidates(action.actionType))
+ return scenario({
+ name: action.enabledWhen ? `符合狀態條件時可以${action.label}資料列` : `使用者可以${action.label}資料列`,
+ type: `row-${action.actionType}`,
+ given: [`使用者已在${pageName(feature)}頁`, action.enabledWhen ? `資料列符合 ${action.enabledWhen}` : '資料列已顯示在結果列表'],
+ when: [`使用者在資料列執行「${action.label}」`],
+ then: [
+ endpoint ? `系統應送出 ${endpoint.method} ${endpoint.path}` : `系統應執行${action.label}流程`,
+ `顯示${action.label}後的結果`
+ ],
+ evidence: evidence(spec, { actions: [action.label], endpoints: endpoint ? [endpoint] : [] }),
+ warnings: [
+ ...missingEvidence(endpoint, `${action.label} API`),
+ 'Then 的資料列狀態變化需人工確認'
+ ]
+ })
+ })
+}
+
+function buildPrintScenarios(spec, feature) {
+ const action = preferredAction(spec, ['print'])
+ const endpoint = endpointFor(spec, ['print'], ['GET'])
+ return [scenario({
+ name: '使用者列印頁面資料',
+ type: 'print',
+ given: [`使用者已在${pageName(feature)}頁`],
+ when: [`使用者執行「${action?.label ?? '列印'}」`],
+ then: [
+ endpoint ? `系統應送出 ${endpoint.method} ${endpoint.path}` : '系統應開啟列印流程',
+ '顯示可列印內容'
+ ],
+ evidence: evidence(spec, { actions: action ? [action.label] : [], endpoints: endpoint ? [endpoint] : [] }),
+ warnings: [
+ ...missingEvidence(endpoint, '列印 API'),
+ 'Then 的列印視窗或下載行為需人工確認'
+ ]
+ })]
+}
+
+function buildGenericScenarios(spec, feature) {
+ const action = preferredAction(spec, ['save', 'create', 'custom'])
+ if (!action && spec.pageContract.tables.length > 0) return buildQueryScenarios(spec, feature)
+ if (!action) return []
+ const fields = fieldLabels(spec)
+ return [scenario({
+ name: '使用者送出表單',
+ type: 'form-submit',
+ given: [`使用者已在${pageName(feature)}頁`],
+ when: [`使用者填寫${listPhrase(fields, '表單資料')}並執行「${action.label}」`],
+ then: ['系統應處理表單送出結果'],
+ evidence: evidence(spec, { fields, actions: [action.label] }),
+ warnings: []
+ })]
+}
+
+function scenario(value) {
+ return {
+ name: value.name,
+ type: value.type,
+ given: value.given,
+ when: value.when,
+ then: value.then,
+ evidence: value.evidence,
+ warnings: value.warnings.filter(Boolean)
+ }
+}
+
+function buildCandidateScenarios(spec, scenarios, feature) {
+ const candidates = []
+ for (const item of spec.prototypeGuide?.checklist ?? []) {
+ const candidate = candidateFromText(item, 'prototypeGuide.checklist', feature)
+ if (candidate && !isCoveredByScenarios(item, scenarios)) candidates.push(candidate)
+ }
+ for (const flow of spec.prototypeGuide?.flowRefs ?? []) {
+ const text = [flow.title, ...(flow.nodes ?? []).map((node) => `${node.nodeType ?? ''} ${node.action ?? ''}`)].join(' ')
+ const candidate = candidateFromText(text, 'prototypeGuide.flowRefs', feature)
+ if (candidate && !isCoveredByScenarios(text, scenarios)) candidates.push(candidate)
+ }
+ for (const format of errorFormats(spec)) {
+ const text = [format.status, format.code, format.message, format.description].filter(Boolean).join(' ')
+ const candidate = candidateFromText(text, 'api-error', feature)
+ if (candidate) candidates.push(candidate)
+ }
+ return dedupeByKey(candidates, (candidate) => `${candidate.type}:${candidate.source}:${candidate.evidenceText}`)
+}
+
+function buildUncoveredEvidence(spec, scenarios, candidateScenarios) {
+ const coveredText = [
+ ...scenarios.flatMap((item) => [
+ item.name,
+ ...item.given,
+ ...item.when,
+ ...item.then,
+ ...item.evidence.fields,
+ ...item.evidence.actions,
+ ...item.evidence.endpoints
+ ]),
+ ...candidateScenarios.flatMap((item) => [item.name, item.evidenceText])
+ ].join(' ')
+ const uncovered = []
+ for (const item of spec.prototypeGuide?.checklist ?? []) {
+ if (!textIncludesEvidence(coveredText, item)) {
+ uncovered.push({ source: 'prototypeGuide.checklist', text: item, reason: 'checklist 尚未對應到正式或候選 scenario' })
+ }
+ }
+ for (const action of spec.pageContract.actions ?? []) {
+ if (!textIncludesEvidence(coveredText, action.label)) {
+ uncovered.push({ source: 'pageContract.actions', text: action.label, reason: 'action 尚未對應到正式或候選 scenario' })
+ }
+ }
+ for (const endpoint of spec.apiContract?.endpoints ?? []) {
+ const text = `${endpoint.method} ${endpoint.path}`
+ if (!textIncludesEvidence(coveredText, text)) {
+ uncovered.push({ source: 'apiContract.endpoints', text, reason: 'API endpoint 尚未被 scenario 使用' })
+ }
+ }
+ return uncovered
+}
+
+function candidateFromText(text, source, feature) {
+ if (/(錯誤|失敗|error|invalid|unauthorized|401|403|400)/i.test(text)) {
+ return {
+ name: `${pageName(feature)}失敗時顯示錯誤結果`,
+ type: 'error-path',
+ source,
+ evidenceText: text,
+ reason: '偵測到錯誤或失敗流程 evidence'
+ }
+ }
+ if (/(必填|required|不可空白|不可空)/i.test(text)) {
+ return {
+ name: `${pageName(feature)}缺少必要資料時顯示驗證結果`,
+ type: 'validation',
+ source,
+ evidenceText: text,
+ reason: '偵測到欄位驗證 evidence'
+ }
+ }
+ if (/(disabled|停用|不可|不能|隱藏|不顯示|才(?:能|可)|狀態|aprvYn)/i.test(text)) {
+ return {
+ name: `${pageName(feature)}依狀態限制操作`,
+ type: 'state-rule',
+ source,
+ evidenceText: text,
+ reason: '偵測到狀態或操作限制 evidence'
+ }
+ }
+ return null
+}
+
+function evidence(spec, overrides = {}) {
+ return {
+ fields: overrides.fields ?? [],
+ actions: overrides.actions ?? [],
+ tables: spec.pageContract.tables.map((table) => `${table.id}:${table.role}`),
+ endpoints: (overrides.endpoints ?? []).map((endpoint) => `${endpoint.method} ${endpoint.path}`),
+ checklist: spec.prototypeGuide?.checklist ?? [],
+ flowRefs: (spec.prototypeGuide?.flowRefs ?? []).map((flow) => flow.title).filter(Boolean)
+ }
+}
+
+function errorFormats(spec) {
+ const handling = spec.apiContract?.errorHandling
+ if (!handling) return []
+ if (Array.isArray(handling.formats)) return handling.formats
+ if (Array.isArray(handling.errors)) return handling.errors
+ if (Array.isArray(handling)) return handling
+ return []
+}
+
+function isCoveredByScenarios(text, scenarios) {
+ return textIncludesEvidence(scenarios.flatMap((scenario) => [
+ scenario.name,
+ ...scenario.given,
+ ...scenario.when,
+ ...scenario.then,
+ ...scenario.evidence.fields,
+ ...scenario.evidence.actions,
+ ...scenario.evidence.checklist,
+ ...scenario.evidence.flowRefs
+ ]).join(' '), text)
+}
+
+function textIncludesEvidence(haystack, needle) {
+ const normalizedHaystack = normalizeForCoverage(haystack)
+ const normalizedNeedle = normalizeForCoverage(needle)
+ if (!normalizedNeedle) return true
+ if (normalizedHaystack.includes(normalizedNeedle)) return true
+ const keywords = normalizedNeedle.split(/\s+/).filter((word) => word.length >= 2)
+ if (keywords.length === 0) return true
+ return keywords.some((word) => normalizedHaystack.includes(word))
+}
+
+function normalizeForCoverage(text) {
+ return String(text ?? '').replace(/[「」『』`'"()[\]{}::,,.。;;/\\\s]+/g, ' ').trim().toLowerCase()
+}
+
+function inferFeature(spec) {
+ if (isAuthPage(spec, spec.maintenanceContract?.pageKind) && /login|登入/i.test(spec.page)) return '使用者登入'
+ return cleanName(spec.prototypeGuide?.description) ??
+ cleanName(spec.pageContract.title) ??
+ cleanName(spec.pageContract.sections.find((section) => section.name)?.name) ??
+ spec.page.replace(/\.html$/i, '')
+}
+
+function isAuthPage(spec, pageKind) {
+ return ['auth', 'login'].includes(pageKind) || spec.pageContract.forms.some((form) => form.fields.some((field) => field.type === 'password'))
+}
+
+function preferredAction(spec, actionTypes) {
+ return spec.pageContract.actions.find((action) => actionTypes.includes(action.actionType)) ?? null
+}
+
+function fieldLabels(spec) {
+ return unique(spec.pageContract.forms.flatMap((form) => form.fields.map((field) => field.label ?? field.name).filter(Boolean)))
+}
+
+function requiredFieldLabels(spec) {
+ const fields = unique(spec.pageContract.forms.flatMap((form) => form.fields.filter((field) => field.required).map((field) => field.label ?? field.name).filter(Boolean)))
+ return fields.length > 0 ? fields : fieldLabels(spec)
+}
+
+function endpointFor(spec, usages, methods) {
+ const endpoints = spec.apiContract?.endpoints ?? []
+ return endpoints.find((endpoint) => usages.includes(endpoint.usage)) ??
+ endpoints.find((endpoint) => methods.includes(endpoint.method)) ??
+ null
+}
+
+function methodCandidates(actionType) {
+ if (actionType === 'delete') return ['DELETE']
+ if (actionType === 'edit') return ['PUT', 'PATCH', 'GET']
+ if (actionType === 'print') return ['GET']
+ return ['POST', 'GET']
+}
+
+function missingEvidence(value, label) {
+ return value ? [] : [`缺少${label} evidence`]
+}
+
+function credentialPhrase(fields) {
+ const hasPassword = fields.some((field) => /密碼|password/i.test(field))
+ const hasAccount = fields.some((field) => /帳號|帳戶|account|user|email|信箱/i.test(field))
+ if (hasAccount && hasPassword) return '帳號與密碼'
+ return listPhrase(fields, '帳密')
+}
+
+function listPhrase(values, fallback) {
+ if (values.length === 0) return fallback
+ if (values.length === 1) return values[0]
+ return `${values.slice(0, -1).join('、')}與${values.at(-1)}`
+}
+
+function pageName(feature) {
+ return /頁$/.test(feature) ? feature.replace(/頁$/, '') : feature
+}
+
+function cleanName(value) {
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim()
+ return text || null
+}
+
+function unique(values) {
+ return [...new Set(values)]
+}
+
+function dedupeByKey(values, keyOf) {
+ const seen = new Set()
+ return values.filter((value) => {
+ const key = keyOf(value)
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
diff --git a/src/stages/scan.js b/src/stages/scan.js
index 9facb45..770fd5b 100644
--- a/src/stages/scan.js
+++ b/src/stages/scan.js
@@ -4,6 +4,7 @@ import { basename, join, relative } from 'node:path'
import { loadConfig } from '../lib/config.js'
import { artifactPath, ensureDir, exists, listFiles, readJson, sha256File, writeJson } from '../lib/files.js'
import { buildApiCatalog, matchApiEndpointCandidates } from '../lib/api-docs.js'
+import { buildBddContract, renderBddContract } from '../lib/bdd.js'
import { buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../lib/html.js'
import { buildMaintenanceContract } from '../lib/maintenance.js'
import { resolvePlaywrightCli } from '../lib/playwright-cli.js'
@@ -42,9 +43,11 @@ export async function scan() {
enrichSpecsWithRouteContracts(layoutSpecs, appMap, apiCatalog, config.prototypeDir)
for (const spec of layoutSpecs) {
const name = artifactPath(config.prototypeDir, spec.source)
+ spec.bddContract = buildBddContract(spec)
await writeJson(join(config.htDir, 'spec', `${name}.spec.json`), spec)
await writeJson(join(config.htDir, 'spec', `${name}.validation.json`), spec.validation)
await writeFile(join(config.htDir, 'spec', `${name}.ui-contract.md`), renderUiContract(spec))
+ await writeFile(join(config.htDir, 'spec', `${name}.bdd.md`), renderBddContract(spec))
}
await writeJson(join(config.htDir, 'api-catalog.json'), apiCatalog)
await writeJson(join(config.htDir, 'app-map.json'), appMap)
@@ -176,7 +179,7 @@ async function captureRenderedPrototype(config, plan, serverUrl) {
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 session = `ht-${process.pid}-${plan.hash.slice(0, 8)}`
const url = new URL(`${encodePath(plan.name)}.html`, serverUrl).href
const screenshots = []
const externalResourceFailures = []
@@ -537,6 +540,15 @@ function renderUiContract(spec) {
lines.push(`- ${action.kind}/${action.scope}/${action.actionType}: ${action.label}`)
}
lines.push('')
+ lines.push('## BDD Scenarios', '')
+ if (!spec.bddContract?.scenarios?.length) lines.push('- none')
+ for (const scenario of spec.bddContract?.scenarios ?? []) {
+ lines.push(`- ${scenario.type}: ${scenario.name}`)
+ }
+ if (spec.bddContract?.warnings?.length) {
+ lines.push(`- Requires human review: ${spec.bddContract.requiresHumanReview ? 'yes' : 'no'}`)
+ }
+ lines.push('')
lines.push('## API Endpoints', '')
if (!spec.apiContract?.endpoints?.length) lines.push('- none')
for (const endpoint of spec.apiContract?.endpoints ?? []) {
diff --git a/test/cli-e2e.test.js b/test/cli-e2e.test.js
index c570e63..a109887 100644
--- a/test/cli-e2e.test.js
+++ b/test/cli-e2e.test.js
@@ -35,14 +35,20 @@ test('CLI runs doctor and scan against one prototype', async () => {
await exec('node', [cli, 'scan'], { cwd })
const contract = await readFile(join(cwd, '.ht/spec/index.ui-contract.md'), 'utf8')
+ const bdd = await readFile(join(cwd, '.ht/spec/index.bdd.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(doctor.stdout, /ok prototype directory/)
assert.match(contract, /Customer Portal/)
+ assert.match(contract, /BDD Scenarios/)
+ assert.match(bdd, /Feature: Customer portal entry/)
+ assert.match(bdd, /Scenario: 使用者填寫必要資料並送出成功/)
assert.doesNotMatch(contract, /Recommended template/)
assert.equal(spec.pageContract.title, null)
+ assert.equal(spec.bddContract.feature, 'Customer portal entry')
+ assert.equal(spec.bddContract.scenarios[0].type, 'application-submit')
assert.deepEqual(pick(spec.pageContract.forms[0].fields[0], ['name', 'label', 'type', 'required']), {
name: 'email',
label: 'Email',
diff --git a/test/html.test.js b/test/html.test.js
index 0cb9131..8e1ca58 100644
--- a/test/html.test.js
+++ b/test/html.test.js
@@ -1,6 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { buildApiCatalog, matchApiEndpointCandidates, matchApiEndpoints } from '../src/lib/api-docs.js'
+import { buildBddContract, renderBddContract } from '../src/lib/bdd.js'
import { buildMaintenanceContract } from '../src/lib/maintenance.js'
import { buildAppMap, buildAppMapWithGuides, buildPageContract, extractRegions, inferRegionSpec, parsePrototypeGuide, summarizeHtml, validatePageContract } from '../src/lib/html.js'
@@ -409,6 +410,130 @@ test('buildMaintenanceContract ignores non-vue guide text for primary entity', (
assert.deepEqual(contract.capabilities, ['print'])
})
+test('buildBddContract creates traceable auth scenarios from page evidence', () => {
+ const spec = buildSpec('/repo/prototype/portal/login.html', `
+
+ 使用者登入
+
+
+ `)
+ spec.maintenanceContract = {
+ pageKind: 'login',
+ capabilities: ['custom'],
+ dataModel: { primaryEntity: 'Login' },
+ businessRules: { validationRules: [{ ruleType: 'required', text: '帳號與密碼必填' }] },
+ rowActions: []
+ }
+ spec.prototypeGuide = {
+ description: '使用者登入',
+ checklist: ['登入失敗時顯示錯誤訊息'],
+ flowRefs: [{ title: '登入流程', nodes: [{ nodeType: 'submit', action: '登入' }] }]
+ }
+ spec.apiContract = {
+ endpoints: [{ method: 'POST', path: '/api/auth/login', usage: 'submit', description: '使用者登入' }]
+ }
+
+ const contract = buildBddContract(spec)
+ const markdown = renderBddContract(spec, contract)
+
+ assert.equal(contract.feature, '使用者登入')
+ assert.equal(contract.scenarios[0].name, '使用者輸入正確帳密登入成功')
+ assert.deepEqual(contract.scenarios[0].given, ['使用者已在使用者登入頁'])
+ assert.deepEqual(contract.scenarios[0].when, ['使用者輸入正確的帳號與密碼並執行「登入」'])
+ assert.ok(contract.scenarios[0].then.includes('系統應送出 POST /api/auth/login'))
+ assert.deepEqual(contract.scenarios[0].evidence.fields, ['帳號', '密碼'])
+ assert.deepEqual(contract.scenarios[0].evidence.checklist, ['登入失敗時顯示錯誤訊息'])
+ assert.equal(contract.requiresHumanReview, true)
+ assert.match(markdown, /Feature: 使用者登入/)
+ assert.match(markdown, /Scenario: 使用者輸入正確帳密登入成功/)
+ assert.match(markdown, /Then 系統應送出 POST \/api\/auth\/login/)
+ assert.match(markdown, /## Evidence/)
+})
+
+test('buildBddContract creates query and row action scenarios with review warnings', () => {
+ const spec = buildSpec('/repo/prototype/venue/applications-list.html', `
+
+ 我的申請紀錄
+
+
+
+ `)
+ spec.maintenanceContract = {
+ pageKind: 'maintenance',
+ capabilities: ['search', 'edit', 'delete'],
+ dataModel: { primaryEntity: 'ApplicationsList' },
+ businessRules: { validationRules: [], statusRules: [{ ruleType: 'enabled-when-status', field: 'aprvYn', value: 'Z', text: 'aprvYn=Z 才可修改刪除' }] },
+ rowActions: [
+ { label: '修改', actionType: 'edit', enabledWhen: "aprvYn === 'Z'" },
+ { label: '刪除', actionType: 'delete', enabledWhen: "aprvYn === 'Z'" }
+ ]
+ }
+ spec.apiContract = {
+ endpoints: [
+ { method: 'GET', path: '/api/applications', usage: 'query', description: '查詢申請紀錄' },
+ { method: 'DELETE', path: '/api/applications/{id}', usage: 'delete', description: '刪除申請' }
+ ]
+ }
+
+ const contract = buildBddContract(spec)
+
+ assert.equal(contract.scenarios[0].type, 'query')
+ assert.deepEqual(contract.scenarios[0].then, ['系統應送出 GET /api/applications', '顯示符合條件的查詢結果'])
+ assert.ok(contract.scenarios.some((scenario) => scenario.name === '符合狀態條件時可以修改資料列'))
+ assert.ok(contract.scenarios.some((scenario) => scenario.name === '符合狀態條件時可以刪除資料列'))
+ assert.ok(contract.warnings.some((warning) => warning.includes('Then')))
+})
+
+test('buildBddContract reports scenario candidates and uncovered evidence', () => {
+ const spec = buildSpec('/repo/prototype/portal/login.html', `
+
+ 使用者登入
+
+
+ `)
+ spec.maintenanceContract = {
+ pageKind: 'login',
+ capabilities: ['custom'],
+ dataModel: { primaryEntity: 'Login' },
+ businessRules: { validationRules: [{ ruleType: 'required', text: '帳號與密碼必填' }] },
+ rowActions: []
+ }
+ spec.prototypeGuide = {
+ description: '使用者登入',
+ checklist: ['登入失敗時顯示錯誤訊息', '連續錯誤三次鎖定帳號'],
+ flowRefs: [{ title: '登入失敗流程', nodes: [{ nodeType: 'error', action: '顯示錯誤訊息' }] }]
+ }
+ spec.apiContract = {
+ endpoints: [{ method: 'POST', path: '/api/auth/login', usage: 'submit', description: '使用者登入' }],
+ errorHandling: { formats: [{ status: 401, description: '帳號或密碼錯誤' }] }
+ }
+
+ const contract = buildBddContract(spec)
+ const markdown = renderBddContract(spec, contract)
+
+ assert.ok(contract.candidateScenarios.some((candidate) => candidate.type === 'error-path'))
+ assert.ok(contract.candidateScenarios.some((candidate) => candidate.source === 'api-error'))
+ assert.ok(contract.uncoveredEvidence.some((item) => item.source === 'prototypeGuide.checklist' && item.text === '連續錯誤三次鎖定帳號'))
+ assert.ok(contract.uncoveredEvidence.some((item) => item.source === 'pageContract.actions' && item.text === '忘記密碼'))
+ assert.equal(contract.requiresHumanReview, true)
+ assert.match(markdown, /## Candidate Scenarios/)
+ assert.match(markdown, /登入失敗時顯示錯誤訊息/)
+ assert.match(markdown, /## Uncovered Evidence/)
+ assert.match(markdown, /連續錯誤三次鎖定帳號/)
+})
+
function buildSpec(source, html) {
const regions = extractRegions(html)
const domSummary = summarizeHtml(html)