diff --git a/README.md b/README.md index 6e97928..79c2cab 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,8 @@ node packages/html-transform/src/cli.js scan `.spec.json` 會包含: - `pageContract`:頁面文字、欄位、表格、actions、layout evidence 與 Vuetify checklist。 +- `captureArtifacts`:screenshot、DOM summary、accessibility tree 與 capture metadata 的可追溯路徑。 +- `browserEvidence`:瀏覽器 resource failure 與 console warning/error。 - `apiContract`:與該頁匹配的 API endpoints、用途分類、rejected candidates 與錯誤格式。 - `prototypeGuide`:該 prototype 對應的人工 guide、舊 JSP/PB、target view 與 checklist。 - `prototypeGuide.flowRefs`:從 guide 的 legacy flow code block 比對出的相關流程節點,包含 menu、task、nodeType、JSP、PB、動作與原始流程行。 diff --git a/src/lib/bdd.js b/src/lib/bdd.js index 1a6ae8a..e2101e3 100644 --- a/src/lib/bdd.js +++ b/src/lib/bdd.js @@ -1,3 +1,10 @@ +/** + * 從已完成 route/API/maintenance enrich 的頁面 spec 產生 BDD 草稿。 + * 正式 scenarios 只放相對穩定的主流程;不夠確定的錯誤、驗證與狀態規則會保留為候選或未覆蓋 evidence,避免 review 時被誤認為已完整。 + * + * @example 寫回 scan spec + * `spec.bddContract = buildBddContract(spec)` + */ export function buildBddContract(spec) { const pageKind = spec.maintenanceContract?.pageKind ?? 'page' const feature = inferFeature(spec) @@ -21,6 +28,13 @@ export function buildBddContract(spec) { } } +/** + * 將 BDD contract 轉成人工 review 友善的 Markdown。 + * 輸出同時保留 Gherkin 草稿、scenario evidence trace、候選 scenario 與尚未覆蓋的 evidence。 + * + * @example 寫入 artifact + * `await writeFile(path, renderBddContract(spec))` + */ export function renderBddContract(spec, contract = spec.bddContract) { const lines = [`# BDD Contract: ${spec.page}`, ''] lines.push(`Feature: ${contract.feature}`) @@ -219,6 +233,10 @@ function scenario(value) { } } +/** + * 從 checklist、legacy flow 與 API error format 找出值得人工補成 scenario 的線索。 + * 這些線索不直接變成正式 Gherkin,因為 prototype evidence 通常不足以推斷完整 Given/When/Then。 + */ function buildCandidateScenarios(spec, scenarios, feature) { const candidates = [] for (const item of spec.prototypeGuide?.checklist ?? []) { @@ -238,6 +256,10 @@ function buildCandidateScenarios(spec, scenarios, feature) { return dedupeByKey(candidates, (candidate) => `${candidate.type}:${candidate.source}:${candidate.evidenceText}`) } +/** + * 列出 scan 看得到、但沒有被正式 scenario 或候選 scenario 消化的 evidence。 + * 這是 BDD review 的缺口清單,不代表 prototype 錯誤。 + */ function buildUncoveredEvidence(spec, scenarios, candidateScenarios) { const coveredText = [ ...scenarios.flatMap((item) => [ @@ -322,6 +344,10 @@ function errorFormats(spec) { return [] } +/** + * 判斷人工文字是否已出現在正式 scenario 的可執行語意中。 + * Checklist 與 flow refs 只是 trace,不算覆蓋,否則會遮蔽還沒寫成 scenario 的業務分支。 + */ function isCoveredByScenarios(text, scenarios) { return textIncludesEvidence(scenarios.flatMap((scenario) => [ scenario.name, @@ -330,8 +356,7 @@ function isCoveredByScenarios(text, scenarios) { ...scenario.then, ...scenario.evidence.fields, ...scenario.evidence.actions, - ...scenario.evidence.checklist, - ...scenario.evidence.flowRefs + ...scenario.evidence.endpoints ]).join(' '), text) } @@ -342,7 +367,7 @@ function textIncludesEvidence(haystack, needle) { 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)) + return keywords.every((word) => normalizedHaystack.includes(word)) } function normalizeForCoverage(text) { diff --git a/src/lib/html.js b/src/lib/html.js index f654c96..805af17 100644 --- a/src/lib/html.js +++ b/src/lib/html.js @@ -635,12 +635,28 @@ function groupFieldsIntoRows(fields) { function mergeInputs(sourceInputs, domInputs) { if (sourceInputs.length === 0) return domInputs if (domInputs.length === 0) return sourceInputs - return sourceInputs.map((sourceInput, index) => ({ - ...domInputs[index], - ...sourceInput, - name: sourceInput.name ?? domInputs[index]?.name ?? null, - label: sourceInput.label ?? domInputs[index]?.label ?? null - })) + return sourceInputs.map((sourceInput, index) => { + const domInput = domInputs[index] ?? {} + const sourceLabel = meaningfulFieldLabel(sourceInput) + const domLabel = meaningfulFieldLabel(domInput) + return { + ...domInput, + ...sourceInput, + name: sourceInput.name ?? domInput.name ?? null, + label: sourceLabel ?? domLabel ?? sourceInput.label ?? domInput.label ?? null, + required: Boolean(sourceInput.required || domInput.required), + readonly: Boolean(sourceInput.readonly || domInput.readonly), + maxLength: sourceInput.maxLength ?? domInput.maxLength ?? null, + options: sourceInput.options?.length ? sourceInput.options : domInput.options ?? [], + defaultValue: sourceInput.defaultValue ?? domInput.defaultValue ?? null + } + }) +} + +function meaningfulFieldLabel(field) { + if (!field?.label) return null + if (field.label === field.name) return null + return field.label } function buildForms(summary) { diff --git a/src/stages/scan.js b/src/stages/scan.js index 770fd5b..a8a72ac 100644 --- a/src/stages/scan.js +++ b/src/stages/scan.js @@ -14,9 +14,8 @@ const viewports = [ ] /** - * 執行 Stage 1:掃描 prototype HTML,產生 deterministic evidence 與 LayoutSpec artifacts。 - * - * @returns {Promise} + * 掃描 prototype HTML 並產出可 review 的 evidence artifacts。 + * 流程會先擷取 browser evidence,再用 app map、API catalog 與 prototype guide enrich 每頁 spec,最後才寫出完整 contract。 */ export async function scan() { const config = await loadConfig() @@ -54,6 +53,10 @@ export async function scan() { console.log(`scan 完成:${htmlFiles.length} 個 HTML 檔案`) } +/** + * 讀取 prototype 旁的人工導覽文件。 + * 這些 guide 是 legacy JSP/PB、checklist 與 flow refs 的來源,後續 BDD 與 maintenance contract 都會使用。 + */ async function readPrototypeGuides(config) { const files = await listFiles(config.prototypeDir, ['.md']) const guides = [] @@ -64,6 +67,10 @@ async function readPrototypeGuides(config) { return guides.filter((guide) => guide.entries.length > 0) } +/** + * 建立跨頁面的 API catalog。 + * scan 不直接呼叫後端,只從 markdown/OpenAPI 文件抽取 endpoint、schema 與錯誤格式作為可追溯 evidence。 + */ async function readApiCatalog(config) { const documents = [] for (const docsDir of config.backendDocsDirs ?? [config.backendDocsDir]) { @@ -78,6 +85,10 @@ async function readApiCatalog(config) { return buildApiCatalog(documents) } +/** + * 將 app map、API match 與 prototype guide 回填到每頁 spec。 + * 這個階段必須在所有頁面初步 scan 完後執行,因為 route/module 判斷依賴跨頁面 app map。 + */ function enrichSpecsWithRouteContracts(layoutSpecs, appMap, apiCatalog, prototypeDir) { const routeByPrototype = new Map(appMap.routes.map((route) => [route.prototype, route])) for (const spec of layoutSpecs) { @@ -119,6 +130,10 @@ function shouldMatchApi(route) { return ['auth', 'auth-support', 'feature-page'].includes(route.kind) } +/** + * 建立單頁 scan plan 與 cache key。 + * cache 命中時會沿用 browser capture artifacts,但仍重新產生 contract,讓推論邏輯更新可重跑。 + */ async function prepareScanFile(config, file) { const hash = await sha256File(file) const name = artifactPath(config.prototypeDir, file) @@ -129,6 +144,10 @@ async function prepareScanFile(config, file) { return { file, hash, name, cacheDir, metadataPath, cacheHit } } +/** + * 掃描單一 HTML 並產出頁面級 spec。 + * browser capture 負責 runtime evidence;source HTML 負責 deterministic fallback,兩者會在 page contract 中合併。 + */ async function scanFile(config, plan, serverUrl) { const { file, hash, name, cacheDir, metadataPath, cacheHit } = plan const html = await readFile(file, 'utf8') @@ -160,6 +179,16 @@ async function scanFile(config, plan, serverUrl) { hash, generatedAt: new Date().toISOString(), page: basename(file), + captureArtifacts: { + screenshots: capture.screenshots, + domSummary: join(cacheDir, 'dom-summary.json'), + accessibilityTree: join(cacheDir, 'accessibility-tree.json'), + metadata: metadataPath + }, + browserEvidence: { + externalResourceFailures: capture.externalResourceFailures ?? [], + consoleMessages: capture.consoleMessages ?? [] + }, pageContract, regions: regionSpecs, validation: validationReport @@ -171,6 +200,10 @@ async function scanFile(config, plan, serverUrl) { return layoutSpec } +/** + * 透過 Vite 與 Playwright CLI 擷取 rendered prototype。 + * 回傳值會同時寫成 cache artifacts,並保留 resource failure 與 console warning/error 供後續人工判讀。 + */ async function captureRenderedPrototype(config, plan, serverUrl) { if (!serverUrl) throw new Error('Stage 1 capture 需要啟動 prototype static server') const resolved = await resolvePlaywrightCli(config.cwd) @@ -226,6 +259,10 @@ async function captureRenderedPrototype(config, plan, serverUrl) { } } +/** + * 建立 Playwright CLI 執行的頁面擷取程式。 + * 這段字串刻意集中處理 rendered DOM evidence,讓 source HTML 與實際瀏覽器狀態的差異能被 capture artifacts 保留下來。 + */ function captureScript(url, viewport, state, screenshotPath) { return `async page => { const requestFailures = []; @@ -271,6 +308,31 @@ function captureScript(url, viewport, state, screenshotPath) { .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 controlLabel = element => { + const id = attr(element, 'id'); + const byFor = id ? document.querySelector('label[for="' + CSS.escape(id) + '"]') : null; + if (byFor && text(byFor)) return text(byFor); + const labelledBy = (attr(element, 'aria-labelledby') || '').split(/\\s+/).filter(Boolean) + .map(id => document.getElementById(id)) + .map(item => item ? text(item) : '') + .filter(Boolean) + .join(' '); + if (labelledBy) return labelledBy; + const wrappingLabel = element.closest('label'); + if (wrappingLabel && text(wrappingLabel)) return text(wrappingLabel); + const aria = attr(element, 'aria-label'); + if (aria) return aria; + const row = element.closest('tr'); + if (row) { + const cells = Array.from(row.children); + const index = cells.findIndex(cell => cell.contains(element)); + for (let i = index - 1; i >= 0; i -= 1) { + const label = text(cells[i]).replace(/[::]$/, ''); + if (label) return label; + } + } + return attr(element, 'placeholder') || attr(element, 'name') || attr(element, 'id') || ''; + }; const bbox = element => { const rect = element.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return null; @@ -282,9 +344,15 @@ function captureScript(url, viewport, state, screenshotPath) { }; const inputs = Array.from(document.querySelectorAll('input, select, textarea')).map(element => ({ tag: element.tagName.toLowerCase(), - type: attr(element, 'type'), + type: attr(element, 'type') || element.tagName.toLowerCase(), name: attr(element, 'name') || attr(element, 'id'), + label: controlLabel(element), required: element.required, + readonly: element.readOnly, + disabled: element.disabled, + maxLength: element.maxLength > 0 ? String(element.maxLength) : null, + options: element.tagName.toLowerCase() === 'select' ? Array.from(element.options).map(option => text(option)).filter(Boolean) : [], + defaultValue: element.value || null, bbox: bbox(element) })); const boxes = selector => Array.from(document.querySelectorAll(selector)).map((element, index) => ({ @@ -316,7 +384,7 @@ function captureScript(url, viewport, state, screenshotPath) { sections: sectionEvidence, formRows: Array.from(document.querySelectorAll('tr, .row, form > div, fieldset')).map((element, index) => ({ id: 'form-row-' + (index + 1), - fields: Array.from(element.querySelectorAll('input, select, textarea')).map(field => ({ name: attr(field, 'name') || attr(field, 'id'), label: attr(field, 'aria-label') || attr(field, 'placeholder') || '', type: attr(field, 'type') || field.tagName.toLowerCase(), bbox: bbox(field) })).filter(field => field.bbox), + fields: Array.from(element.querySelectorAll('input, select, textarea')).map(field => ({ name: attr(field, 'name') || attr(field, 'id'), label: controlLabel(field), type: attr(field, 'type') || field.tagName.toLowerCase(), bbox: bbox(field) })).filter(field => field.bbox), bbox: bbox(element) })).filter(row => row.fields.length > 0), tables: tableEvidence, @@ -471,6 +539,10 @@ function validateSpecs(specs, pageContract, domSummary) { } } +/** + * 產生人工 review 用的 UI contract Markdown。 + * JSON spec 是機器可讀來源;這份 Markdown 只摘要最關鍵的 UI、BDD、browser 與 API evidence。 + */ function renderUiContract(spec) { const lines = [`# UI Contract: ${spec.page}`, ''] const contract = spec.pageContract @@ -545,10 +617,29 @@ function renderUiContract(spec) { for (const scenario of spec.bddContract?.scenarios ?? []) { lines.push(`- ${scenario.type}: ${scenario.name}`) } + if (spec.bddContract?.candidateScenarios?.length) lines.push(`- Candidate scenarios: ${spec.bddContract.candidateScenarios.length}`) + if (spec.bddContract?.uncoveredEvidence?.length) lines.push(`- Uncovered evidence: ${spec.bddContract.uncoveredEvidence.length}`) if (spec.bddContract?.warnings?.length) { lines.push(`- Requires human review: ${spec.bddContract.requiresHumanReview ? 'yes' : 'no'}`) } lines.push('') + lines.push('## Capture Artifacts', '') + if (!spec.captureArtifacts) lines.push('- none') + else { + lines.push(`- DOM summary: ${spec.captureArtifacts.domSummary}`) + lines.push(`- Accessibility tree: ${spec.captureArtifacts.accessibilityTree}`) + lines.push(`- Metadata: ${spec.captureArtifacts.metadata}`) + lines.push(`- Screenshots: ${(spec.captureArtifacts.screenshots ?? []).map((item) => `${item.viewport}/${item.state}`).join(', ') || 'none'}`) + } + lines.push('') + lines.push('## Browser Evidence', '') + const resourceFailures = spec.browserEvidence?.externalResourceFailures ?? [] + const consoleMessages = spec.browserEvidence?.consoleMessages ?? [] + lines.push(`- Resource failures: ${resourceFailures.length}`) + for (const failure of resourceFailures.slice(0, 5)) lines.push(` - ${failure.status ?? failure.reason}: ${failure.url}`) + lines.push(`- Console warnings/errors: ${consoleMessages.length}`) + for (const message of consoleMessages.slice(0, 5)) lines.push(` - ${message.type}: ${message.text}`) + 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 a109887..48845ec 100644 --- a/test/cli-e2e.test.js +++ b/test/cli-e2e.test.js @@ -43,12 +43,17 @@ test('CLI runs doctor and scan against one prototype', async () => { assert.match(doctor.stdout, /ok prototype directory/) assert.match(contract, /Customer Portal/) assert.match(contract, /BDD Scenarios/) + assert.match(contract, /Capture Artifacts/) + assert.match(contract, /Browser Evidence/) 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.match(spec.captureArtifacts.domSummary, /\.ht\/cache\/prototype\/index\/dom-summary\.json$/) + assert.match(spec.captureArtifacts.accessibilityTree, /\.ht\/cache\/prototype\/index\/accessibility-tree\.json$/) + assert.equal(Array.isArray(spec.browserEvidence.externalResourceFailures), true) 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 8e1ca58..99abe9a 100644 --- a/test/html.test.js +++ b/test/html.test.js @@ -512,7 +512,7 @@ test('buildBddContract reports scenario candidates and uncovered evidence', () = } spec.prototypeGuide = { description: '使用者登入', - checklist: ['登入失敗時顯示錯誤訊息', '連續錯誤三次鎖定帳號'], + checklist: ['登入失敗時顯示錯誤訊息', '連續錯誤三次鎖定帳號', '支援外部 SSO 導向'], flowRefs: [{ title: '登入失敗流程', nodes: [{ nodeType: 'error', action: '顯示錯誤訊息' }] }] } spec.apiContract = { @@ -524,14 +524,37 @@ test('buildBddContract reports scenario candidates and uncovered evidence', () = const markdown = renderBddContract(spec, contract) assert.ok(contract.candidateScenarios.some((candidate) => candidate.type === 'error-path')) + assert.ok(contract.candidateScenarios.some((candidate) => candidate.source === 'prototypeGuide.checklist' && candidate.evidenceText === '登入失敗時顯示錯誤訊息')) 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 === 'prototypeGuide.checklist' && item.text === '支援外部 SSO 導向')) + assert.ok(!contract.uncoveredEvidence.some((item) => 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, /連續錯誤三次鎖定帳號/) + assert.match(markdown, /支援外部 SSO 導向/) +}) + +test('buildPageContract prefers meaningful rendered labels over fallback control names', () => { + const contract = buildPageContract({ + page: 'dynamic-form.html', + source: '/repo/prototype/dynamic-form.html', + html: '
', + regions: extractRegions('
'), + domSummary: { + title: null, + headings: [], + buttons: [], + labels: ['Email'], + inputs: [{ tag: 'input', type: 'text', name: 'email', label: 'Email', required: true }], + textSamples: ['Email'] + }, + screenshotPath: null + }) + + assert.equal(contract.forms[0].fields[0].label, 'Email') + assert.equal(contract.forms[0].fields[0].required, true) }) function buildSpec(source, html) {