-
Notifications
You must be signed in to change notification settings - Fork 57
Expand file tree
/
Copy pathtests.html
More file actions
311 lines (291 loc) · 19.7 KB
/
Copy pathtests.html
File metadata and controls
311 lines (291 loc) · 19.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Easy2FA — 核心逻辑自测</title>
<style>
:root{ color-scheme: dark; }
body{ margin:0; padding:32px; background:#06090c; color:#eaf2f5; font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,Monaco,monospace; }
h1{ font-size:18px; letter-spacing:.04em; }
.note{ color:#9fb3bf; font-size:12px; margin:-6px 0 18px; }
#summary{ font-size:16px; font-weight:700; padding:10px 14px; border-radius:10px; margin:14px 0; display:inline-block; }
.ok{ background:rgba(52,211,153,.14); color:#34d399; border:1px solid rgba(52,211,153,.4); }
.fail{ background:rgba(251,113,133,.14); color:#fb7185; border:1px solid rgba(251,113,133,.4); }
.row{ padding:6px 10px; border-bottom:1px solid rgba(255,255,255,.06); display:flex; gap:10px; }
.row .s{ flex:none; width:48px; font-weight:700; }
.pass .s{ color:#34d399; } .err .s{ color:#fb7185; }
.err{ background:rgba(251,113,133,.06); }
.d{ color:#9fb3bf; }
h2{ font-size:13px; letter-spacing:.08em; color:#22d3ee; margin:22px 0 4px; text-transform:uppercase; }
</style>
</head>
<body>
<h1>Easy2FA — 核心逻辑自测</h1>
<p class="note">本页<b>直接从 index.html 提取真实实现</b>来执行——无镜像副本、零漂移:改了 index.html 刷新本页即测最新代码。<br>
需经 <b>localhost</b> 或 <b>HTTPS</b> 打开(Web Crypto 与 fetch 需要安全上下文;<code>file://</code> 不行)。</p>
<div id="summary">运行中…</div>
<div id="out"></div>
<script>
// ============================================================================
// 从 index.html 抠出 Component 的纯逻辑方法,拼成对象字面量执行。
// 方法体内的 this.xxx 互调用在对象上天然成立;state/ACCENTS 由这里 mock。
// 任何方法找不到 / 花括号不配对都会让整页 FAIL——比"镜像忘了同步"沉默腐烂强。
// ============================================================================
const METHODS = [
'base32Decode', 'base32Encode', 'totp', 'utf8', 'b64ToBytes',
'pbReaders', 'decodeMigration', 'decodeOtpParam', 'parseMigration',
'clampDigits', 'clampPeriod', 'normAlgo',
'parseInput', 'parseAccountUrl', 'parseAccountsFromText',
'parseStructured', 'accountFromRecord', 'csvSplit',
'hashStr', 'pickColor', 'normalizeAccount',
'encodeBoardParam', 'decodeBoardParam', 'buildAccountUrl', 'fmtCode',
];
function extractMethod(html, name) {
// class 方法两空格缩进起行;调用点(this.xxx( )不会被 \n␣␣ 前缀匹配到
const re = new RegExp('\\n (?:async )?' + name + '\\s*\\(');
const m = re.exec(html);
if (!m) throw new Error('index.html 中未找到方法 ' + name + '()');
const open = html.indexOf('{', m.index + m[0].length - 1);
let depth = 0, j = open;
for (; j < html.length; j++) {
if (html[j] === '{') depth++;
else if (html[j] === '}') { depth--; if (depth === 0) break; }
}
if (depth !== 0) throw new Error('方法 ' + name + '() 花括号不配对(提取失败)');
return html.slice(m.index + 1, j + 1);
}
async function loadApp() {
// cache:'no-store':sw.js 对这类请求直接放行,保证抓到的是磁盘上最新的 index.html
const res = await fetch('./index.html', { cache: 'no-store' });
if (!res.ok) throw new Error('无法读取 index.html:HTTP ' + res.status);
const html = await res.text();
const src = METHODS.map((n) => extractMethod(html, n));
const app = new Function('return {\n' + src.join(',\n') + '\n};')();
const am = /ACCENTS = (\[[^\]]*\])/.exec(html);
app.ACCENTS = am ? JSON.parse(am[1].replace(/'/g, '"')) : ['#22d3ee'];
app.state = { accounts: [] };
return app;
}
// ============================================================================
// 测试工具
// ============================================================================
const results = [];
let section = '';
function group(name){ section = name; results.push({group:name}); }
function check(name, cond, detail){ results.push({name, ok:!!cond, detail:detail||''}); }
async function eq(name, got, want){ const g = await got; check(name, g === want, 'got ' + JSON.stringify(g) + ' / want ' + JSON.stringify(want)); }
function ascii(s){ return new Uint8Array([...s].map(c => c.charCodeAt(0))); }
function bytesEq(a, b){ if(!a || !b || a.length !== b.length) return false; for(let i = 0; i < a.length; i++) if(a[i] !== b[i]) return false; return true; }
function varintEnc(n){ const b = []; while(n > 0x7f){ b.push((n & 0x7f) | 0x80); n = Math.floor(n / 128); } b.push(n); return b; }
function pbField(tag, bytes){ return [tag, ...varintEnc(bytes.length), ...bytes]; }
function b64url(arr){ let s = ''; for(const x of arr) s += String.fromCharCode(x); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
async function run(){
const app = await loadApp();
group('提取器');
check('从 index.html 提取 ' + METHODS.length + ' 个真实方法', true);
// ---- RFC 6238 Appendix B 向量(8 位、period 30)----
group('TOTP · RFC 6238');
const SHA1 = app.base32Encode(ascii('12345678901234567890'));
const SHA256 = app.base32Encode(ascii('12345678901234567890123456789012'));
const SHA512 = app.base32Encode(ascii('1234567890123456789012345678901234567890123456789012345678901234'));
const vectors = [
[59, '94287082','46119246','90693936'],
[1111111109, '07081804','68084774','25091201'],
[1111111111, '14050471','67062674','99943326'],
[1234567890, '89005924','91819424','93441116'],
[2000000000, '69279037','90698825','38618901'],
];
for(const [T, s1, s256, s512] of vectors){
await eq('RFC6238 T=' + T + ' SHA-1', app.totp(SHA1, T, 8, 30, 'SHA-1'), s1);
await eq('RFC6238 T=' + T + ' SHA-256', app.totp(SHA256, T, 8, 30, 'SHA-256'), s256);
await eq('RFC6238 T=' + T + ' SHA-512', app.totp(SHA512, T, 8, 30, 'SHA-512'), s512);
}
// ---- base32 ----
group('base32');
check('base32Decode("JBSWY3DPEHPK3PXP") = "Hello!"+deadbeef',
bytesEq(app.base32Decode('JBSWY3DPEHPK3PXP'), new Uint8Array([0x48,0x65,0x6c,0x6c,0x6f,0x21,0xde,0xad,0xbe,0xef])));
check('base32 大小写无关', bytesEq(app.base32Decode('jbswy3dpehpk3pxp'), app.base32Decode('JBSWY3DPEHPK3PXP')));
check('base32Decode 非法字符 → null', app.base32Decode('JBSW!!!0189') === null);
for(const arr of [[1],[0,255],[1,2,3,4,5],[72,101,108,108,111,33,222,173,190,239]]){
const b = new Uint8Array(arr);
check('base32 往返 [' + arr.join(',') + ']', bytesEq(app.base32Decode(app.base32Encode(b)), b));
}
// ---- 参数 clamp / 算法归一 ----
group('clamp / normAlgo');
check('clampDigits("7")=7', app.clampDigits('7') === 7);
check('clampDigits("9")→6(越界回默认)', app.clampDigits('9') === 6);
check('clampDigits(undefined)→6', app.clampDigits(undefined) === 6);
check('clampPeriod("60")=60', app.clampPeriod('60') === 60);
check('clampPeriod("5")→30(下限 10)', app.clampPeriod('5') === 30);
check('clampPeriod("301")→30(上限 300)', app.clampPeriod('301') === 30);
check('normAlgo("SHA256")=SHA-256', app.normAlgo('SHA256') === 'SHA-256');
check('normAlgo("sha-512")=SHA-512', app.normAlgo('sha-512') === 'SHA-512');
check('normAlgo("SHA1")=SHA-1', app.normAlgo('SHA1') === 'SHA-1');
check('normAlgo(null)=SHA-1', app.normAlgo(null) === 'SHA-1');
check('normAlgo("MD5")→SHA-1(未知回默认)', app.normAlgo('MD5') === 'SHA-1');
// ---- parseInput(setup 输入解析)----
group('parseInput');
const pi = app.parseInput('otpauth://totp/Acme:bob?secret=JBSWY3DPEHPK3PXP&issuer=Acme&digits=8&period=60&algorithm=SHA256');
check('otpauth 完整解析 valid', pi && pi.valid);
check('otpauth issuer=Acme', pi && pi.issuer === 'Acme');
check('otpauth label=bob(issuer 前缀剥离)', pi && pi.label === 'bob');
check('otpauth digits=8', pi && pi.digits === 8);
check('otpauth period=60', pi && pi.period === 60);
check('otpauth algorithm=SHA-256', pi && pi.algorithm === 'SHA-256');
const ph = app.parseInput('otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP');
check('HOTP 链接被拒(valid=false, reason=hotp)', ph && !ph.valid && ph.reason === 'hotp');
check('裸密钥带空格小写 → valid', (app.parseInput('jbsw y3dp ehpk 3pxp') || {}).valid === true);
check('非法字符裸密钥 → invalid', (app.parseInput('0189') || {}).valid === false);
const pl = app.parseInput('otpauth://totp/prod-db?secret=JBSWY3DPEHPK3PXP');
check('无 issuer 前缀时 label=整个路径', pl && pl.valid && pl.label === 'prod-db' && pl.issuer === '');
// ---- parseAccountUrl(书签链接)----
group('parseAccountUrl');
const pa = app.parseAccountUrl('https://x.example/2fa/#secret=JBSWY3DPEHPK3PXP&label=GitHub&digits=8&period=60&algorithm=256');
check('书签链接解析 valid', pa && pa.valid);
check('书签 label=GitHub', pa && pa.label === 'GitHub');
check('书签 digits=8 period=60 algo=SHA-256', pa && pa.digits === 8 && pa.period === 60 && pa.algorithm === 'SHA-256');
check('无 secret 的 URL → null', app.parseAccountUrl('https://x.example/#label=nope') === null);
// ---- parseAccountsFromText(混合文本抠链接 + 去重)----
group('parseAccountsFromText');
const mixed = [
'# 我的测试号(注释行应被忽略)',
'otpauth://totp/Acme:bob?secret=JBSWY3DPEHPK3PXP&issuer=Acme',
'一些噪声行 hello world',
'https://tool.example/2fa/#secret=GEZDGNBVGY3TQOJQ&label=Stripe',
'secret=MFRGGZDFMZTWQ2LK&label=BareLine',
'otpauth://totp/Acme:bob?secret=JBSWY3DPEHPK3PXP&issuer=Acme',
].join('\n');
const accs = app.parseAccountsFromText(mixed);
check('混合文本抠出 3 个(重复 otpauth 去重)', accs.length === 3, 'got ' + accs.length);
check('第 1 条:otpauth bob@Acme', accs[0] && accs[0].label === 'bob' && accs[0].issuer === 'Acme');
check('第 2 条:书签 Stripe', accs[1] && accs[1].label === 'Stripe' && accs[1].secret === 'GEZDGNBVGY3TQOJQ');
check('第 3 条:裸 secret= 行', accs[2] && accs[2].label === 'BareLine' && accs[2].secret === 'MFRGGZDFMZTWQ2LK');
check('空文本 → []', app.parseAccountsFromText('').length === 0);
check('纯噪声 → []', app.parseAccountsFromText('nothing to see\nhere').length === 0);
// ---- JSON / CSV 结构化导入(issue #5:picker 的 .json/.csv 现在真能解析)----
group('parseStructured · JSON/CSV');
// JSON:数组 of 对象
const jArr = app.parseAccountsFromText('[{"secret":"JBSWY3DPEHPK3PXP","label":"acme","issuer":"Acme"}]');
check('JSON 数组解出 1 个', jArr.length === 1, 'got ' + jArr.length);
check('JSON label=acme / issuer=Acme', jArr[0] && jArr[0].label === 'acme' && jArr[0].issuer === 'Acme');
check('JSON secret 归一大写', jArr[0] && jArr[0].secret === 'JBSWY3DPEHPK3PXP');
// JSON:单对象 + 字段别名(name→label, service→issuer, timer→period, algo)
const jObj = app.parseAccountsFromText('{"secret":"gezdgnbvgy3tqojq","name":"bob","service":"GitHub","digits":8,"timer":60,"algo":"SHA256"}');
check('JSON 单对象 + 别名解出 1 个', jObj.length === 1, 'got ' + jObj.length);
check('JSON 别名映射(label/issuer/digits/period/algo)',
jObj[0] && jObj[0].label === 'bob' && jObj[0].issuer === 'GitHub' && jObj[0].digits === 8 && jObj[0].period === 60 && jObj[0].algorithm === 'SHA-256');
// JSON:{accounts:[...]} 包一层
const jWrap = app.parseAccountsFromText('{"accounts":[{"secret":"JBSWY3DPEHPK3PXP"},{"secret":"GEZDGNBVGY3TQOJQ"}]}');
check('JSON 包一层 {accounts:[...]} 解出 2 个', jWrap.length === 2, 'got ' + jWrap.length);
// JSON:坏 secret 的记录被丢弃
const jBad = app.parseAccountsFromText('[{"secret":"JBSWY3DPEHPK3PXP"},{"secret":"not-base32!!!"},{"label":"缺secret"}]');
check('JSON 非法/缺 secret 记录被丢弃(只留 1)', jBad.length === 1, 'got ' + jBad.length);
// JSON:数组里混 otpauth 字符串——由上层 URL 正则捡走,仍能出账号
const jStr = app.parseAccountsFromText('["otpauth://totp/Acme:bob?secret=JBSWY3DPEHPK3PXP&issuer=Acme"]');
check('JSON 数组里的 otpauth 字符串仍被解析', jStr.length === 1 && jStr[0].secret === 'JBSWY3DPEHPK3PXP');
// CSV:带表头
const csv = app.parseAccountsFromText('secret,label,issuer\nJBSWY3DPEHPK3PXP,acme,Acme\nGEZDGNBVGY3TQOJQ,bob,GitHub');
check('CSV 表头 + 2 行解出 2 个', csv.length === 2, 'got ' + csv.length);
check('CSV 首行字段(secret/label/issuer)', csv[0] && csv[0].secret === 'JBSWY3DPEHPK3PXP' && csv[0].label === 'acme' && csv[0].issuer === 'Acme');
// CSV:列序打乱 + 引号内含逗号
const csv2 = app.parseAccountsFromText('issuer,secret,label\n"Acme, Inc.",JBSWY3DPEHPK3PXP,work');
check('CSV 乱序列 + 引号内逗号', csv2.length === 1 && csv2[0].issuer === 'Acme, Inc.' && csv2[0].label === 'work');
// CSV:分号分隔(欧洲 Excel)
const csvSemi = app.parseAccountsFromText('secret;label\nJBSWY3DPEHPK3PXP;acme');
check('CSV 分号分隔', csvSemi.length === 1 && csvSemi[0].label === 'acme');
// 无 secret 列头的逗号文本 → 不当 CSV(交回 URL 抠取,此处应为 0)
check('无 secret 列头的普通逗号行 → 不误判为 CSV', app.parseAccountsFromText('hello, world, foo\nbar, baz, qux').length === 0);
// 坏 JSON(YAML 冒号行)不抛错、不误伤:无 URL/secret= 故为 []
check('坏 JSON 不抛错 → []', app.parseAccountsFromText('- secret: JBSWY3DPEHPK3PXP\n label: x').length === 0);
check('csvSplit 引号转义 ""', JSON.stringify(app.csvSplit('a,"b""c",d', ',')) === JSON.stringify(['a','b"c','d']));
// ---- Google Authenticator 迁移码(protobuf)----
group('迁移码');
const secretBytes = [...app.base32Decode('JBSWY3DPEHPK3PXP')];
const otp = [...pbField(0x0A, secretBytes), ...pbField(0x12, [...ascii('bob')]), ...pbField(0x1A, [...ascii('Acme')]), 0x20,1, 0x28,1, 0x30,2];
const payload = new Uint8Array(pbField(0x0A, otp));
const accts = app.decodeMigration(payload);
check('迁移码解出 1 个账号', accts.length === 1, 'got ' + accts.length);
if(accts[0]){
check('迁移码 secret 往返 = JBSWY3DPEHPK3PXP', accts[0].secret === 'JBSWY3DPEHPK3PXP', 'got ' + accts[0].secret);
check('迁移码 name=bob', accts[0].label === 'bob');
check('迁移码 issuer=Acme', accts[0].issuer === 'Acme');
check('迁移码 digits=6', accts[0].digits === 6);
check('迁移码 algorithm=SHA-1', accts[0].algorithm === 'SHA-1');
}
const hotp = new Uint8Array(pbField(0x0A, [...pbField(0x0A, secretBytes), 0x30,1]));
check('迁移码跳过 HOTP(type=1)', app.decodeMigration(hotp).length === 0);
// 全链路:otpauth-migration:// URL → 账号
const mig = app.parseMigration('otpauth-migration://offline?data=' + b64url(payload));
check('parseMigration 全链路解出 1 个', mig.length === 1 && mig[0].secret === 'JBSWY3DPEHPK3PXP');
check('parseMigration 坏 data → []', app.parseMigration('otpauth-migration://offline?data=%%%').length === 0);
// ---- 损坏/截断的迁移码:干净失败,不产出垃圾账号(issue #4)----
const truncated = payload.subarray(0, payload.length - 6); // 掐掉尾部:内层长度前缀声明的字节不够
check('截断的迁移码 → [](不产出垃圾账号)', app.decodeMigration(truncated).length === 0, 'got ' + app.decodeMigration(truncated).length);
const lyingLen = new Uint8Array([0x0A, 0x7F, ...otp]); // 外层长度前缀谎报 127 字节
check('长度前缀越界 → []', app.decodeMigration(lyingLen).length === 0);
const badVarint = new Uint8Array([0x0A, 0x03, 0x0A, 0xFF]); // varint 最高位悬空直到 EOF
check('未终结的 varint → []', app.decodeMigration(badVarint).length === 0);
// 同包一好一坏:坏的丢弃,好的保留
const goodPlusBad = new Uint8Array([...pbField(0x0A, otp), ...pbField(0x0A, [0x0A, 0x50])]);
const gpb = app.decodeMigration(goodPlusBad);
check('一好一坏 → 只留好的 1 个', gpb.length === 1 && gpb[0].secret === 'JBSWY3DPEHPK3PXP', 'got ' + gpb.length);
check('parseMigration 截断全链路 → []', app.parseMigration('otpauth-migration://offline?data=' + b64url(truncated)).length === 0);
// ---- normalizeAccount ----
group('normalizeAccount');
const na = app.normalizeAccount({ secret: 'jbswy3dp ehpk3pxp', label: 'x', digits: '99', period: '5', algorithm: 'sha256' });
check('secret 去空格并归一大写', na.secret === 'JBSWY3DPEHPK3PXP');
check('digits/period 越界回默认', na.digits === 6 && na.period === 30);
check('algorithm 归一 SHA-256', na.algorithm === 'SHA-256');
const n1 = app.normalizeAccount({ secret: 'jbswy3dpehpk3pxp' });
const n2 = app.normalizeAccount({ secret: 'JBSWY3DPEHPK3PXP' });
check('同密钥不同大小写归一后一致(去重键)', n1.secret === n2.secret);
// ---- 看板链接编码往返 ----
group('#board= 编码');
app.state = { accounts: [
app.normalizeAccount({ secret: 'JBSWY3DPEHPK3PXP', label: 'A', issuer: 'Acme', digits: 8, period: 60, algorithm: 'SHA-256' }),
app.normalizeAccount({ secret: 'GEZDGNBVGY3TQOJQ', label: 'B' }),
] };
const bp = app.encodeBoardParam();
check('encodeBoardParam 非空且 URL-safe', !!bp && !/[+/=]/.test(bp));
const back = app.decodeBoardParam(bp);
check('board 往返数量 = 2', back.length === 2, 'got ' + back.length);
check('board 往返字段完整(A/8位/60s/SHA-256)',
back[0] && back[0].secret === 'JBSWY3DPEHPK3PXP' && back[0].label === 'A' && back[0].issuer === 'Acme'
&& back[0].digits === 8 && back[0].period === 60 && back[0].algorithm === 'SHA-256');
check('decodeBoardParam 坏输入 → []', app.decodeBoardParam('!!!not-base64').length === 0);
check('decodeBoardParam 非数组 JSON → []', app.decodeBoardParam(b64url([...ascii('{"a":1}')])).length === 0);
// ---- 书签链接生成 ----
group('buildAccountUrl');
const u1 = new URLSearchParams(app.buildAccountUrl({ secret: 'JBSWY3DPEHPK3PXP', label: 'GitHub', issuer: 'Acme', digits: 8, period: 60, algorithm: 'SHA-256' }).split('#')[1]);
check('非默认参数写入 hash', u1.get('secret') === 'JBSWY3DPEHPK3PXP' && u1.get('digits') === '8' && u1.get('period') === '60' && u1.get('algorithm') === '256');
const u2 = new URLSearchParams(app.buildAccountUrl({ secret: 'JBSWY3DPEHPK3PXP', label: '', issuer: '', digits: 6, period: 30, algorithm: 'SHA-1' }).split('#')[1]);
check('默认参数省略(hash 只有 secret)', u2.get('secret') === 'JBSWY3DPEHPK3PXP' && !u2.has('digits') && !u2.has('period') && !u2.has('algorithm') && !u2.has('label'));
// ---- 验证码分组显示 ----
group('fmtCode');
check('6 位 → "123 456"', app.fmtCode('123456') === '123 456');
check('7 位 → "1234 567"', app.fmtCode('1234567') === '1234 567');
check('8 位 → "1234 5678"', app.fmtCode('12345678') === '1234 5678');
check('空 → ""', app.fmtCode('') === '');
render();
}
function render(){
const out = document.getElementById('out');
const tests = results.filter(r => !r.group);
const passed = tests.filter(r => r.ok).length, total = tests.length;
const sum = document.getElementById('summary');
sum.textContent = (passed === total ? '✓ 全部通过 ' : '✗ 有失败 ') + passed + ' / ' + total;
sum.className = passed === total ? 'ok' : 'fail';
out.innerHTML = results.map(r =>
r.group ? '<h2>' + r.group + '</h2>'
: '<div class="row ' + (r.ok ? 'pass' : 'err') + '"><span class="s">' + (r.ok ? 'PASS' : 'FAIL') + '</span><span>' + r.name + (r.ok ? '' : ' <span class="d">— ' + r.detail + '</span>') + '</span></div>'
).join('');
}
run().catch(e => {
render(); // 先渲染已完成的行,再覆盖 summary 为错误详情
document.getElementById('summary').textContent = '✗ 运行出错:' + e.message + (/subtle|fetch/i.test(e.message || '') ? '(请用 localhost/HTTPS 打开)' : '');
document.getElementById('summary').className = 'fail';
});
</script>
</body>
</html>