check-software-dashboard.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. // Browser acceptance against a real, already-running read-only panel.
  2. // Failure/empty checks below intercept responses only inside a fresh browser context.
  3. import assert from 'node:assert/strict'
  4. import { mkdir } from 'node:fs/promises'
  5. const { chromium } = await import(process.env.PLAYWRIGHT_MODULE)
  6. const url = process.env.DASHBOARD_URL
  7. assert(url, 'DASHBOARD_URL is required')
  8. const out = process.env.DASHBOARD_OUTPUT || '/tmp/software-dashboard-check'
  9. await mkdir(out, { recursive: true })
  10. const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_EXECUTABLE, headless: true })
  11. try {
  12. const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 })
  13. const errors = []
  14. const mutations = []
  15. page.on('pageerror', e => errors.push(e.message))
  16. page.on('request', r => { if (r.method() !== 'GET') mutations.push(r.url()) })
  17. const reviewResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/review' && response.status() === 200)
  18. await page.goto(url)
  19. const review = await (await reviewResponse).json()
  20. await page.locator('#summary h1').waitFor()
  21. await page.waitForFunction(() => document.querySelector('#sync').textContent.startsWith('已同步'))
  22. assert.equal(await page.locator('.metric').count(), 4)
  23. assert(await page.locator('.directory').count() > 0, 'server must provide real repository inventory')
  24. await page.evaluate(() => window.scrollTo(0, 0))
  25. await page.screenshot({ path: out + '/01-overview.png', fullPage: true })
  26. await page.locator('nav [data-tab="impact"]').click()
  27. const candidateID = await page.locator('#candidate').inputValue()
  28. const candidate = review.candidates.find(item => item.id === candidateID)
  29. assert(candidate, 'displayed candidate must belong to this review')
  30. const paths = candidate.ontology_context?.impact_paths || []
  31. assert.equal(await page.locator('svg[aria-label="候选的静态可达关联图"]').count(), paths.length ? 1 : 0)
  32. if (!paths.length) await page.getByText('暂无可展示的关联路径;不表示不存在依赖。', { exact: true }).waitFor()
  33. await page.locator('details').first().locator('summary').click()
  34. assert(!(await page.locator('details').first().innerText()).includes('[object Object]'))
  35. await page.evaluate(() => window.scrollTo(0, 0))
  36. await page.screenshot({ path: out + '/02-impact.png', fullPage: true })
  37. await page.locator('nav [data-tab="changes"]').click()
  38. await page.locator('[data-comparison]').click()
  39. await page.locator('#comparison .minimetrics').waitFor()
  40. await page.evaluate(() => window.scrollTo(0, 0))
  41. await page.screenshot({ path: out + '/03-changes.png', fullPage: true })
  42. await page.locator('[data-evidence="changes.patch"]').click()
  43. await page.waitForFunction(() => document.querySelector('#artifact-text').textContent.startsWith('---'))
  44. await page.locator('#close').click()
  45. await page.locator('nav [data-tab="tests"]').click()
  46. await page.locator('tbody tr').first().waitFor()
  47. assert.equal(await page.locator('tbody tr').count(), 2)
  48. await page.evaluate(() => window.scrollTo(0, 0))
  49. await page.screenshot({ path: out + '/04-tests.png', fullPage: true })
  50. await page.locator('nav [data-tab="risks"]').click()
  51. await page.locator('#filter').selectOption('selected')
  52. assert(await page.locator('.candidate-row').count() > 0)
  53. await page.locator('#search').fill('not-a-real-file-xyz')
  54. assert.equal(await page.locator('.candidate-row').count(), 0)
  55. await page.setViewportSize({ width: 390, height: 844 })
  56. await page.locator('nav [data-tab="overview"]').click()
  57. assert(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), 'no mobile overflow')
  58. await page.evaluate(() => window.scrollTo(0, 0))
  59. await page.screenshot({ path: out + '/05-mobile.png', fullPage: true })
  60. assert.deepEqual(errors, [])
  61. assert.deepEqual(mutations, [])
  62. await page.close()
  63. // Synthetic boundaries: unknown data, failed tests, update, disconnection and hostile text.
  64. const boundary = await browser.newPage({ viewport: { width: 1200, height: 900 } })
  65. let state = { id: 'boundary', revision: 0, phase: 'analyzing', project: '<img src=x onerror=alert(1)>', candidates: [], decisions: {}, coverage: {} }
  66. let fail = false
  67. let liveEvidence = null
  68. boundary.on('pageerror', e => errors.push(e.message))
  69. await boundary.route('**/api/**', async route => {
  70. if (fail) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'Synthetic connection failure' }) })
  71. const evidence = liveEvidence || { revision: state.revision, files: [], checks: state.phase === 'verification_failed' ? {
  72. 'baseline/checks.json': { passed: true, case_counts: { passed: 10, failed: 0, skipped: 2, unknown: 0 } },
  73. 'round-1/checks.json': { passed: false, case_counts: { passed: 9, failed: 1, skipped: 2, unknown: 0 } },
  74. } : {} }
  75. return route.fulfill({ contentType: 'application/json', body: JSON.stringify(new URL(route.request().url()).pathname === '/api/review' ? state : evidence) })
  76. })
  77. await boundary.goto(url)
  78. await boundary.locator('#summary h1').waitFor()
  79. assert.equal(await boundary.locator('#summary img').count(), 0, 'untrusted data must be escaped')
  80. assert.equal(await boundary.locator('.metric strong').last().textContent(), '—')
  81. await boundary.locator('nav [data-tab="tests"]').click()
  82. await boundary.getByText('本轮尚无测试检查报告;未执行不计通过。', { exact: true }).waitFor()
  83. state = { ...state, revision: 1, phase: 'verification_failed', execution: { result: { generation: 1, changed_files: [], verification_passed: false } } }
  84. await boundary.locator('#refresh').click()
  85. await boundary.getByText('改后验证失败', { exact: true }).waitFor()
  86. await boundary.locator('tbody tr').nth(1).waitFor()
  87. assert((await boundary.locator('tbody tr').nth(1).innerText()).includes('未通过'))
  88. fail = true
  89. await boundary.locator('#refresh').click()
  90. await boundary.getByText('连接中断 · 展示上次快照', { exact: true }).waitFor()
  91. assert((await boundary.locator('#error').innerText()).includes('不是最新状态'))
  92. assert.equal(await boundary.locator('tbody tr').count(), 2, 'preserve last snapshot on disconnect')
  93. fail = false
  94. for (const example of [
  95. { name: '财务核算服务', label: 'Python 文件', adapter: 'synthetic-python', path: 'ledger/rules.py', title: '费用边界规则', extension: '.py' },
  96. { name: '仓储调度前端', label: 'TypeScript 文件', adapter: 'synthetic-typescript', path: 'dispatch/index.ts', title: '接口职责边界', extension: '.ts' },
  97. ]) {
  98. state = { id: 'boundary', revision: state.revision + 1, phase: 'awaiting_selection', project: example.name,
  99. analysis_scope: { label: example.label, total_files: 12, adapter: example.adapter },
  100. coverage: { analyzed_files: 9 }, decisions: {},
  101. repository_summary: { files: 20, directory_group_basis: 'snapshot_files', directory_groups: [{ name: 'src', files: 12 }, { name: 'docs', files: 8 }],
  102. facts: [{ label: '纳入快照的文件', value: 20 }], file_types: [{ extension: example.extension, files: 12 }] },
  103. candidates: [{ id: 'new-kind', kind: 'adapter_specific_rule', title: example.title, path: example.path, source_range: { start: 3, end: 9 }, ontology_context: { dependency_paths: [{ path: 'contracts/contract'+example.extension, via: [{ origin: { path: example.path, line: 3 } }] }], dependency_paths_total: 1, unresolved_references_total: 2, unresolved_references: [{ path: example.path, line: 4, target: "ExternalPolicy", reason: "external_or_unindexed_symbol" }] } }],
  104. }
  105. await boundary.locator('#refresh').click()
  106. await boundary.getByRole('heading', { name: example.name, exact: true }).waitFor()
  107. await boundary.locator('nav [data-tab="overview"]').click()
  108. assert((await boundary.locator('.metric').first().innerText()).includes(example.label))
  109. assert(!(await boundary.locator('#content').innerText()).includes('Maven'))
  110. assert.equal(await boundary.locator('.directory').count(), 2)
  111. await boundary.locator('nav [data-tab="impact"]').click()
  112. await boundary.getByRole('heading', { name: example.title, exact: true }).waitFor()
  113. await boundary.getByRole('heading', { name: '下游依赖 · 需要保持哪些组件契约', exact: true }).waitFor()
  114. assert((await boundary.locator('#content').innerText()).includes('contracts/contract'+example.extension))
  115. assert((await boundary.locator('#content').innerText()).includes('2 条未解析引用'))
  116. await boundary.getByText('待查证引用 · 2 条', { exact: true }).click()
  117. await boundary.getByText('ExternalPolicy', { exact: true }).waitFor()
  118. await boundary.getByText('外部库或未索引类型', { exact: true }).waitFor()
  119. assert(!(await boundary.locator('#content').innerText()).includes('[object Object]'))
  120. await boundary.locator('nav [data-tab="risks"]').click()
  121. assert((await boundary.locator('#content').innerText()).includes(example.adapter))
  122. }
  123. state = { ...state, revision: state.revision + 1, phase: 'running', execution: { result: { generation: 1 } } }
  124. liveEvidence = { revision: state.revision, token: 'first', files: [], checks: {
  125. 'baseline/checks.json': { passed: true, build_status: 'passed', test_status: 'passed', case_counts: { passed: 7 } },
  126. } }
  127. await boundary.locator('#refresh').click()
  128. await boundary.locator('nav [data-tab="tests"]').click()
  129. await boundary.locator('tbody tr').waitFor()
  130. assert.equal(await boundary.locator('tbody tr').count(), 1)
  131. liveEvidence = { ...liveEvidence, token: 'second', checks: { ...liveEvidence.checks,
  132. 'round-1/checks.json': { passed: false, build_status: 'passed', test_status: 'failed', test_reason: 'Synthetic assertion regression', case_counts: { passed: 6, failed: 1 } },
  133. } }
  134. await boundary.locator('#refresh').click()
  135. await boundary.locator('tbody tr').nth(1).waitFor()
  136. assert((await boundary.locator('tbody tr').nth(1).innerText()).includes('通过 / 失败'))
  137. await boundary.getByText('Synthetic assertion regression', { exact: true }).waitFor()
  138. state = { ...state, revision: state.revision + 1, phase: 'changes_requested',
  139. quality_review: { decision: 'revise', notes: { reason: '保留原有错误顺序,补充边界依据。' } },
  140. candidates: Array.from({ length: 65 }, (_, i) => ({ id: 'entry-' + i, path: 'src/Entry' + i + '.ts', symbol: 'Entry' + i, kind: 'synthetic' })),
  141. }
  142. liveEvidence = { ...liveEvidence, revision: state.revision }
  143. await boundary.locator('#refresh').click()
  144. await boundary.locator('nav [data-tab="risks"]').click()
  145. await boundary.getByText('保留原有错误顺序,补充边界依据。', { exact: true }).waitFor()
  146. assert.equal(await boundary.locator('.candidate-row').count(), 20)
  147. for (let i = 0; i < 3; i++) await boundary.locator('[data-candidate-page="1"]').click()
  148. assert.equal(await boundary.locator('.candidate-row').count(), 5)
  149. await boundary.getByRole('button', { name: '下一页', exact: true }).isDisabled().then(value => assert(value))
  150. await boundary.locator('#search').fill('Entry64')
  151. assert.equal(await boundary.locator('.candidate-row').count(), 1)
  152. state = { ...state, revision: state.revision + 1 }
  153. liveEvidence = { ...liveEvidence, revision: state.revision }
  154. await boundary.evaluate(() => poll())
  155. assert.equal(await boundary.locator('#search').inputValue(), 'Entry64')
  156. assert(await boundary.locator('#search').evaluate(el => el === document.activeElement), 'refresh must preserve active search')
  157. state = { ...state, revision: state.revision + 1, phase: 'analyzing', candidates: [], coverage: { analyzed_files: 240, java_files: 240 },
  158. progress: { stage: 'link', completed_batches: 2, total_batches: 2 } }
  159. liveEvidence = { ...liveEvidence, revision: state.revision }
  160. await boundary.locator('#refresh').click()
  161. await boundary.getByText('源码扫描完成,正在关联跨文件本体与约束。', { exact: true }).waitFor()
  162. assert.equal(await boundary.locator('.metric strong').nth(1).textContent(), '—', 'unfinished findings are not zero')
  163. assert.deepEqual(errors, [])
  164. // A running task may publish evidence without changing its review revision.
  165. // Never concatenate pages from different manifests, including modal next-page reads.
  166. const race = await browser.newPage()
  167. let evidenceToken = 'v1', replaceDuringRead = false
  168. const pageTokens = []
  169. const documentText = '{"changed":["Example.java"]}'
  170. race.on('pageerror', e => errors.push(e.message))
  171. await race.route('**/api/**', async route => {
  172. const target = new URL(route.request().url())
  173. if (target.pathname === '/api/review') return route.fulfill({ json: {
  174. id: 'race', revision: 1, phase: 'running', project: 'Evidence pagination fixture', candidates: [], decisions: {}, coverage: {},
  175. } })
  176. if (target.pathname === '/api/evidence') return route.fulfill({ json: {
  177. id: 'race', revision: 1, token: evidenceToken, files: [{ name: 'comparison.json', bytes: documentText.length }], checks: {},
  178. } })
  179. const requested = target.searchParams.get('token'), offset = Number(target.searchParams.get('offset'))
  180. pageTokens.push(requested)
  181. if (requested !== evidenceToken) return route.fulfill({ status: 409, json: { error: 'Evidence version changed; reopen the file.' } })
  182. if (offset === 0 && replaceDuringRead) {
  183. evidenceToken += '-updated'
  184. await race.evaluate(() => poll())
  185. }
  186. return route.fulfill({ json: { text: offset === 0 ? documentText.slice(0, 12) : documentText.slice(12),
  187. offset, next_offset: offset === 0 ? 12 : null, total_chars: documentText.length } })
  188. })
  189. await race.goto(url)
  190. await race.waitForFunction(() => index !== null && busy === false)
  191. assert.deepEqual(await race.evaluate(() => readDocument('comparison.json')), { changed: ['Example.java'] })
  192. assert.deepEqual(pageTokens.splice(0), ['v1', 'v1'])
  193. replaceDuringRead = true
  194. const failure = await race.evaluate(() => readDocument('comparison.json').catch(e => e.message))
  195. assert.equal(failure, 'Evidence version changed; reopen the file.')
  196. assert.deepEqual(pageTokens.splice(0), ['v1', 'v1'], 'JSON pages must retain the first token')
  197. const beforeModal = evidenceToken
  198. await race.evaluate(() => showArtifact('comparison.json'))
  199. await race.locator('#more').click()
  200. await race.getByText('Evidence version changed; reopen the file.', { exact: true }).waitFor()
  201. assert.deepEqual(pageTokens, [beforeModal, beforeModal], 'modal next page must retain the first token')
  202. assert.deepEqual(errors, [])
  203. await race.close()
  204. console.log('PASS: real review 5 views; unknown/failure/disconnection; Python/TypeScript schemas; live evidence at unchanged revision; rejection notes; all 65 paginated candidates; search focus; measured progress; version-pinned evidence pagination')
  205. } finally { await browser.close() }