web/test/offline-selftest.ts141 lines · 9354 bytes
| 1 | // Offline-mode self-test (?mode=offline): plays the shipped src/recorded-run.json with a fake clock and checks that |
| 2 | // nothing ever tries to reach the network (fetch, WebSocket, EventSource and XHR are traps). npm run selftest |
| 3 | import { attempts } from './offline-stub' |
| 4 | import { installDom } from './domstub' |
| 5 | import { readFileSync } from 'node:fs' |
| 6 | import { join } from 'node:path' |
| 7 | import { airlockView, startTitle, type AirlockPhase } from '../src/airlock' |
| 8 | import { PENDING, RECORDING } from '../src/chaindata' |
| 9 | import { CONFIG } from '../src/config' |
| 10 | import { blockTs } from '../src/events' |
| 11 | import { setLang } from '../src/i18n' |
| 12 | import { startOffline } from '../src/offline' |
| 13 | import { replayFrame, tickReplay } from '../src/playback' |
| 14 | import { CHAIN, MODE, OFFLINE } from '../src/runtime' |
| 15 | import { store } from '../src/store' |
| 16 | import { render, ui } from '../src/ui' |
| 17 | import { findBlocked, NAMES_NOTE } from './scan' |
| 18 | |
| 19 | let fails = 0 |
| 20 | function ok(cond: unknown, msg: string) { |
| 21 | console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`) |
| 22 | if (!cond) fails++ |
| 23 | } |
| 24 | const CJK = /[㐀-鿿 -〿-]/ |
| 25 | const plain = (h: string) => h.replace(/<button data-lang[^>]*>[\s\S]*?<\/button>/g, '').replace(/<[^>]+>/g, ' ').replace(/'/g, "'").replace(/\s+/g, ' ') |
| 26 | |
| 27 | ok(MODE === 'offline' && OFFLINE, '?mode=offline selects offline playback') |
| 28 | ok(CHAIN.http.length === 0 && CHAIN.ws.length === 0 && CHAIN.explorerTx === null && CHAIN.sourcifyApi === null, 'offline: no RPC endpoint, no explorer, no Sourcify') |
| 29 | const raw = readFileSync(join(process.cwd(), 'src', 'recorded-run.json'), 'utf8') |
| 30 | ok(findBlocked(raw).length === 0, `recording has no blocked names or local paths (${NAMES_NOTE})${findBlocked(raw).length ? ': ' + findBlocked(raw).join(', ') : ''}`) |
| 31 | ok(RECORDING.v === 1 && RECORDING.deployment.chainId === 11155111, `recording format v1 on Sepolia (source: ${RECORDING.source})`) |
| 32 | |
| 33 | const dom = installDom() |
| 34 | let NOW = Date.now() |
| 35 | Date.now = () => NOW |
| 36 | startTitle() |
| 37 | |
| 38 | if (RECORDING.source === 'none') { |
| 39 | ok(PENDING, 'no recording yet: the page says so') |
| 40 | render() |
| 41 | ok(plain(dom.html()).includes('还没有录制资料'), 'empty recording: "no recording yet" message') |
| 42 | } else { |
| 43 | const R = RECORDING |
| 44 | startOffline() |
| 45 | const ids = Object.keys(R.proposals) |
| 46 | ok(ids.length >= 2 && ids.every((id) => store.proposals.has(id)), `${ids.length} recorded proposals loaded`) |
| 47 | ok(Object.keys(R.blocks).every((n) => blockTs.get(Number(n)) === R.blocks[n]) && Object.keys(R.receipts).every((tx) => store.receipts.has(tx.toLowerCase())), 'block timestamps and receipts come from the recording') |
| 48 | // every proposal's final state at the end of the recording agrees with getProposal + clearanceOf |
| 49 | const finals = new Map<string, AirlockPhase>() |
| 50 | for (const id of ids) { |
| 51 | const p = store.proposals.get(id)! |
| 52 | const v = airlockView(p, R.head, (n) => blockTs.get(n)) |
| 53 | finals.set(id, v.phase) |
| 54 | const st = R.proposals[id].status |
| 55 | const want = st === 2 ? ['ejected'] : st === 3 ? ['executed'] : !/^0x0*$/.test(R.proposals[id].clearanceOf) ? ['cleared', 'ready'] : ['chamber', 'waiting'] |
| 56 | ok(want.includes(v.phase), `#${id} ${p.card ?? p.target}: final phase ${v.phase} matches status ${st}${st === 3 ? ` (cleared at ${v.clear?.block}, executed at ${p.execute?.block})` : ''}${st === 2 ? ` (${v.veto?.rule} by ${v.veto?.by})` : ''}`) |
| 57 | } |
| 58 | ok([...finals.values()].includes('ejected') && [...finals.values()].includes('executed'), 'the recording holds at least one ejected and one executed upgrade') |
| 59 | |
| 60 | // play the whole loop on a fake clock; render each phase the first time it shows up |
| 61 | const seen = new Map<string, Set<AirlockPhase>>() |
| 62 | const rendered = new Map<AirlockPhase, { zh: string; tw?: string; en?: string; id: string; strip: string; final: AirlockPhase }>() |
| 63 | const t0 = NOW |
| 64 | const limit = t0 + ids.length * 120_000 |
| 65 | const played = new Set<string>() |
| 66 | for (NOW = t0; NOW < limit; NOW += 100) { |
| 67 | tickReplay(NOW) |
| 68 | const f = replayFrame(NOW) |
| 69 | if (!f) continue |
| 70 | played.add(f.tl.id) |
| 71 | const ph = airlockView(f.p, f.vblock, (n) => blockTs.get(n)).phase |
| 72 | if (!seen.has(f.tl.id)) seen.set(f.tl.id, new Set()) |
| 73 | seen.get(f.tl.id)!.add(ph) |
| 74 | if (!rendered.has(ph)) { |
| 75 | ui.details = false |
| 76 | setLang('zh-CN', false) |
| 77 | render() |
| 78 | const shot: { zh: string; tw?: string; en?: string; id: string; strip: string; final: AirlockPhase } = { zh: dom.html(), id: f.tl.id, strip: dom.els.get('strip')?.innerHTML ?? '', final: finals.get(f.tl.id)! } |
| 79 | if (ph === 'ejected') { |
| 80 | setLang('zh-TW', false) |
| 81 | render() |
| 82 | shot.tw = dom.html() |
| 83 | setLang('en', false) |
| 84 | render() |
| 85 | shot.en = dom.html() |
| 86 | setLang('zh-CN', false) |
| 87 | } |
| 88 | rendered.set(ph, shot) |
| 89 | } |
| 90 | if (played.size === ids.length && f.tl.id === [...played][0] && NOW - t0 > 5000) break |
| 91 | } |
| 92 | const playable = ids.filter((id) => ['ejected', 'executed', 'cleared', 'ready'].includes(finals.get(id)!)) |
| 93 | ok(playable.every((id) => played.has(id)), `the loop plays every finished proposal (${[...played].join(', ')})`) |
| 94 | for (const [id, phases] of seen) console.log(` #${id}: ${[...phases].join(' → ')}`) |
| 95 | const all = new Set([...seen.values()].flatMap((s) => [...s])) |
| 96 | ok(['chamber', 'ejected', 'executed'].every((p) => all.has(p as AirlockPhase)) && (all.has('cleared') || all.has('ready')), `playback walks through chamber, cleared/ready, executed and ejected (${[...all].join(', ')})`) |
| 97 | const badge = R.source === 'sepolia' ? '回放 · 录制自 Ethereum Sepolia' : '回放 · 录制自 Sepolia 分叉' |
| 98 | for (const [ph, shot] of rendered) { |
| 99 | if (ph === 'idle') continue |
| 100 | ok(shot.zh.includes(`class="lk ph-${ph}`) && plain(shot.zh).includes(badge) && plain(shot.zh).includes('回放 · 冷静期快进'), `zh-CN playback frame: ${ph}, with the replay badge and the fast-forward tag`) |
| 101 | } |
| 102 | // the card being replayed shows "Replaying" in its status chip until the replay reaches its final state |
| 103 | { |
| 104 | const mid = rendered.get('chamber')! |
| 105 | const card = mid.strip.split('<button').find((b) => b.includes(`data-act="sel:${mid.id}"`)) ?? '' |
| 106 | ok(mid.final !== 'chamber' && card.includes('pc-chip rp') && plain(card).includes('回放中') && !plain(card).includes(mid.final === 'executed' ? '已执行 ' : '已否决'), `strip during the replay of #${mid.id}: the chip says replaying, not its final state (${mid.final})`) |
| 107 | } |
| 108 | const c = rendered.get('cleared') ?? rendered.get('ready') |
| 109 | if (c) ok(plain(c.zh).includes('Chainlink CRE 已放行') && plain(c.zh).includes('证据哈希') && plain(c.zh).includes('报告交易') && c.zh.includes('pipe st-done k-clear'), 'cleared frame: "cleared by Chainlink CRE" with the evidence hash and the report tx') |
| 110 | const x = rendered.get('executed') |
| 111 | if (x) ok(plain(x.zh).includes('升级已生效') && plain(x.zh).includes('已换上 DemoBTC v2'), 'executed frame: the upgrade is live, the proxy runs v2') |
| 112 | const w = rendered.get('waiting') |
| 113 | if (w) ok(plain(w.zh).includes('冷静期已满') && plain(w.zh).includes('锁住 · 没有放行'), 'waiting frame: cooldown over without a clearance, the inner door stays locked') |
| 114 | const e = rendered.get('ejected')! |
| 115 | ok(plain(e.zh).includes('已弹出') && e.zh.includes('class="cn-stamp"') && e.zh.includes('pipe st-done k-veto'), 'zh-CN ejected frame: stamp and the CRE pipeline done as a veto') |
| 116 | ok(plain(e.tw!).includes(R.source === 'sepolia' ? '回放 · 錄製自 Ethereum Sepolia' : '回放 · 錄製自 Sepolia 分叉') && plain(e.tw!).includes('已彈出'), 'zh-TW ejected frame') |
| 117 | const en = plain(e.en!) |
| 118 | ok(en.includes(R.source === 'sepolia' ? 'Replay · recorded on Ethereum Sepolia' : 'Replay · recorded on a Sepolia fork') && en.includes('Ejected') && en.includes('Replay · cooldown fast-forwarded') && !CJK.test(en), 'en ejected frame, no Chinese characters') |
| 119 | // the drawer says when and from which blocks the run was recorded |
| 120 | setLang('en', false) |
| 121 | ui.details = true |
| 122 | render() |
| 123 | const dr = plain(dom.els.get('details')?.innerHTML ?? '') |
| 124 | ok(dr.includes(`recorded ${R.recordedOn}`) && dr.includes(`#${R.head.toLocaleString('en-US')}`) && dr.includes('no network at all'), 'details drawer: recording source, block range and date; no network') |
| 125 | ui.details = false |
| 126 | // every recorded proposal has a card; a guardian change is named for what it does |
| 127 | render() |
| 128 | const strip = dom.els.get('strip')?.innerHTML ?? '' |
| 129 | ok(ids.every((id) => strip.includes(`data-act="sel:${id}"`)), `strip: a card for each of the ${ids.length} recorded proposals`) |
| 130 | const guard = ids.find((id) => R.proposals[id].kind === 1 && R.proposals[id].target.toLowerCase() === R.deployment.receiver.toLowerCase()) |
| 131 | if (guard) ok(plain(strip).includes('Remove CRE'), `#${guard}: "remove the CRE receiver" is named as such`) |
| 132 | setLang('zh-CN', false) |
| 133 | const every = [...rendered.values()].map((x) => x.zh + (x.tw ?? '') + (x.en ?? '')).join('') |
| 134 | ok(findBlocked(every).length === 0, 'rendered offline page is clean of blocked names and local paths') |
| 135 | ok(!every.includes('href="http'), 'offline page has no outbound links') |
| 136 | } |
| 137 | |
| 138 | ok(attempts.length === 0, `no network attempt at all (${attempts.length}${attempts.length ? ': ' + attempts.slice(0, 3).join(', ') : ''})`) |
| 139 | console.log(` (playback speed ${CONFIG.replay.msPerBlock} ms/block)`) |
| 140 | console.log(fails ? `\n${fails} FAILED` : '\nall passed') |
| 141 | process.exit(fails ? 1 : 0) |