Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / web / test / offline-selftest.ts
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
3import { attempts } from './offline-stub'
4import { installDom } from './domstub'
5import { readFileSync } from 'node:fs'
6import { join } from 'node:path'
7import { airlockView, startTitle, type AirlockPhase } from '../src/airlock'
8import { PENDING, RECORDING } from '../src/chaindata'
9import { CONFIG } from '../src/config'
10import { blockTs } from '../src/events'
11import { setLang } from '../src/i18n'
12import { startOffline } from '../src/offline'
13import { replayFrame, tickReplay } from '../src/playback'
14import { CHAIN, MODE, OFFLINE } from '../src/runtime'
15import { store } from '../src/store'
16import { render, ui } from '../src/ui'
17import { findBlocked, NAMES_NOTE } from './scan'
18
19let fails = 0
20function ok(cond: unknown, msg: string) {
21 console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`)
22 if (!cond) fails++
23}
24const CJK = /[㐀-鿿 -〿＀-￯]/
25const plain = (h: string) => h.replace(/<button data-lang[^>]*>[\s\S]*?<\/button>/g, '').replace(/<[^>]+>/g, ' ').replace(/&#39;/g, "'").replace(/\s+/g, ' ')
26
27ok(MODE === 'offline' && OFFLINE, '?mode=offline selects offline playback')
28ok(CHAIN.http.length === 0 && CHAIN.ws.length === 0 && CHAIN.explorerTx === null && CHAIN.sourcifyApi === null, 'offline: no RPC endpoint, no explorer, no Sourcify')
29const raw = readFileSync(join(process.cwd(), 'src', 'recorded-run.json'), 'utf8')
30ok(findBlocked(raw).length === 0, `recording has no blocked names or local paths (${NAMES_NOTE})${findBlocked(raw).length ? ': ' + findBlocked(raw).join(', ') : ''}`)
31ok(RECORDING.v === 1 && RECORDING.deployment.chainId === 11155111, `recording format v1 on Sepolia (source: ${RECORDING.source})`)
32
33const dom = installDom()
34let NOW = Date.now()
35Date.now = () => NOW
36startTitle()
37
38if (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
138ok(attempts.length === 0, `no network attempt at all (${attempts.length}${attempts.length ? ': ' + attempts.slice(0, 3).join(', ') : ''})`)
139console.log(` (playback speed ${CONFIG.replay.msPerBlock} ms/block)`)
140console.log(fails ? `\n${fails} FAILED` : '\nall passed')
141process.exit(fails ? 1 : 0)