| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- /**
- * 票 61 闸门变异测试:证明 t61 的每条关键断言**抓得住它要防的缺陷**(t59/t60 同一纪律)。
- *
- * 做法:对源文件做**单点**变异注入 → 跑 t61 → 断言指定断言转红 → 按字节还原并校验一致。
- *
- * 十二条变异对应本票的真实风险:
- * M1 页大小 10 → 20 → A1 红(服务端页码分页本身被吃掉)
- * M2 翻页请求的 token 守卫失效 → A7 红(迟到页响应落进新页码)
- * M3 历史单窗页大小退 100 → B1 红(历史单窗不再按每页 10 取)
- * M4 多窗页脚(已合并 N 个时间窗)删除 → C3 红(如实口径被吃掉)
- * M5 服务端分页块元数据删除 → R5 红(历史单窗不画 pager)
- * M6 多窗块冒充服务端全局页 → C3 红(伪造全局页码)
- * M7 一页也画控件 → R4 红(条文 4 失守)
- * M8 服务端翻页不走 store → R6 红(翻页没有请求,页不真翻)
- * M9 条件变化不复位页码 → A4 红(条文 5/6 失守)
- * M10 历史事件重戴 12 条帽 → C3 红(合并集合被截断)
- * M11 mock 历史页不再按 page 切片 → F2 红(mock 同构失守)
- * M12 计数行暴露后端参数名 → S6 红(条文 10 失守)
- *
- * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t61-mutations.mjs
- */
- import assert from 'node:assert/strict'
- import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
- import { spawnSync } from 'node:child_process'
- import { tmpdir } from 'node:os'
- import { join } from 'node:path'
- import { fileURLToPath } from 'node:url'
- const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
- const GATE = fileURLToPath(new URL('t61-event-server-paging.mjs', import.meta.url))
- const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
- /** 用文件重定向取子进程输出(Windows 沙箱下管道捕获是文档化边界,见 t60 注释)。 */
- const reportDir = mkdtempSync(join(tmpdir(), 't61-mutations-'))
- function runGate() {
- const outPath = join(reportDir, 'stdout.txt')
- const errPath = join(reportDir, 'stderr.txt')
- const out = openSync(outPath, 'w')
- const err = openSync(errPath, 'w')
- let status = 0
- try {
- status = spawnSync(process.execPath, [GATE], { stdio: ['ignore', out, err] }).status
- } finally {
- closeSync(out)
- closeSync(err)
- }
- return { output: `${readFileSync(outPath, 'utf8')}${readFileSync(errPath, 'utf8')}`, status }
- }
- function failingNames(output) {
- return [...output.matchAll(/^FAIL ([^\s:]+)/gm)].map((match) => match[1])
- }
- let injected = 0
- let proved = 0
- function mutate(label, file, from, to, expectRed) {
- const path = srcPath(file)
- const original = readFileSync(path, 'utf8')
- const candidates = [from, from.replace(/\n/g, '\r\n')]
- const needle = candidates.find((candidate) => original.includes(candidate))
- if (!needle) {
- console.error(`FAIL ${label}: 变异源未命中(文件形状已变,需人工更新变异脚本)`)
- injected += 1
- return
- }
- injected += 1
- writeFileSync(path, original.replace(needle, to), 'utf8')
- let caught = false
- let how = ''
- try {
- const { output, status } = runGate()
- const red = failingNames(output)
- if (red.some((name) => name === expectRed || name.startsWith(expectRed))) {
- caught = true
- how = `${expectRed} 转红`
- } else if (status !== 0 && /does not provide an export|SyntaxError|Cannot find (module|export)/.test(output)) {
- caught = true
- how = '闸门整体加载失败(导出缺失)— 崩溃式抓住'
- } else if (status !== 0) {
- how = `退出码 ${status} 但目标断言未红(红项:${red.join(', ') || '无'})— 判据不明,按未抓住记`
- } else {
- how = '未转红(假绿!闸门仍全绿)'
- }
- console.log(`${caught ? 'PASS' : 'FAIL'} ${label} → ${how}`)
- if (caught) proved += 1
- } finally {
- writeFileSync(path, original, 'utf8')
- assert.equal(readFileSync(path, 'utf8'), original, `${label}: 还原失败`)
- }
- }
- // M1 页大小 10 → 20
- mutate('M1-page-size-back-to-20', 'domain/pagination.js', 'export const PAGE_SIZE = 10', 'export const PAGE_SIZE = 20', 'A1')
- // M2 翻页请求的 token 守卫失效(loadPage 的第一处守卫;单行锚点,命中即 loadPage)
- mutate(
- 'M2-late-page-token-guard-disabled',
- 'stores/eventStore.js',
- ' if (mine !== token) return null',
- ' if (mine !== token && false) return null',
- 'A7',
- )
- // M3 历史单窗页大小退 100
- mutate(
- 'M3-history-single-window-page-size-100',
- 'services/warningResponseApi.js',
- 'pageSize: singleWindow ? PAGE_SIZE : HISTORY_WINDOW_MAX_PAGE_SIZE',
- 'pageSize: singleWindow ? 100 : HISTORY_WINDOW_MAX_PAGE_SIZE',
- 'B1',
- )
- // M4 多窗页脚口径删除
- mutate('M4-merged-footer-note-removed', 'domain/warningResponsePanels.js', ' pageNote: `已合并 ${paging.windowCount || 0} 个时间窗 · 已加载 ${paging.loaded || 0} 条`,\n', '', 'C3')
- // M5 服务端分页块元数据删除
- mutate(
- 'M5-history-server-page-metadata-removed',
- 'domain/warningResponsePanels.js',
- ` return listBlock('历史事件', rows, emptyText, {
- serverPage: { page: paging.page, pageCount: paging.pageCount, total: paging.total },
- })`,
- ` return listBlock('历史事件', rows, emptyText)`,
- 'R5',
- )
- // M6 多窗块冒充服务端全局页(把前端集合分页换成伪造的全局页码)
- mutate(
- 'M6-merged-block-fakes-global-page',
- 'domain/warningResponsePanels.js',
- ` return listBlock('历史事件', rows, emptyText, {
- paginate: true,`,
- ` return listBlock('历史事件', rows, emptyText, {
- serverPage: { page: 1, pageCount: 2, total: paging.loaded },
- paginate: true,`,
- 'C3',
- )
- // M7 一页也画控件
- mutate(
- 'M7-single-page-still-draws-pager',
- 'components/EventListPane.vue',
- 'v-if="view.pagination && view.pagination.pageCount > 1" class="event-list-pane__pager"',
- 'v-if="view.pagination" class="event-list-pane__pager"',
- 'R4',
- )
- // M8 服务端翻页不走 store(块内吞掉翻页,不发 list-page)
- mutate(
- 'M8-server-page-flip-not-forwarded',
- 'components/PanelBlocks.vue',
- ` if (block.serverPage) {
- emit('list-page', { label: block.label, page: Number(target) || 1 })
- return
- }`,
- ` if (block.serverPage && false) {
- emit('list-page', { label: block.label, page: Number(target) || 1 })
- return
- }`,
- 'R6',
- )
- // M9 条件变化不复位页码
- mutate(
- 'M9-condition-change-keeps-page',
- 'stores/eventStore.js',
- 'if (CONDITION_KEYS.some((key) => before[key] !== after[key])) {',
- 'if (CONDITION_KEYS.some((key) => before[key] !== after[key]) && false) {',
- 'A4',
- )
- // M10 历史事件重戴 12 条帽
- mutate(
- 'M10-history-events-re-capped-at-12',
- 'domain/warningResponsePanels.js',
- ' const rows = events.map((item) => ({',
- ' const rows = events.slice(0, 12).map((item) => ({',
- 'C3',
- )
- // M11 mock 历史页不再按 page 切片(锚点必须落在 historyAnalysisPayload 里:
- // 同样的 `const page = ...` 也出现在 currentEventsPayload,单行锚点会命中错的那份)
- mutate(
- 'M11-mock-history-ignores-page',
- 'mocks/contractMocks.js',
- ` const events = scenario === 'empty' ? [] : sortEventRows(source.items)
- const page = Number(query && query.page) > 0 ? Number(query.page) : 1`,
- ` const events = scenario === 'empty' ? [] : sortEventRows(source.items)
- const page = 1`,
- 'F2',
- )
- // M12 计数行暴露后端参数名
- mutate(
- 'M12-count-row-exposes-param-names',
- 'components/PagerNav.vue',
- '第 {{ page }}/{{ pageCount }} 页 · 共 {{ total }} 条',
- '第 {{ page }}/{{ pageCount }} 页 · pageSize={{ total }}',
- 'S6',
- )
- rmSync(reportDir, { recursive: true, force: true })
- console.log('')
- console.log(`T61 变异:${proved}/${injected} 条被闸门抓住。`)
- if (proved !== injected) {
- console.error('有变异未被抓住(闸门有假绿风险)。')
- process.exit(1)
- }
- console.log('T61 变异全部打红并按字节还原。')
|