check-software-dashboard.mjs 14 KB

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