fix: jsdoc

This commit is contained in:
2026-05-24 11:14:05 +08:00
parent da9e388a11
commit 8d40f6972b
6 changed files with 179 additions and 17 deletions
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 evidencesource 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 ?? []) {
+5
View File
@@ -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
View File
@@ -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) {