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', ` +
+

我的申請紀錄

+
+ + + +
申請單號狀態操作
A001審核中
+
+ `) + 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)