fix: jsdoc
This commit is contained in:
@@ -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、動作與原始流程行。
|
||||
|
||||
+28
-3
@@ -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) {
|
||||
|
||||
+22
-6
@@ -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) {
|
||||
|
||||
+96
-5
@@ -14,9 +14,8 @@ const viewports = [
|
||||
]
|
||||
|
||||
/**
|
||||
* 執行 Stage 1:掃描 prototype HTML,產生 deterministic evidence 與 LayoutSpec artifacts。
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
* 掃描 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 ?? []) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
+26
-3
@@ -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: '<main><input id="email"></main>',
|
||||
regions: extractRegions('<main><input id="email"></main>'),
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user