@yfwu2020/dsh-selection-explain 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,408 @@
1
+ /**
2
+ * 侧边栏网页划词桥的纯逻辑测试(无浏览器、无宿主)。
3
+ *
4
+ * 覆盖两块最容易写坏、又最难人工发现的东西:
5
+ * ① 注入本身:桥脚本插在哪、CSP 只放宽哪一条、沙箱补了哪个 token;
6
+ * ② 帧内脚本行为:给它一个"帧里的 DOM + 一段选区",它报给父页面的消息
7
+ * 对不对(文字 / 带【】的上下文窗口 / keyContext / 帧内坐标)。
8
+ *
9
+ * 做法:从 lib/client.js 里把 bridgeBody / bridgeIntoHtml / insertIntoHtml
10
+ * 三个**纯函数**抠出来单独跑 —— 不加载整个 client bundle,也就不需要 DOM 桩。
11
+ *
12
+ * 用法:node scripts/test-bridge.mjs [lib/client.js 路径]
13
+ * 退出码:全部 PASS = 0。
14
+ */
15
+ import { readFileSync } from 'node:fs'
16
+ import { dirname, resolve } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url))
20
+ const BUNDLE = process.argv[2] || resolve(HERE, '..', 'lib', 'client.js')
21
+ const source = readFileSync(BUNDLE, 'utf8')
22
+
23
+ let failed = 0
24
+ const assert = (label, ok, extra) => {
25
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${extra ? ' — ' + extra : ''}`)
26
+ if (!ok) failed += 1
27
+ }
28
+
29
+ /** 从 bundle 里抠一个具名函数(按花括号配平找结尾,字符串/注释里的花括号不参与计数)。 */
30
+ function sliceFunction(text, name) {
31
+ const start = text.indexOf('function ' + name + '(')
32
+ if (start < 0) throw new Error('bundle 里找不到函数:' + name)
33
+ let depth = 0
34
+ let index = text.indexOf('{', start)
35
+ for (let i = index; i < text.length; i += 1) {
36
+ const ch = text[i]
37
+ if (ch === '{') depth += 1
38
+ else if (ch === '}') {
39
+ depth -= 1
40
+ if (depth === 0) return text.slice(start, i + 1)
41
+ } else if (ch === "'" || ch === '"' || ch === '`') {
42
+ // 跳过字符串字面量(里面的花括号不能算进配平)
43
+ for (i += 1; i < text.length; i += 1) {
44
+ if (text[i] === '\\') i += 1
45
+ else if (text[i] === ch) break
46
+ }
47
+ } else if (ch === '/' && text[i + 1] === '/') {
48
+ while (i < text.length && text[i] !== '\n') i += 1
49
+ } else if (ch === '/' && text[i + 1] === '*') {
50
+ i = text.indexOf('*/', i) + 1
51
+ }
52
+ }
53
+ throw new Error('函数没配平:' + name)
54
+ }
55
+
56
+ // ───────────────────────── 抠出纯函数 ─────────────────────────
57
+ const MARK = 'data-dsh-sel-bridge'
58
+ const api = new Function(
59
+ 'BRIDGE_MARK',
60
+ [
61
+ sliceFunction(source, 'bridgeBody'),
62
+ sliceFunction(source, 'bridgeIntoHtml'),
63
+ sliceFunction(source, 'insertIntoHtml'),
64
+ 'return { bridgeBody: bridgeBody, bridgeIntoHtml: bridgeIntoHtml, insertIntoHtml: insertIntoHtml }',
65
+ ].join('\n'),
66
+ )(MARK)
67
+
68
+ assert('从 bundle 抠出 bridgeBody / bridgeIntoHtml / insertIntoHtml', typeof api.bridgeIntoHtml === 'function')
69
+
70
+ // ───────────────────────── ① 注入 ─────────────────────────
71
+
72
+ /** 宿主基础预览(ui-sidebar-documentpreview 的 BasicHtmlFrame)长这样:
73
+ * DOMPurify 清洗 + 一条 script-src 'none' 的 CSP,装进 srcdoc + sandbox=""。 */
74
+ const BASIC =
75
+ '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ' +
76
+ "script-src 'none'; style-src 'unsafe-inline'; img-src data:; connect-src 'none'; frame-src 'none'; " +
77
+ 'form-action \'none\'; base-uri \'none\'"><style>body{color:red}</style></head>' +
78
+ '<body><p>the migration ran long, so we ship Wednesday</p></body></html>'
79
+
80
+ const basicOut = api.bridgeIntoHtml(BASIC)
81
+ assert('基础预览:注入成功', typeof basicOut === 'string' && basicOut.indexOf(MARK) > 0)
82
+ assert('基础预览:只放宽 script-src', /script-src 'unsafe-inline'/.test(basicOut) && !/script-src 'none'/.test(basicOut))
83
+ assert(
84
+ '基础预览:其余 CSP 一条不动(不许联网/外链)',
85
+ /default-src 'none'/.test(basicOut) && /connect-src 'none'/.test(basicOut) && /img-src data:/.test(basicOut) && /frame-src 'none'/.test(basicOut),
86
+ )
87
+ assert('基础预览:原文一字不少', basicOut.indexOf('the migration ran long, so we ship Wednesday') > 0)
88
+ assert('基础预览:只注入一段脚本', basicOut.split(MARK).length - 1 === 1, String(basicOut.split(MARK).length - 1))
89
+ assert('基础预览:脚本落在 <head> 内(head 之后)', basicOut.indexOf('<head>') < basicOut.indexOf(MARK) && basicOut.indexOf(MARK) < basicOut.indexOf('</head>'))
90
+ assert('基础预览:重复注入返回 null(幂等)', api.bridgeIntoHtml(basicOut) === null)
91
+
92
+ // 交互式预览:外层是宿主的 bootstrap(charset → script),桥必须插在 charset 之后、bootstrap 之前
93
+ const BOOTSTRAP = '<!doctype html><meta charset="utf-8"><script>(()=>{document.open();document.write("<p>x</p>");document.close()})()</scr' + 'ipt>'
94
+ const bootstrapOut = api.bridgeIntoHtml(BOOTSTRAP)
95
+ assert(
96
+ '交互式 bootstrap:插在 charset 之后、bootstrap 之前',
97
+ bootstrapOut.indexOf('<meta charset="utf-8">') < bootstrapOut.indexOf(MARK) && bootstrapOut.indexOf(MARK) < bootstrapOut.indexOf('<script>'),
98
+ )
99
+ assert('交互式 bootstrap:没有 CSP 时不硬塞 CSP', bootstrapOut.indexOf('Content-Security-Policy') < 0)
100
+
101
+ // 没有 head / html / charset 的碎片:插在最前(不能因为找不到锚点就丢掉)
102
+ const fragmentOut = api.bridgeIntoHtml('<p>裸片段</p>')
103
+ assert('碎片文档:插在最前', fragmentOut.indexOf('<script') === 0 && fragmentOut.indexOf('<p>裸片段</p>') > 0, fragmentOut.slice(0, 30))
104
+ assert('空串 / null 不注入', api.bridgeIntoHtml('') === null && api.bridgeIntoHtml(null) === null)
105
+
106
+ // 插入位置:doctype 之后(有 doctype 但没 html/head/charset 时)
107
+ const doctypeOut = api.insertIntoHtml('<!doctype html><p>t</p>', '<X>')
108
+ assert('只有 doctype 时插在 doctype 之后(不破坏怪异模式判断)', doctypeOut === '<!doctype html><X><p>t</p>', doctypeOut)
109
+
110
+ // ───────────────────────── ② 帧内脚本行为 ─────────────────────────
111
+
112
+ /** 把 bridgeBody 变成注入用的脚本本体(与 bridgeIntoHtml 里同一形态)。 */
113
+ const FRAME_SCRIPT = '(' + String(api.bridgeBody) + ')();'
114
+
115
+ /** 帧内的最小 DOM:够 getSelection + createRange + closest + innerText 用。 */
116
+ function makeFrameEnv(htmlText, selected, rect, containerTag, muteDocListeners) {
117
+ const sent = []
118
+ const timers = []
119
+
120
+ class Node {
121
+ constructor(tag, text) {
122
+ this.tagName = tag
123
+ this.nodeType = tag === '#text' ? 3 : 1
124
+ this.children = []
125
+ this.parentElement = null
126
+ this.parentNode = null
127
+ this._text = text || ''
128
+ this.className = ''
129
+ this.attrs = {}
130
+ }
131
+ appendChild(child) {
132
+ child.parentElement = this
133
+ child.parentNode = this
134
+ this.children.push(child)
135
+ return child
136
+ }
137
+ /** 桥里的 collectText 走 childNodes / nodeValue(真实 DOM 的文本节点属性)。 */
138
+ get childNodes() {
139
+ return this.children
140
+ }
141
+ get nodeValue() {
142
+ return this._text
143
+ }
144
+ get textContent() {
145
+ return (this._text || '') + this.children.map((c) => c.textContent).join('')
146
+ }
147
+ get innerText() {
148
+ return this.textContent
149
+ }
150
+ getAttribute(key) {
151
+ return this.attrs[key] ?? null
152
+ }
153
+ setAttribute(key, value) {
154
+ this.attrs[key] = String(value)
155
+ }
156
+ closest(selector) {
157
+ const want = String(selector).split(',').map((s) => s.trim().toUpperCase())
158
+ let node = this
159
+ while (node) {
160
+ if (want.includes(node.tagName)) return node
161
+ node = node.parentElement
162
+ }
163
+ return null
164
+ }
165
+ }
166
+
167
+ const body = new Node('BODY')
168
+ const paragraph = new Node(containerTag || 'P')
169
+ const textNode = new Node('#text', htmlText)
170
+ paragraph.appendChild(textNode)
171
+ body.appendChild(paragraph)
172
+
173
+ // 选区是可变的:拖拽用例要在"按住鼠标"期间改变它
174
+ const state = { text: selected, collapsed: false, rect: rect }
175
+ const range = {
176
+ startContainer: textNode,
177
+ get startOffset() {
178
+ return htmlText.indexOf(state.text)
179
+ },
180
+ toString: () => state.text,
181
+ getClientRects: () => [state.rect],
182
+ getBoundingClientRect: () => state.rect,
183
+ }
184
+ const selection = {
185
+ rangeCount: 1,
186
+ get isCollapsed() {
187
+ return state.collapsed
188
+ },
189
+ toString: () => state.text,
190
+ getRangeAt: () => range,
191
+ }
192
+
193
+ const doc = {
194
+ body,
195
+ title: 'fixture',
196
+ _listeners: new Map(),
197
+ addEventListener(type, handler) {
198
+ // muteDocListeners:模拟"document.write 把 document 上的监听冲掉了"的交互式预览,
199
+ // 这时只剩 window 上的监听 + 250ms 轮询兜底
200
+ if (muteDocListeners) return
201
+ if (!this._listeners.has(type)) this._listeners.set(type, [])
202
+ this._listeners.get(type).push(handler)
203
+ },
204
+ dispatch(type, event) {
205
+ for (const handler of [...(this._listeners.get(type) || [])]) handler(event)
206
+ },
207
+ hasFocus: () => true,
208
+ getSelection: () => selection,
209
+ createRange() {
210
+ return {
211
+ _node: null,
212
+ _end: null,
213
+ selectNodeContents(node) {
214
+ this._node = node
215
+ },
216
+ setEnd(node, offset) {
217
+ this._end = { node, offset }
218
+ },
219
+ toString() {
220
+ const full = this._node.textContent
221
+ return this._end ? full.slice(0, this._end.offset) : full
222
+ },
223
+ }
224
+ },
225
+ }
226
+
227
+ const win = {
228
+ _listeners: new Map(),
229
+ addEventListener(type, handler) {
230
+ if (!this._listeners.has(type)) this._listeners.set(type, [])
231
+ this._listeners.get(type).push(handler)
232
+ },
233
+ dispatch(type, event) {
234
+ for (const handler of [...(this._listeners.get(type) || [])]) handler(event)
235
+ },
236
+ getSelection: () => selection,
237
+ }
238
+
239
+ const parent = {
240
+ postMessage(message) {
241
+ sent.push(message)
242
+ },
243
+ }
244
+
245
+ const intervals = []
246
+ const run = new Function('window', 'document', 'parent', 'setTimeout', 'setInterval', FRAME_SCRIPT)
247
+ run(win, doc, parent, (fn, ms) => timers.push({ fn, ms }), (fn) => intervals.push(fn))
248
+ // 注入后脚本自己排了 setTimeout(report, 60):手动跑掉所有排队的定时器
249
+ for (const timer of timers.splice(0)) timer.fn()
250
+ return {
251
+ win,
252
+ doc,
253
+ sent,
254
+ timers,
255
+ intervals,
256
+ selection,
257
+ /** 跑掉排队的 setTimeout(mouseup 之后那次 report 就在里面)。 */
258
+ flushTimers() {
259
+ for (const timer of timers.splice(0)) timer.fn()
260
+ },
261
+ /** 跑一拍轮询(250ms 那次)。 */
262
+ tick() {
263
+ for (const fn of intervals) fn()
264
+ },
265
+ /** 改选区(拖拽中每动一下都算一次变化)。 */
266
+ setSelection(text, nextRect) {
267
+ state.text = text
268
+ state.collapsed = !text
269
+ if (nextRect) state.rect = nextRect
270
+ doc.dispatch('selectionchange', {})
271
+ },
272
+ }
273
+ }
274
+
275
+ const FIXTURE = 'PM: 这周能发布吗? Dev: the migration ran long, so we ship Wednesday.'
276
+ const RECT = { left: 12, top: 34, right: 190, bottom: 52, width: 178, height: 18 }
277
+ const env = makeFrameEnv(FIXTURE, 'the migration ran long', RECT)
278
+ const first = env.sent[env.sent.length - 1]
279
+ assert('帧内脚本:注入后立刻上报一次', !!first && first.__dshSel === 1 && first.kind === 'selection')
280
+ assert('帧内脚本:报的文字就是选中文字', first && first.sel && first.sel.text === 'the migration ran long', first && first.sel && first.sel.text)
281
+ assert('帧内脚本:报的是帧内视口坐标', first && first.sel.rect.x === 12 && first.sel.rect.bottom === 52 && first.sel.rect.w === 178, JSON.stringify(first && first.sel.rect))
282
+ assert(
283
+ '帧内脚本:上下文用【】标出选中部分,前后文都在',
284
+ first && first.sel.context === 'PM: 这周能发布吗? Dev: 【the migration ran long】, so we ship Wednesday.',
285
+ first && first.sel.context,
286
+ )
287
+ assert('帧内脚本:keyContext 只取选中之前那一小段', first && first.sel.keyContext === 'PM: 这周能发布吗? Dev:', first && first.sel.keyContext)
288
+ assert('帧内脚本:普通段落不给标签(标签由父页面按帧来源定)', first && first.sel.label === '', first && first.sel.label)
289
+
290
+ // 重复 report 不应该重复发(轮询每 250ms 一次)
291
+ env.win.dispatch('mouseup', {})
292
+ env.flushTimers()
293
+ assert('帧内脚本:同一选区 + 同一位置不重复上报', env.sent.filter((m) => m.kind === 'selection').length === 1, String(env.sent.filter((m) => m.kind === 'selection').length))
294
+
295
+ // 选区清空 → 发 clear(父页面据此收浮标)
296
+ env.setSelection('')
297
+ env.win.dispatch('mouseup', {})
298
+ env.flushTimers()
299
+ assert('帧内脚本:选区清空后发 clear', env.sent[env.sent.length - 1].kind === 'clear', env.sent[env.sent.length - 1].kind)
300
+
301
+ // ── 浮现时机(拖拽中不许冒浮标)──
302
+ const dragEnv = makeFrameEnv(FIXTURE, '', RECT)
303
+ dragEnv.setSelection('')
304
+ const dragSent = () => dragEnv.sent.filter((m) => m.kind === 'selection').length
305
+ dragEnv.win.dispatch('mousedown', {})
306
+ assert('帧内脚本:按下先发一条 press(父页面据此收浮标)', dragEnv.sent[dragEnv.sent.length - 1].kind === 'press', JSON.stringify(dragEnv.sent.map((m) => m.kind)))
307
+ dragEnv.setSelection('the')
308
+ dragEnv.setSelection('the migration')
309
+ dragEnv.setSelection('the migration ran long')
310
+ dragEnv.flushTimers()
311
+ assert('帧内脚本:拖拽进行中(按住鼠标)不上报', dragSent() === 0, String(dragSent()))
312
+ dragEnv.win.dispatch('mouseup', {})
313
+ dragEnv.flushTimers()
314
+ assert('帧内脚本:松手后才上报一次', dragSent() === 1, String(dragSent()))
315
+ assert('帧内脚本:松手那次报的就是最终选区', dragEnv.sent[dragEnv.sent.length - 1].sel.text === 'the migration ran long')
316
+
317
+ // 松手落在帧外(收不到 mouseup):轮询不能永久卡死
318
+ dragEnv.win.dispatch('mousedown', {})
319
+ const staleEnv = dragEnv
320
+ staleEnv.setSelection('ran long')
321
+ staleEnv.tick()
322
+ assert('帧内脚本:按住时轮询也不报', dragSent() === 1, String(dragSent()))
323
+ // 把"按下时间"往前拨:模拟松手发生在帧外、已经过了 STALE_PRESS_MS
324
+ const realNow = Date.now
325
+ Date.now = () => realNow() + 7000
326
+ staleEnv.tick()
327
+ staleEnv.tick()
328
+ Date.now = realNow
329
+ assert('帧内脚本:超时后轮询恢复上报(松手落在帧外也能自愈)', dragSent() === 2, String(dragSent()))
330
+
331
+ // 轮询的"稳定一拍"(交互式预览里 document 上的监听被 document.write 冲掉后,只剩这条路)
332
+ const pollEnv = makeFrameEnv(FIXTURE, '', RECT, 'P', true)
333
+ pollEnv.setSelection('migration')
334
+ pollEnv.tick()
335
+ assert('帧内脚本:轮询第一拍看到变化只记账、不报', pollEnv.sent.filter((m) => m.kind === 'selection').length === 0, String(pollEnv.sent.length))
336
+ pollEnv.tick()
337
+ assert('帧内脚本:稳定一拍后轮询上报', pollEnv.sent.filter((m) => m.kind === 'selection').length === 1, String(pollEnv.sent.length))
338
+
339
+ // 键盘扩选(Shift+方向键):没有 mousedown,selectionchange 立刻上报
340
+ const keyEnv = makeFrameEnv(FIXTURE, '', RECT)
341
+ keyEnv.setSelection('')
342
+ keyEnv.setSelection('the migration')
343
+ keyEnv.win.dispatch('keyup', { key: 'ArrowRight' })
344
+ keyEnv.flushTimers()
345
+ assert('帧内脚本:键盘扩选即时上报(不受拖拽闸门影响)', keyEnv.sent.filter((m) => m.kind === 'selection').length === 1, String(keyEnv.sent.length))
346
+
347
+ // ── 消失时机:别"自己消失" ──
348
+ // 页面重绘/动画期间 getClientRects() 会短暂拿不到矩形 → read() 返回 null。
349
+ // 这种瞬时读不到不能当成"选区没了",否则用户看到的就是浮标自己消失。
350
+ const flapEnv = makeFrameEnv(FIXTURE, '', RECT, 'P', true) // 关掉 document 监听,逼它走轮询
351
+ flapEnv.setSelection('migration')
352
+ flapEnv.tick()
353
+ flapEnv.tick()
354
+ assert('(准备)瞬时抖动前浮标是亮的', flapEnv.sent.filter((m) => m.kind === 'selection').length === 1)
355
+ flapEnv.setSelection('', null) // 读不到了(瞬时)
356
+ flapEnv.tick()
357
+ flapEnv.tick()
358
+ assert('帧内脚本:连续两拍读不到选区也不 clear(页面重绘的瞬时抖动)', flapEnv.sent.filter((m) => m.kind === 'clear').length === 0, JSON.stringify(flapEnv.sent.map((m) => m.kind)))
359
+ for (let i = 0; i < 6; i += 1) flapEnv.tick()
360
+ assert('帧内脚本:连续 6 拍都读不到才 clear(选区真的没了)', flapEnv.sent.filter((m) => m.kind === 'clear').length === 1, JSON.stringify(flapEnv.sent.map((m) => m.kind)))
361
+
362
+ // 滚动重报(帧内滚动挪浮标)不许有清空权
363
+ const scrollEnv = makeFrameEnv(FIXTURE, '', RECT, 'P', true)
364
+ scrollEnv.setSelection('migration')
365
+ scrollEnv.tick()
366
+ scrollEnv.tick()
367
+ scrollEnv.setSelection('')
368
+ scrollEnv.win.dispatch('scroll', {})
369
+ scrollEnv.flushTimers()
370
+ assert('帧内脚本:帧内滚动重报不清空(只有用户动作才有清空权)', scrollEnv.sent.filter((m) => m.kind === 'clear').length === 0, JSON.stringify(scrollEnv.sent.map((m) => m.kind)))
371
+
372
+ // 代码块里的选区 → 标签「代码块」(父页面拿它决定走「注释」提示词)
373
+ // 注意:容器判定与父页面 pickContainer 同规则(往上找到第一个 ≥120 字的元素),
374
+ // 所以 fixture 要够长,容器才会落在 <pre> 自己身上。
375
+ const CODE_FIXTURE = [
376
+ 'function clampSelection(text, max) {',
377
+ ' if (!text) return ""',
378
+ ' return text.length > max ? text.slice(0, max) : text',
379
+ '}',
380
+ 'export default clampSelection',
381
+ ].join('\n')
382
+ const codeEnv = makeFrameEnv(CODE_FIXTURE, 'return text.length > max ? text.slice(0, max) : text', RECT, 'PRE')
383
+ assert(
384
+ '帧内脚本:pre 容器里的选区带「代码块」标签',
385
+ codeEnv.sent.length === 1 && codeEnv.sent[0].sel.label === '代码块',
386
+ JSON.stringify(codeEnv.sent.map((m) => m.sel && m.sel.label)),
387
+ )
388
+
389
+ // 表格里的选区 → 标签「表格」
390
+ const TABLE_FIXTURE =
391
+ '| 说法 | 在此处的含义 |\n| --- | --- |\n| the migration | 发布前那次数据迁移,跑完才敢发版 |\n| ran long | 耗时超出预期,所以顺延到周三 |\n| ship | 发布、上线(这里指把这一版推出去) |'
392
+ const tableEnv = makeFrameEnv(TABLE_FIXTURE, '耗时超出预期', RECT, 'TABLE')
393
+ assert('帧内脚本:table 容器里的选区带「表格」标签', tableEnv.sent[0].sel.label === '表格', tableEnv.sent[0].sel.label)
394
+
395
+ // 输入框里的选区 → 一律不报(别干扰输入)
396
+ const inputEnv = makeFrameEnv('hello world', 'world', RECT, 'TEXTAREA')
397
+ assert('帧内脚本:输入框里的选区不报', inputEnv.sent.length === 0, String(inputEnv.sent.length))
398
+
399
+ // ping 消息 → hello + 当前选区(父页面用来探测桥在不在)
400
+ const pinged = []
401
+ env.setSelection('the migration ran long')
402
+ env.win.dispatch('message', { data: { __dshSel: 1, kind: 'ping' } })
403
+ env.flushTimers()
404
+ assert('帧内脚本:应答 ping(hello + 当前选区)', env.sent.some((m) => m.kind === 'hello') && env.sent.some((m) => m.kind === 'selection'), env.sent.map((m) => m.kind).join(','))
405
+ void pinged
406
+
407
+ console.log(`\n=== 划词桥单测结束:${failed === 0 ? '全部通过' : failed + ' 项失败'} ===`)
408
+ process.exit(failed === 0 ? 0 : 1)
@@ -328,6 +328,13 @@ function withAbort(response, signal) {
328
328
 
329
329
  const routeFetch = (input, init) => {
330
330
  const url = typeof input === 'string' ? input : ''
331
+ // blob:(划词桥的交互式预览用例):从内存表里读回我们自己建的那份文档。
332
+ // content-type 跟着 blob 的 type 走(真实浏览器就是这么给的)——插件要靠它搬 charset。
333
+ if (url.startsWith('blob:')) {
334
+ const blob = blobStore.get(url)
335
+ if (!blob) return Promise.reject(new Error('unknown blob: ' + url))
336
+ return Promise.resolve(new Response(blob, { headers: { 'content-type': blob.type || 'text/html' } }))
337
+ }
331
338
  // 模型清单:用 fixture(真实 host 有 41 个模型,跑用例时不该依赖它)
332
339
  if (url.indexOf('/selection-explain/api/models') >= 0) {
333
340
  const body = globalThis.__modelCatalogFixture || { ok: true, current: null, stages: null, models: [] }
@@ -759,13 +766,27 @@ const routeFetch = (input, init) => {
759
766
  }
760
767
  return fetch(typeof input === 'string' && input.startsWith('/') ? ORIGIN + input : input, init)
761
768
  }
762
-
763
769
  const browserFetch = (input, init) => {
764
770
  const out = routeFetch(input, init)
765
771
  const signal = init && init.signal
766
772
  if (!signal || !out || typeof out.then !== 'function') return out
767
773
  return out.then((response) => (response && response.body ? withAbort(response, signal) : response))
768
774
  }
775
+ /**
776
+ * 划词桥的 blob 桩:宿主交互式预览的外层文档就是 `blob:`(见 ui-sidebar-documentpreview
777
+ * 的 HtmlFrame)——桥要 fetch 它、再把注入过脚本的那份重新发成一个 blob 换上去。
778
+ */
779
+ const blobStore = new Map()
780
+ let blobSeq = 0
781
+ URL.createObjectURL = (blob) => {
782
+ blobSeq += 1
783
+ const url = `blob:stub/${blobSeq}`
784
+ blobStore.set(url, blob)
785
+ return url
786
+ }
787
+ URL.revokeObjectURL = (url) => {
788
+ blobStore.delete(url)
789
+ }
769
790
  new Function('window', 'document', 'location', 'navigator', 'requestAnimationFrame', 'fetch', source)(
770
791
  windowStub,
771
792
  documentStub,
@@ -2503,6 +2524,204 @@ assert('点回来看到"已停止"的状态与可重试入口', hook.state().pha
2503
2524
  assert('删除键不会误触发回放(回放会重新写历史,这里应为 0 次)', replayHits.length === 0, String(replayHits.length))
2504
2525
  }
2505
2526
 
2527
+ // ───────────────────────── 侧边栏网页(iframe)划词桥 ─────────────────────────
2528
+ // 侧边栏 HTML 预览是不透明源沙箱 iframe:父页面读不到里面的选区,
2529
+ // 所以桥由帧内主动上报。这一段验的是**父侧接线**:注入、来源校验、坐标换算、
2530
+ // 上下文与标签走通、帧内清空选区后收浮标。
2531
+ let bridgeOwnedBlob = ''
2532
+ {
2533
+ // 上一段(历史列表)是开着面板测的;划词桥按老规矩"面板开着时不另开一个",
2534
+ // 先收起来,否则下面每条消息都会被 panelOpen 挡掉(那就测了个寂寞)。
2535
+ hook.close()
2536
+ const beforeBridge = hook.selection()
2537
+
2538
+ /** 宿主基础预览(BasicHtmlFrame):DOMPurify 清洗 + script-src 'none',srcdoc + sandbox=""。 */
2539
+ const SANITIZED =
2540
+ '<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ' +
2541
+ "script-src 'none'; style-src 'unsafe-inline'\"><style>body{color:red}</style></head>" +
2542
+ '<body><p>the migration ran long, so we ship Wednesday</p></body></html>'
2543
+ const frame = new FakeEl('iframe')
2544
+ frame.setAttribute('data-html-preview', 'true')
2545
+ frame.setAttribute('sandbox', '')
2546
+ frame.setAttribute('srcdoc', SANITIZED)
2547
+ frame.isConnected = true
2548
+ const frameWindow = { name: 'sidebar-frame' }
2549
+ frame.contentWindow = frameWindow
2550
+
2551
+ documentStub.querySelectorAll = (selector) => (selector === 'iframe' ? [frame] : [])
2552
+ const scanned = hook.bridgeScan()
2553
+ assert('扫到侧边栏预览帧并桥上', scanned.frames.length === 1 && scanned.frames[0].srcdoc === true, JSON.stringify(scanned.frames))
2554
+
2555
+ const injected = frame.getAttribute('srcdoc')
2556
+ assert('桥脚本注入了 srcdoc(内容没被替换掉)', injected.indexOf('data-dsh-sel-bridge') > 0 && injected.indexOf('the migration ran long, so we ship Wednesday') > 0)
2557
+ assert('基础预览的 CSP 只放宽 script-src', /script-src 'unsafe-inline'/.test(injected) && /default-src 'none'/.test(injected))
2558
+ assert('沙箱补 allow-scripts,但不给 allow-same-origin', frame.getAttribute('sandbox') === 'allow-scripts', String(frame.getAttribute('sandbox')))
2559
+
2560
+ // 陌生窗口发来的同款消息一律不理(消息可以被任意页面伪造,来源必须对得上)
2561
+ windowStub.dispatch('message', { source: { name: 'attacker' }, data: { __dshSel: 1, kind: 'selection', sel: { text: '伪造', context: '', rect: { x: 0, y: 0, right: 10, bottom: 10, w: 10, h: 10 } } } })
2562
+ const afterForged = hook.selection()
2563
+ assert(
2564
+ '陌生来源的消息被忽略(选区没被改写)',
2565
+ (beforeBridge === null && afterForged === null) || (!!afterForged && afterForged.text === beforeBridge.text && afterForged.source === beforeBridge.source),
2566
+ JSON.stringify(afterForged),
2567
+ )
2568
+ assert('陌生来源的消息不会点亮 bridge()', hook.bridge().active === false)
2569
+
2570
+ // 帧内报来一条选区:帧内视口坐标 + iframe 自身的 getBoundingClientRect(桩:left 40 / top 100)
2571
+ windowStub.dispatch('message', {
2572
+ source: frameWindow,
2573
+ data: {
2574
+ __dshSel: 1,
2575
+ kind: 'selection',
2576
+ sel: {
2577
+ text: 'the migration ran long',
2578
+ context: 'PM: 这周能发布吗? Dev: 【the migration ran long】, so we ship Wednesday.',
2579
+ keyContext: 'PM: 这周能发布吗? Dev:',
2580
+ label: '',
2581
+ rect: { x: 10, y: 20, right: 120, bottom: 38, w: 110, h: 18 },
2582
+ },
2583
+ },
2584
+ })
2585
+ const bridgeSelection = hook.selection()
2586
+ assert('帧内选区让浮标出现在「帧偏移 + 帧内坐标」处', button.style.display === 'inline-flex' && button.style.left === '98px' && button.style.top === '85px', button.style.left + '/' + button.style.top)
2587
+ assert('选区记成 iframe 来源', !!bridgeSelection && bridgeSelection.source === 'iframe' && bridgeSelection.text === 'the migration ran long', JSON.stringify(bridgeSelection))
2588
+ assert('标签按帧来源给(侧边栏网页)', bridgeSelection.label === '侧边栏网页', bridgeSelection.label)
2589
+ assert('上下文由帧内带来(含【】标记)', bridgeSelection.context.indexOf('【the migration ran long】') > 0, bridgeSelection.context)
2590
+ assert('bridge() 报 active', hook.bridge().active === true)
2591
+
2592
+ // 交互式预览:外层是宿主 bootstrap(blob: + document.write),桥要换一份带脚本的 blob 上去
2593
+ const BOOTSTRAP =
2594
+ '<!doctype html><meta charset="utf-8"><script>(()=>{document.open();document.write("<p>the migration ran long, so we ship Wednesday</p>");document.close()})()</scr' + 'ipt>'
2595
+ const originalBlob = URL.createObjectURL(new Blob([BOOTSTRAP], { type: 'text/html;charset=utf-8' }))
2596
+ const blobFrame = new FakeEl('iframe')
2597
+ blobFrame.setAttribute('data-html-preview', 'true')
2598
+ blobFrame.setAttribute('sandbox', 'allow-scripts')
2599
+ blobFrame.setAttribute('src', originalBlob)
2600
+ blobFrame.isConnected = true
2601
+ const blobWindow = { name: 'sidebar-blob-frame' }
2602
+ blobFrame.contentWindow = blobWindow
2603
+ documentStub.querySelectorAll = (selector) => (selector === 'iframe' ? [frame, blobFrame] : [])
2604
+ hook.bridgeScan()
2605
+ await sleep(20) // fetch(blob) → 注入 → 换 src 是异步的
2606
+ bridgeOwnedBlob = blobFrame.getAttribute('src')
2607
+ assert('交互式预览:src 换成我们重发的那份 blob', bridgeOwnedBlob !== originalBlob && bridgeOwnedBlob.startsWith('blob:'), bridgeOwnedBlob)
2608
+ assert('交互式预览:换上去的文档里有桥脚本', String(await (await browserFetch(bridgeOwnedBlob)).text()).indexOf('data-dsh-sel-bridge') > 0)
2609
+ assert(
2610
+ '交互式预览:重发时把原 blob 的 content-type 搬过来(charset 不能丢,否则整页乱码)',
2611
+ (blobStore.get(bridgeOwnedBlob) || {}).type === 'text/html;charset=utf-8',
2612
+ String((blobStore.get(bridgeOwnedBlob) || {}).type),
2613
+ )
2614
+ assert('交互式预览:宿主原来那份 blob 不归我们动(它自己回收)', blobStore.has(originalBlob))
2615
+ assert('交互式预览:我们自己那份 blob 登记在册(清理时回收)', hook.bridge().frames.some((item) => item.own === true))
2616
+
2617
+ // 帧内清空选区 → 收浮标(顶层选区不会塌,所以只能靠这条消息)
2618
+ windowStub.dispatch('message', { source: frameWindow, data: { __dshSel: 1, kind: 'clear' } })
2619
+ assert('帧内清空选区后收浮标', button.style.display === 'none' && hook.bridge().active === false)
2620
+
2621
+ // ── 消失时机:帧内的"按下"也要收浮标(父页面收不到帧里的 mousedown)──
2622
+ windowStub.dispatch('message', {
2623
+ source: frameWindow,
2624
+ data: {
2625
+ __dshSel: 1,
2626
+ kind: 'selection',
2627
+ sel: { text: '按住前那一段', context: '【按住前那一段】', keyContext: '', label: '', rect: { x: 4, y: 6, right: 40, bottom: 24, w: 36, h: 18 } },
2628
+ },
2629
+ })
2630
+ assert('(准备)选区先让浮标亮着', button.style.display === 'inline-flex')
2631
+ windowStub.dispatch('message', { source: frameWindow, data: { __dshSel: 1, kind: 'press' } })
2632
+ assert('帧内按下:浮标立刻收起', button.style.display === 'none')
2633
+ assert('帧内按下:这条选区本身还留着(松手后可能还要用)', hook.selection() !== null && hook.selection().source === 'iframe', JSON.stringify(hook.selection()))
2634
+
2635
+ // 按下的若是**另一个**网页:那条选区已经不是用户在看的东西了,状态一起清
2636
+ windowStub.dispatch('message', {
2637
+ source: frameWindow,
2638
+ data: {
2639
+ __dshSel: 1,
2640
+ kind: 'selection',
2641
+ sel: { text: '另一个帧的词', context: '【另一个帧的词】', keyContext: '', label: '', rect: { x: 4, y: 6, right: 40, bottom: 24, w: 36, h: 18 } },
2642
+ },
2643
+ })
2644
+ windowStub.dispatch('message', { source: blobWindow, data: { __dshSel: 1, kind: 'press' } })
2645
+ assert('在另一个网页里按下:浮标收起且旧选区状态清掉', button.style.display === 'none' && hook.selection() === null && hook.bridge().active === false)
2646
+
2647
+ // 交互式预览那一帧报来的选区 → 点浮标 → 面板用的就是桥给的上下文
2648
+ windowStub.dispatch('message', {
2649
+ source: blobWindow,
2650
+ data: {
2651
+ __dshSel: 1,
2652
+ kind: 'selection',
2653
+ sel: {
2654
+ text: 'slipped',
2655
+ context: 'the deadline 【slipped】 to Wednesday',
2656
+ keyContext: 'the deadline',
2657
+ label: '代码块',
2658
+ rect: { x: 8, y: 12, right: 60, bottom: 30, w: 52, h: 18 },
2659
+ },
2660
+ },
2661
+ })
2662
+ assert('第二帧的选区也能认(多标签页)', button.style.display === 'inline-flex' && hook.selection().text === 'slipped', JSON.stringify(hook.selection()))
2663
+ assert('帧内给的标签优先(代码块)', hook.selection().label === '代码块', hook.selection().label)
2664
+ button.dispatch('click', { preventDefault() {}, stopPropagation() {} })
2665
+ const bridgePayload = hook.payload()
2666
+ assert('点浮标后面板用桥给的上下文(不读顶层 DOM)', !!bridgePayload && bridgePayload.context === 'the deadline 【slipped】 to Wednesday', bridgePayload && bridgePayload.context)
2667
+ assert('面板标签也是桥给的', bridgePayload.label === '代码块', bridgePayload && bridgePayload.label)
2668
+ hook.close()
2669
+
2670
+ // ── 别的插件在侧边栏渲染的生成网页(「图解」那种:blob: + allow-scripts,没有宿主标记)──
2671
+ const visualFrame = new FakeEl('iframe')
2672
+ visualFrame.setAttribute('class', 'dsv-frame')
2673
+ visualFrame.setAttribute('title', '图解')
2674
+ visualFrame.setAttribute('sandbox', 'allow-scripts allow-forms allow-modals allow-popups')
2675
+ visualFrame.setAttribute('src', URL.createObjectURL(new Blob(['<!doctype html><html><head><meta charset="utf-8"></head><body><p>图解页</p></body></html>'], { type: 'text/html' })))
2676
+ visualFrame.isConnected = true
2677
+ const visualWindow = { name: 'reply-visual-frame' }
2678
+ visualFrame.contentWindow = visualWindow
2679
+
2680
+ // 反面用例:不该被动的帧
2681
+ const opaqueFrame = new FakeEl('iframe') // 没有 allow-scripts 的第三方帧:不给它加权限
2682
+ opaqueFrame.setAttribute('sandbox', '')
2683
+ opaqueFrame.setAttribute('srcdoc', '<!doctype html><p>别人的沙箱</p>')
2684
+ opaqueFrame.isConnected = true
2685
+ const remoteFrame = new FakeEl('iframe') // 远端站点:没有任何注入手段
2686
+ remoteFrame.setAttribute('sandbox', 'allow-scripts')
2687
+ remoteFrame.setAttribute('src', 'https://example.com/')
2688
+ remoteFrame.isConnected = true
2689
+ const ownFrame = new FakeEl('iframe') // 我们自己小窗里的网页答案预览
2690
+ ownFrame.setAttribute('data-preview-id', 'pv1')
2691
+ ownFrame.setAttribute('sandbox', 'allow-scripts')
2692
+ ownFrame.setAttribute('srcdoc', '<!doctype html><p>生成的网页</p>')
2693
+ ownFrame.isConnected = true
2694
+ panel.appendChild(ownFrame)
2695
+
2696
+ documentStub.querySelectorAll = (selector) =>
2697
+ selector === 'iframe' ? [frame, blobFrame, visualFrame, opaqueFrame, remoteFrame, ownFrame] : []
2698
+ hook.bridgeScan()
2699
+ await sleep(20) // visualFrame 的 blob 分支也是异步的
2700
+ const bridgedNow = hook.bridge().frames
2701
+ assert(
2702
+ '「图解」那种 blob 帧也被桥上(不只认宿主标记)',
2703
+ bridgedNow.length === 3 && visualFrame.getAttribute('data-dsh-sel-bridged') === '1',
2704
+ JSON.stringify(bridgedNow),
2705
+ )
2706
+ assert('第三方没有 allow-scripts 的沙箱帧不动它(不给别人加权限)', opaqueFrame.getAttribute('data-dsh-sel-bridged') === null && opaqueFrame.getAttribute('sandbox') === '')
2707
+ assert('远端站点 iframe 不动它', remoteFrame.getAttribute('data-dsh-sel-bridged') === null)
2708
+ assert('我们小窗里的网页答案预览不桥(面板开着也不会用它)', ownFrame.getAttribute('data-dsh-sel-bridged') === null)
2709
+ assert('「图解」帧的 src 换成了我们重发的那份', visualFrame.getAttribute('src').indexOf('blob:') === 0 && visualFrame.getAttribute('src') !== null)
2710
+
2711
+ windowStub.dispatch('message', {
2712
+ source: visualWindow,
2713
+ data: {
2714
+ __dshSel: 1,
2715
+ kind: 'selection',
2716
+ sel: { text: '图解里的词', context: '【图解里的词】的上下文', keyContext: '', label: '', rect: { x: 5, y: 6, right: 40, bottom: 22, w: 35, h: 16 } },
2717
+ },
2718
+ })
2719
+ const visualSelection = hook.selection()
2720
+ assert('「图解」帧的选区也认', !!visualSelection && visualSelection.text === '图解里的词', JSON.stringify(visualSelection))
2721
+ assert('位置标签取帧自己的 title(图解)', visualSelection.label === '图解', visualSelection.label)
2722
+ hook.close()
2723
+ }
2724
+
2506
2725
  // ───────────────────────── 关闭 / 清理 ─────────────────────────
2507
2726
  hook.close()
2508
2727
  assert('Esc/关闭后隐藏', panel.style.display === 'none')
@@ -2514,6 +2733,7 @@ for (const dispose of disposers.reverse()) {
2514
2733
  }
2515
2734
  }
2516
2735
  assert('清理后 DOM 归零', mount.children.length === 0 && body.children.indexOf(container) >= 0)
2736
+ assert('清理后划词桥自己建的预览 blob 被回收', bridgeOwnedBlob !== '' && !blobStore.has(bridgeOwnedBlob), bridgeOwnedBlob)
2517
2737
  assert('清理后钩子移除', windowStub.__dshSelectionExplain === undefined)
2518
2738
  console.log('\n=== 客户端集成测试结束 ===')
2519
2739