fix: jsdoc
This commit is contained in:
@@ -207,6 +207,8 @@ node packages/html-transform/src/cli.js scan
|
|||||||
`.spec.json` 會包含:
|
`.spec.json` 會包含:
|
||||||
|
|
||||||
- `pageContract`:頁面文字、欄位、表格、actions、layout evidence 與 Vuetify checklist。
|
- `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 與錯誤格式。
|
- `apiContract`:與該頁匹配的 API endpoints、用途分類、rejected candidates 與錯誤格式。
|
||||||
- `prototypeGuide`:該 prototype 對應的人工 guide、舊 JSP/PB、target view 與 checklist。
|
- `prototypeGuide`:該 prototype 對應的人工 guide、舊 JSP/PB、target view 與 checklist。
|
||||||
- `prototypeGuide.flowRefs`:從 guide 的 legacy flow code block 比對出的相關流程節點,包含 menu、task、nodeType、JSP、PB、動作與原始流程行。
|
- `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) {
|
export function buildBddContract(spec) {
|
||||||
const pageKind = spec.maintenanceContract?.pageKind ?? 'page'
|
const pageKind = spec.maintenanceContract?.pageKind ?? 'page'
|
||||||
const feature = inferFeature(spec)
|
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) {
|
export function renderBddContract(spec, contract = spec.bddContract) {
|
||||||
const lines = [`# BDD Contract: ${spec.page}`, '']
|
const lines = [`# BDD Contract: ${spec.page}`, '']
|
||||||
lines.push(`Feature: ${contract.feature}`)
|
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) {
|
function buildCandidateScenarios(spec, scenarios, feature) {
|
||||||
const candidates = []
|
const candidates = []
|
||||||
for (const item of spec.prototypeGuide?.checklist ?? []) {
|
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}`)
|
return dedupeByKey(candidates, (candidate) => `${candidate.type}:${candidate.source}:${candidate.evidenceText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出 scan 看得到、但沒有被正式 scenario 或候選 scenario 消化的 evidence。
|
||||||
|
* 這是 BDD review 的缺口清單,不代表 prototype 錯誤。
|
||||||
|
*/
|
||||||
function buildUncoveredEvidence(spec, scenarios, candidateScenarios) {
|
function buildUncoveredEvidence(spec, scenarios, candidateScenarios) {
|
||||||
const coveredText = [
|
const coveredText = [
|
||||||
...scenarios.flatMap((item) => [
|
...scenarios.flatMap((item) => [
|
||||||
@@ -322,6 +344,10 @@ function errorFormats(spec) {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判斷人工文字是否已出現在正式 scenario 的可執行語意中。
|
||||||
|
* Checklist 與 flow refs 只是 trace,不算覆蓋,否則會遮蔽還沒寫成 scenario 的業務分支。
|
||||||
|
*/
|
||||||
function isCoveredByScenarios(text, scenarios) {
|
function isCoveredByScenarios(text, scenarios) {
|
||||||
return textIncludesEvidence(scenarios.flatMap((scenario) => [
|
return textIncludesEvidence(scenarios.flatMap((scenario) => [
|
||||||
scenario.name,
|
scenario.name,
|
||||||
@@ -330,8 +356,7 @@ function isCoveredByScenarios(text, scenarios) {
|
|||||||
...scenario.then,
|
...scenario.then,
|
||||||
...scenario.evidence.fields,
|
...scenario.evidence.fields,
|
||||||
...scenario.evidence.actions,
|
...scenario.evidence.actions,
|
||||||
...scenario.evidence.checklist,
|
...scenario.evidence.endpoints
|
||||||
...scenario.evidence.flowRefs
|
|
||||||
]).join(' '), text)
|
]).join(' '), text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +367,7 @@ function textIncludesEvidence(haystack, needle) {
|
|||||||
if (normalizedHaystack.includes(normalizedNeedle)) return true
|
if (normalizedHaystack.includes(normalizedNeedle)) return true
|
||||||
const keywords = normalizedNeedle.split(/\s+/).filter((word) => word.length >= 2)
|
const keywords = normalizedNeedle.split(/\s+/).filter((word) => word.length >= 2)
|
||||||
if (keywords.length === 0) return true
|
if (keywords.length === 0) return true
|
||||||
return keywords.some((word) => normalizedHaystack.includes(word))
|
return keywords.every((word) => normalizedHaystack.includes(word))
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeForCoverage(text) {
|
function normalizeForCoverage(text) {
|
||||||
|
|||||||
+22
-6
@@ -635,12 +635,28 @@ function groupFieldsIntoRows(fields) {
|
|||||||
function mergeInputs(sourceInputs, domInputs) {
|
function mergeInputs(sourceInputs, domInputs) {
|
||||||
if (sourceInputs.length === 0) return domInputs
|
if (sourceInputs.length === 0) return domInputs
|
||||||
if (domInputs.length === 0) return sourceInputs
|
if (domInputs.length === 0) return sourceInputs
|
||||||
return sourceInputs.map((sourceInput, index) => ({
|
return sourceInputs.map((sourceInput, index) => {
|
||||||
...domInputs[index],
|
const domInput = domInputs[index] ?? {}
|
||||||
...sourceInput,
|
const sourceLabel = meaningfulFieldLabel(sourceInput)
|
||||||
name: sourceInput.name ?? domInputs[index]?.name ?? null,
|
const domLabel = meaningfulFieldLabel(domInput)
|
||||||
label: sourceInput.label ?? domInputs[index]?.label ?? null
|
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) {
|
function buildForms(summary) {
|
||||||
|
|||||||
+96
-5
@@ -14,9 +14,8 @@ const viewports = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 執行 Stage 1:掃描 prototype HTML,產生 deterministic evidence 與 LayoutSpec artifacts。
|
* 掃描 prototype HTML 並產出可 review 的 evidence artifacts。
|
||||||
*
|
* 流程會先擷取 browser evidence,再用 app map、API catalog 與 prototype guide enrich 每頁 spec,最後才寫出完整 contract。
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
*/
|
||||||
export async function scan() {
|
export async function scan() {
|
||||||
const config = await loadConfig()
|
const config = await loadConfig()
|
||||||
@@ -54,6 +53,10 @@ export async function scan() {
|
|||||||
console.log(`scan 完成:${htmlFiles.length} 個 HTML 檔案`)
|
console.log(`scan 完成:${htmlFiles.length} 個 HTML 檔案`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀取 prototype 旁的人工導覽文件。
|
||||||
|
* 這些 guide 是 legacy JSP/PB、checklist 與 flow refs 的來源,後續 BDD 與 maintenance contract 都會使用。
|
||||||
|
*/
|
||||||
async function readPrototypeGuides(config) {
|
async function readPrototypeGuides(config) {
|
||||||
const files = await listFiles(config.prototypeDir, ['.md'])
|
const files = await listFiles(config.prototypeDir, ['.md'])
|
||||||
const guides = []
|
const guides = []
|
||||||
@@ -64,6 +67,10 @@ async function readPrototypeGuides(config) {
|
|||||||
return guides.filter((guide) => guide.entries.length > 0)
|
return guides.filter((guide) => guide.entries.length > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立跨頁面的 API catalog。
|
||||||
|
* scan 不直接呼叫後端,只從 markdown/OpenAPI 文件抽取 endpoint、schema 與錯誤格式作為可追溯 evidence。
|
||||||
|
*/
|
||||||
async function readApiCatalog(config) {
|
async function readApiCatalog(config) {
|
||||||
const documents = []
|
const documents = []
|
||||||
for (const docsDir of config.backendDocsDirs ?? [config.backendDocsDir]) {
|
for (const docsDir of config.backendDocsDirs ?? [config.backendDocsDir]) {
|
||||||
@@ -78,6 +85,10 @@ async function readApiCatalog(config) {
|
|||||||
return buildApiCatalog(documents)
|
return buildApiCatalog(documents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 將 app map、API match 與 prototype guide 回填到每頁 spec。
|
||||||
|
* 這個階段必須在所有頁面初步 scan 完後執行,因為 route/module 判斷依賴跨頁面 app map。
|
||||||
|
*/
|
||||||
function enrichSpecsWithRouteContracts(layoutSpecs, appMap, apiCatalog, prototypeDir) {
|
function enrichSpecsWithRouteContracts(layoutSpecs, appMap, apiCatalog, prototypeDir) {
|
||||||
const routeByPrototype = new Map(appMap.routes.map((route) => [route.prototype, route]))
|
const routeByPrototype = new Map(appMap.routes.map((route) => [route.prototype, route]))
|
||||||
for (const spec of layoutSpecs) {
|
for (const spec of layoutSpecs) {
|
||||||
@@ -119,6 +130,10 @@ function shouldMatchApi(route) {
|
|||||||
return ['auth', 'auth-support', 'feature-page'].includes(route.kind)
|
return ['auth', 'auth-support', 'feature-page'].includes(route.kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立單頁 scan plan 與 cache key。
|
||||||
|
* cache 命中時會沿用 browser capture artifacts,但仍重新產生 contract,讓推論邏輯更新可重跑。
|
||||||
|
*/
|
||||||
async function prepareScanFile(config, file) {
|
async function prepareScanFile(config, file) {
|
||||||
const hash = await sha256File(file)
|
const hash = await sha256File(file)
|
||||||
const name = artifactPath(config.prototypeDir, file)
|
const name = artifactPath(config.prototypeDir, file)
|
||||||
@@ -129,6 +144,10 @@ async function prepareScanFile(config, file) {
|
|||||||
return { file, hash, name, cacheDir, metadataPath, cacheHit }
|
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) {
|
async function scanFile(config, plan, serverUrl) {
|
||||||
const { file, hash, name, cacheDir, metadataPath, cacheHit } = plan
|
const { file, hash, name, cacheDir, metadataPath, cacheHit } = plan
|
||||||
const html = await readFile(file, 'utf8')
|
const html = await readFile(file, 'utf8')
|
||||||
@@ -160,6 +179,16 @@ async function scanFile(config, plan, serverUrl) {
|
|||||||
hash,
|
hash,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
page: basename(file),
|
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,
|
pageContract,
|
||||||
regions: regionSpecs,
|
regions: regionSpecs,
|
||||||
validation: validationReport
|
validation: validationReport
|
||||||
@@ -171,6 +200,10 @@ async function scanFile(config, plan, serverUrl) {
|
|||||||
return layoutSpec
|
return layoutSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 透過 Vite 與 Playwright CLI 擷取 rendered prototype。
|
||||||
|
* 回傳值會同時寫成 cache artifacts,並保留 resource failure 與 console warning/error 供後續人工判讀。
|
||||||
|
*/
|
||||||
async function captureRenderedPrototype(config, plan, serverUrl) {
|
async function captureRenderedPrototype(config, plan, serverUrl) {
|
||||||
if (!serverUrl) throw new Error('Stage 1 capture 需要啟動 prototype static server')
|
if (!serverUrl) throw new Error('Stage 1 capture 需要啟動 prototype static server')
|
||||||
const resolved = await resolvePlaywrightCli(config.cwd)
|
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) {
|
function captureScript(url, viewport, state, screenshotPath) {
|
||||||
return `async page => {
|
return `async page => {
|
||||||
const requestFailures = [];
|
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') || '')
|
.map(element => text(element) || element.value || attr(element, 'aria-label') || attr(element, 'title') || '')
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const labels = Array.from(document.querySelectorAll('label')).map(text).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 bbox = element => {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
if (rect.width <= 0 || rect.height <= 0) return null;
|
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 => ({
|
const inputs = Array.from(document.querySelectorAll('input, select, textarea')).map(element => ({
|
||||||
tag: element.tagName.toLowerCase(),
|
tag: element.tagName.toLowerCase(),
|
||||||
type: attr(element, 'type'),
|
type: attr(element, 'type') || element.tagName.toLowerCase(),
|
||||||
name: attr(element, 'name') || attr(element, 'id'),
|
name: attr(element, 'name') || attr(element, 'id'),
|
||||||
|
label: controlLabel(element),
|
||||||
required: element.required,
|
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)
|
bbox: bbox(element)
|
||||||
}));
|
}));
|
||||||
const boxes = selector => Array.from(document.querySelectorAll(selector)).map((element, index) => ({
|
const boxes = selector => Array.from(document.querySelectorAll(selector)).map((element, index) => ({
|
||||||
@@ -316,7 +384,7 @@ function captureScript(url, viewport, state, screenshotPath) {
|
|||||||
sections: sectionEvidence,
|
sections: sectionEvidence,
|
||||||
formRows: Array.from(document.querySelectorAll('tr, .row, form > div, fieldset')).map((element, index) => ({
|
formRows: Array.from(document.querySelectorAll('tr, .row, form > div, fieldset')).map((element, index) => ({
|
||||||
id: 'form-row-' + (index + 1),
|
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)
|
bbox: bbox(element)
|
||||||
})).filter(row => row.fields.length > 0),
|
})).filter(row => row.fields.length > 0),
|
||||||
tables: tableEvidence,
|
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) {
|
function renderUiContract(spec) {
|
||||||
const lines = [`# UI Contract: ${spec.page}`, '']
|
const lines = [`# UI Contract: ${spec.page}`, '']
|
||||||
const contract = spec.pageContract
|
const contract = spec.pageContract
|
||||||
@@ -545,10 +617,29 @@ function renderUiContract(spec) {
|
|||||||
for (const scenario of spec.bddContract?.scenarios ?? []) {
|
for (const scenario of spec.bddContract?.scenarios ?? []) {
|
||||||
lines.push(`- ${scenario.type}: ${scenario.name}`)
|
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) {
|
if (spec.bddContract?.warnings?.length) {
|
||||||
lines.push(`- Requires human review: ${spec.bddContract.requiresHumanReview ? 'yes' : 'no'}`)
|
lines.push(`- Requires human review: ${spec.bddContract.requiresHumanReview ? 'yes' : 'no'}`)
|
||||||
}
|
}
|
||||||
lines.push('')
|
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', '')
|
lines.push('## API Endpoints', '')
|
||||||
if (!spec.apiContract?.endpoints?.length) lines.push('- none')
|
if (!spec.apiContract?.endpoints?.length) lines.push('- none')
|
||||||
for (const endpoint of spec.apiContract?.endpoints ?? []) {
|
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(doctor.stdout, /ok prototype directory/)
|
||||||
assert.match(contract, /Customer Portal/)
|
assert.match(contract, /Customer Portal/)
|
||||||
assert.match(contract, /BDD Scenarios/)
|
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, /Feature: Customer portal entry/)
|
||||||
assert.match(bdd, /Scenario: 使用者填寫必要資料並送出成功/)
|
assert.match(bdd, /Scenario: 使用者填寫必要資料並送出成功/)
|
||||||
assert.doesNotMatch(contract, /Recommended template/)
|
assert.doesNotMatch(contract, /Recommended template/)
|
||||||
assert.equal(spec.pageContract.title, null)
|
assert.equal(spec.pageContract.title, null)
|
||||||
assert.equal(spec.bddContract.feature, 'Customer portal entry')
|
assert.equal(spec.bddContract.feature, 'Customer portal entry')
|
||||||
assert.equal(spec.bddContract.scenarios[0].type, 'application-submit')
|
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']), {
|
assert.deepEqual(pick(spec.pageContract.forms[0].fields[0], ['name', 'label', 'type', 'required']), {
|
||||||
name: 'email',
|
name: 'email',
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
|
|||||||
+26
-3
@@ -512,7 +512,7 @@ test('buildBddContract reports scenario candidates and uncovered evidence', () =
|
|||||||
}
|
}
|
||||||
spec.prototypeGuide = {
|
spec.prototypeGuide = {
|
||||||
description: '使用者登入',
|
description: '使用者登入',
|
||||||
checklist: ['登入失敗時顯示錯誤訊息', '連續錯誤三次鎖定帳號'],
|
checklist: ['登入失敗時顯示錯誤訊息', '連續錯誤三次鎖定帳號', '支援外部 SSO 導向'],
|
||||||
flowRefs: [{ title: '登入失敗流程', nodes: [{ nodeType: 'error', action: '顯示錯誤訊息' }] }]
|
flowRefs: [{ title: '登入失敗流程', nodes: [{ nodeType: 'error', action: '顯示錯誤訊息' }] }]
|
||||||
}
|
}
|
||||||
spec.apiContract = {
|
spec.apiContract = {
|
||||||
@@ -524,14 +524,37 @@ test('buildBddContract reports scenario candidates and uncovered evidence', () =
|
|||||||
const markdown = renderBddContract(spec, contract)
|
const markdown = renderBddContract(spec, contract)
|
||||||
|
|
||||||
assert.ok(contract.candidateScenarios.some((candidate) => candidate.type === 'error-path'))
|
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.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.ok(contract.uncoveredEvidence.some((item) => item.source === 'pageContract.actions' && item.text === '忘記密碼'))
|
||||||
assert.equal(contract.requiresHumanReview, true)
|
assert.equal(contract.requiresHumanReview, true)
|
||||||
assert.match(markdown, /## Candidate Scenarios/)
|
assert.match(markdown, /## Candidate Scenarios/)
|
||||||
assert.match(markdown, /登入失敗時顯示錯誤訊息/)
|
assert.match(markdown, /登入失敗時顯示錯誤訊息/)
|
||||||
assert.match(markdown, /## Uncovered Evidence/)
|
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) {
|
function buildSpec(source, html) {
|
||||||
|
|||||||
Reference in New Issue
Block a user